Compare commits

...
12 Commits
Author SHA1 Message Date
Ultradesu 2b254f417f Updated readme 2026-08-12 01:41:00 +01:00
Ultradesu 69883af8bd Reworked settings page
Build and Publish / Build and Publish Docker Image (push) Successful in 3m28s
2026-08-11 12:26:16 +01:00
Aleksandr Bogomiakov 6f337ee626 Add DHT routing for similarity search
Build and Publish / Build and Publish Docker Image (push) Successful in 3m30s
2026-08-10 19:54:40 +01:00
Aleksandr Bogomiakov 77cbb62d84 Added similarity search
Build and Publish / Build and Publish Docker Image (push) Successful in 3m29s
2026-08-10 01:44:40 +01:00
Ultradesu 05d68724f3 Fixed invite logic to be lightweight
Build and Publish / Build and Publish Docker Image (push) Successful in 4m13s
2026-07-29 14:21:32 +01:00
Ultradesu d71845509d Bump protocols. Added proto status
Build and Publish / Build and Publish Docker Image (push) Successful in 6m1s
2026-07-28 23:01:57 +01:00
Ultradesu 4efcfdc539 Bump protocols. Added proto status
Build and Publish / Build and Publish Docker Image (push) Successful in 5m25s
2026-07-28 22:46:43 +01:00
Ultradesu d61d7a6bac Bump protocols. Added proto status
Build and Publish / Build and Publish Docker Image (push) Successful in 5m13s
2026-07-28 22:20:33 +01:00
Ultradesu cad8b2280f Added favicon 2026-07-28 21:27:48 +01:00
Ultradesu 3a75c7d848 Fixed history DTO view
Build and Publish / Build and Publish Docker Image (push) Successful in 4m6s
2026-07-28 08:34:29 +01:00
Ultradesu dc1fac1a94 hotfix
Build and Publish / Build and Publish Docker Image (push) Successful in 5m51s
2026-07-28 00:01:36 +01:00
Ultradesu 122214a896 Fixed playback history window
Build and Publish / Build and Publish Docker Image (push) Successful in 5m20s
2026-07-27 23:41:29 +01:00
26 changed files with 4902 additions and 393 deletions
+2
View File
@@ -3,3 +3,5 @@
/.claude
/media
/federation
/similarity-models
/federation-cache
Generated
+618 -7
View File
@@ -159,6 +159,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 = "arc-swap"
version = "1.9.2"
@@ -488,6 +494,24 @@ dependencies = [
"virtue",
]
[[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 = "bitflags"
version = "1.3.2"
@@ -1069,6 +1093,16 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.20"
@@ -1093,6 +1127,12 @@ version = "0.8.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
@@ -1284,6 +1324,12 @@ dependencies = [
"serde",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "data-encoding"
version = "2.11.0"
@@ -1341,6 +1387,17 @@ dependencies = [
"serde_core",
]
[[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_builder"
version = "0.20.2"
@@ -1493,12 +1550,30 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "downcast-rs"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
[[package]]
name = "dyn-clone"
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 = "ecdsa"
version = "0.16.9"
@@ -1641,6 +1716,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"
@@ -1707,8 +1793,9 @@ dependencies = [
[[package]]
name = "federation-net"
version = "0.1.0"
source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e690b370c505d153bef214b21a8f2aa55d667367ac1e16bde8bc0de88963c2"
dependencies = [
"blake3",
"data-encoding",
@@ -1747,6 +1834,16 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "filetime"
version = "0.2.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
dependencies = [
"cfg-if",
"libc",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -1763,6 +1860,12 @@ dependencies = [
"miniz_oxide",
]
[[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.11.1"
@@ -1835,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.9.4"
version = "0.10.2"
dependencies = [
"anyhow",
"async-stream",
@@ -1847,6 +1950,7 @@ dependencies = [
"cot",
"croner",
"encoding_rs",
"futures-util",
"id3",
"image",
"librqbit",
@@ -1855,6 +1959,8 @@ dependencies = [
"openidconnect",
"postcard",
"reqwest 0.12.28",
"rodio",
"rustfft",
"schemars 0.9.0",
"serde",
"serde_json",
@@ -1866,6 +1972,7 @@ dependencies = [
"tower",
"tracing",
"tracing-subscriber",
"tract-onnx",
"uuid",
]
@@ -2190,6 +2297,18 @@ dependencies = [
"tracing",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"num-traits",
"zerocopy",
]
[[package]]
name = "hash32"
version = "0.2.1"
@@ -2235,6 +2354,8 @@ dependencies = [
"allocator-api2",
"equivalent",
"foldhash 0.2.0",
"serde",
"serde_core",
]
[[package]]
@@ -2798,6 +2919,15 @@ dependencies = [
"smallvec",
]
[[package]]
name = "inventory"
version = "0.3.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
dependencies = [
"rustversion",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
@@ -3490,6 +3620,12 @@ dependencies = [
"tracing",
]
[[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"
@@ -3505,6 +3641,16 @@ version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "matrixmultiply"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
dependencies = [
"autocfg",
"rawpointer",
]
[[package]]
name = "md-5"
version = "0.10.6"
@@ -3530,6 +3676,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 = "mime"
version = "0.3.17"
@@ -3546,6 +3698,16 @@ dependencies = [
"unicase",
]
[[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 = "miniz_oxide"
version = "0.8.9"
@@ -3613,8 +3775,9 @@ dependencies = [
[[package]]
name = "music-dht"
version = "0.2.0"
source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c5b429b90a8f1b0980b3a35a6fa5445d7a275c737eb04db18db4d7f14c81478"
dependencies = [
"async-trait",
"blake3",
@@ -3702,6 +3865,21 @@ dependencies = [
"tempfile",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex 0.4.6",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndk-context"
version = "0.1.1"
@@ -3833,6 +4011,24 @@ dependencies = [
"winapi",
]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "nom-language"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29"
dependencies = [
"nom",
]
[[package]]
name = "nonzero_ext"
version = "0.3.0"
@@ -3916,10 +4112,20 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36"
dependencies = [
"num-complex",
"num-complex 0.2.4",
"num-integer",
"num-iter",
"num-rational",
"num-rational 0.2.4",
"num-traits",
]
[[package]]
name = "num-bigint"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
dependencies = [
"num-integer",
"num-traits",
]
@@ -3949,6 +4155,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@@ -3996,6 +4211,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -4350,6 +4576,12 @@ version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -4560,6 +4792,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"
@@ -4649,6 +4890,15 @@ dependencies = [
"num-traits",
]
[[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 = "primeorder"
version = "0.13.6"
@@ -4676,6 +4926,29 @@ dependencies = [
"unicode-ident",
]
[[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 = "pxfm"
version = "0.1.30"
@@ -4881,6 +5154,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"
@@ -4899,6 +5182,32 @@ dependencies = [
"bitflags 2.13.1",
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -5097,6 +5406,18 @@ dependencies = [
"libc",
]
[[package]]
name = "rodio"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
dependencies = [
"dasp_sample",
"num-rational 0.4.2",
"symphonia",
"thiserror 2.0.19",
]
[[package]]
name = "rsa"
version = "0.9.10"
@@ -5152,6 +5473,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 0.4.6",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]]
name = "rustix"
version = "1.1.4"
@@ -5252,6 +5587,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"
@@ -5261,6 +5609,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"
@@ -5994,6 +6351,22 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[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 = "stringprep"
version = "0.1.5"
@@ -6327,6 +6700,17 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tar"
version = "0.4.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
dependencies = [
"filetime",
"libc",
"xattr",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@@ -6786,12 +7170,229 @@ 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",
"dyn-clone",
"dyn-eq",
"erased-serde",
"inventory",
"lazy_static",
"log",
"maplit",
"ndarray",
"num-complex 0.4.6",
"num-integer",
"num-traits",
"pastey",
"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",
"dyn-clone",
"dyn-eq",
"dyn-hash",
"half",
"inventory",
"itertools 0.14.0",
"lazy_static",
"libm",
"maplit",
"ndarray",
"nom",
"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",
"dyn-clone",
"dyn-eq",
"dyn-hash",
"half",
"lazy_static",
"log",
"minijinja",
"num-traits",
"pastey",
"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",
"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",
"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",
"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"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[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"
@@ -7557,6 +8158,16 @@ dependencies = [
"tap",
]
[[package]]
name = "xattr"
version = "1.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
dependencies = [
"libc",
"rustix",
]
[[package]]
name = "xml-rs"
version = "0.8.28"
+7 -3
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.9.4"
version = "0.10.2"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
@@ -12,7 +12,7 @@ cot = { version = "0.6.0", default-features = false, features = ["postgres", "js
schemars = { version = "0.9", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
openidconnect = "4.0"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
async-stream = "0.3"
bytes = "1"
@@ -31,6 +31,10 @@ md-5 = "0.10"
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
anyhow = "1.0"
futures-util = "0.3"
rodio = { version = "0.22.2", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac", "symphonia-adpcm", "symphonia-aiff", "symphonia-mkv", "symphonia-pcm"] }
rustfft = "6.4.1"
tract-onnx = "0.23.4"
tokio-cron-scheduler = "0.15"
croner = "3"
async-trait = "0.1"
@@ -39,4 +43,4 @@ uuid = "1"
librqbit = { version = "8.1.1", features = ["disable-upload"] }
# P2P federation: publishes the library into a shared DHT and serves audio /
# catalogs to furumi peers (TUI clients) over the frid stack.
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
music-dht = "0.4.0"
+125 -183
View File
@@ -1,201 +1,143 @@
# furumusic
# Furumusic
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
**Your library. Your users. Your network.**
Built with Rust ([cot](https://cot.rs) framework).
Furumusic is a self-hosted, multi-user music server with a full web player and
support for the Furumi federated network. It turns a collection of music files
into a shared library that is available from any modern browser, while every
user keeps their own playlists, likes, listening history, and playback state.
## Quick start
Music can be uploaded directly, imported from a `.torrent` file, or downloaded
from a magnet link. An optional AI-assisted import pipeline reads the available
tags and path information, reconstructs inconsistent metadata, finds artwork,
and organizes the result into artists, releases, and tracks. Uncertain matches
are kept for review instead of silently entering the library with bad data.
## Why Furumusic?
Furumusic is for a household, a small community, or anyone who wants one music
library without handing it to a subscription service. The server owns the
catalog and media files; the browser is only the player.
- one shared library with separate user accounts;
- a responsive web player with artists, releases, search, queue, and playlists;
- direct file uploads and torrent or magnet imports;
- optional AI-assisted recognition and normalization of metadata;
- password login or OIDC/SSO with group-based access control;
- optional federation without a central catalog or search service;
- trusted-device pairing, playback handoff, and synchronization between Furumi
players;
- Last.fm scrobbling and similarity-based discovery when configured.
Furumusic does not include music. Import only media you are allowed to store and
share.
## Importing music
Users can add audio files from the web interface or ask the server to download
selected files from a torrent. Both routes feed the same import pipeline, so a
library remains consistent regardless of where its files came from.
The importer combines embedded tags, filenames, folder structure, and existing
catalog context. With an OpenAI-compatible language model configured, it can
normalize artist and release names, separate featured artists, recover track
numbers and release types, and flag ambiguous results for a user to approve.
Cover art is taken from nearby image files or embedded artwork when available.
The inbox and permanent library are separate directories. New files are first
processed in the inbox and are moved into the organized library only after
their metadata has been accepted.
## Federation
Federation is optional. Independent Furumusic and Furumi players that use the
same Network ID discover one another as a logical network. Each peer keeps its
own library and can continue working alone.
When federation is enabled, local and remote artists, releases, and tracks can
appear in the same search and library views. Missing audio is streamed from an
available peer and can optionally be retained locally. Similarity search also
stays decentralized: embeddings and exact ranking remain on the instance that
owns the music.
Trusted-device pairing is separate from public catalog discovery. It connects
a user's own Furumi players so likes, playlists, playback state, and control can
move between approved devices.
## Build and run
Furumusic requires PostgreSQL and a Rust toolchain with Rust 2024 edition
support. Create an empty database, provide its connection URL, and start the
server:
```bash
export FURU_DATABASE_URL=postgresql://user:pass@localhost/furumusic
cargo run
# Open http://localhost:8000/admin/setup to create the first admin account
export FURU_DATABASE_URL='postgresql://furumusic:password@127.0.0.1/furumusic'
cargo run --release --locked
```
## Project structure
Open <http://127.0.0.1:8000/admin/setup> to create the first administrator.
After setup, configure the inbox and library directories under **Admin →
Settings** before importing music.
To listen on another address or port:
```bash
cargo run --release --locked -- -l 0.0.0.0:8000
```
Cargo.toml Project manifest and dependencies
build.rs Captures rustc version + target at compile time
src/
main.rs Entrypoint; HTTP router, login/logout handlers, tracing init
config.rs 3-tier config system (default → DB → env); FURU_* env vars
auth.rs Session auth, Role enum (Admin/User), login/logout/guards
user.rs User + OidcLink DB models, CRUD, password hashing, migrations
oidc.rs OIDC/SSO flow: discovery, PKCE, token exchange, user provisioning
i18n/
mod.rs Language resolution (cookie → Accept-Language → default), extractor
phrases.rs All UI strings in English and Russian (translations! macro)
api/
mod.rs JSON API endpoints (mounted at /api), session-based auth
admin/
mod.rs Admin sub-app router: dashboard, settings, users, debug, setup
views.rs Admin page handlers and templates
templates/
base.html Root HTML layout with lang/title blocks
login.html Login page (password + optional SSO button)
admin/
layout.html Admin sidebar/nav wrapper
index.html Admin dashboard
debug.html Build info + config table (with secret redaction)
settings.html OIDC and auth settings form
setup.html First-run admin account creation
users.html User list
user_form.html User create/edit form
A Nix development shell is included for Linux and macOS:
```bash
nix develop
cargo run --locked
```
The repository also contains a multi-stage `Dockerfile` for building a small
runtime image. A deployment must provide PostgreSQL plus persistent, writable
volumes for the inbox and music library.
## Configuration
Most settings can be changed from the administration interface. Every setting
also has a `FURU_`-prefixed environment variable; environment values take
priority over values stored in PostgreSQL.
The settings needed for a useful first installation are:
| Setting | Purpose |
| --- | --- |
| `FURU_DATABASE_URL` | PostgreSQL connection URL; required to run the service |
| `FURU_AGENT_INBOX_DIR` | Temporary inbox for uploads and downloaded files |
| `FURU_AGENT_STORAGE_DIR` | Permanent, organized music library |
| `FURU_AGENT_ENABLED` | Enables the background metadata import pipeline |
| `FURU_AGENT_LLM_URL` | Base URL of an OpenAI-compatible model server |
| `FURU_AGENT_LLM_MODEL` | Model used to recognize and normalize metadata |
| `FURU_FEDERATION_ENABLED` | Publishes the library and enables peer discovery |
| `FURU_FEDERATION_NETWORK_ID` | Joins peers with the same value into one logical network |
AI recognition, federation, similarity search, Last.fm, and OIDC are optional.
A local password-authenticated server can be used without any of them.
## Architecture
### Config system (`src/config.rs`)
Furumusic is written in Rust on the
[Cot](https://cot.rs) web framework. PostgreSQL stores the catalog, accounts,
playlists, configuration, and background-job state. Audio inspection uses
Symphonia, torrent downloads use librqbit, and the shared `music-dht`/Frid
protocol stack provides decentralized catalog search, media transfer, and
connected-device synchronization.
Every setting lives in `AppConfig` and is resolved in three layers:
The browser interface and JSON API are served by the same application. Import,
artwork, metadata enrichment, similarity indexing, and maintenance run as
durable background jobs rather than blocking playback requests.
1. **Compiled default**`AppConfig::default()`
2. **Database override** — rows in the `furumusic__config_entry` table
3. **Environment variable**`FURU_<FIELD_NAME>` (highest priority)
## Contributing
`ConfigSources` tracks where each field's effective value came from (shown in the admin debug page).
Bug reports, design discussions, and patches are welcome. Before submitting a
change, run:
**To add a new config field:**
1. Add the field to `AppConfig` struct
2. Set its default in `AppConfig::default()`
3. Add the field to `ConfigSources` struct and its `Default` impl
4. Add it to the `impl_env_overrides!(…)` invocation
5. Add an `apply_db_field!()` call in `apply_db_overrides`
6. Add an `entry!()` line in `admin/views.rs → config_display_entries()`
### Auth (`src/auth.rs`)
Session-based authentication with two roles:
- **`Role::Admin`** — full access to admin panel
- **`Role::User`** — standard user
Key functions:
- `login(session, user_id)` — sets session, cycles session ID
- `logout(session)` — flushes session
- `get_session_user(session, db)` — returns `AuthenticatedUser` if active
- `require_admin_or_redirect(session, db)` — guard that returns 403 or redirects to `/login`
### OIDC/SSO (`src/oidc.rs`)
Full OpenID Connect authorization code flow with PKCE:
1. `GET /auth/oidc/start` — discovers provider, builds auth URL, stores CSRF/nonce/PKCE in session, redirects to IdP
2. `GET /auth/oidc/callback` — validates CSRF, exchanges code for tokens, verifies ID token, provisions user
Provider metadata is cached for 1 hour and invalidated when OIDC config changes.
**Group access and role mapping:** The `oidc_user_groups` config field lists OIDC group names (comma-separated) allowed to access the service. When it is set, users outside both `oidc_user_groups` and `oidc_admin_groups` are denied before provisioning/login. The `oidc_admin_groups` config field lists OIDC group names that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload.
**User provisioning order:**
1. Find existing `OidcLink` by issuer+sub → update claims, update role
2. Find existing `User` by email → create OidcLink, update role
3. Create new user (no password) + OidcLink
Stale links (pointing to deleted users) are cleaned up automatically.
### User model (`src/user.rs`)
Two database models:
- **`User`** — id, username (unique), password (optional for OIDC-only), email, display_name, avatar_url, role, is_active
- **`OidcLink`** — id, user_id, issuer, sub, email, name, avatar_url; unique index on (issuer, sub)
Migrations: M0003 (User table), M0004 (OidcLink table), M0005 (OidcLink indexes).
### i18n (`src/i18n/`)
Compile-time bilingual UI (English + Russian).
- `translations!` macro in `phrases.rs` generates a `Translations` struct with static `EN` and `RU` instances
- Language resolution: `furu_lang` cookie → `Accept-Language` header → English default
- `I18n` is a cot request extractor — handlers receive it automatically
- `set_lang` endpoint (`/set-lang?lang=ru&next=/`) sets the cookie
### API (`src/api/`)
JSON API mounted at `/api`. Uses the same session cookie as HTML pages — works automatically for same-origin frontend requests (no CORS, no tokens needed).
Helpers in `api/mod.rs`:
- `json_ok(value)` — 200 with `application/json`
- `json_error(status, message)` — error response as `{"error": "..."}`
| Route | Method | Description |
|-------|--------|-------------|
| `/api/me` | GET | Current user (id, name, role) or 401 |
**Swagger UI** is available at `/swagger/` when `FURU_SWAGGER_ENABLED=true`. The OpenAPI spec is auto-generated from handler types.
To add a new API endpoint:
1. Define request/response structs with `#[derive(Serialize, JsonSchema)]`
2. Write an async handler, return `Json(response).into_response()`
3. Add a `Route::with_api_handler_and_name(…, api_get(handler), …)` in `ApiApp::router()`
4. The endpoint appears automatically in Swagger UI
### Admin panel (`src/admin/`)
Mounted at `/admin`. All routes (except `/admin/setup`) require `Role::Admin`.
| Route | Purpose |
|-------|---------|
| `/admin/setup` | First-run: create initial admin (only works when zero users exist) |
| `/admin/` | Dashboard |
| `/admin/debug` | Build info, config values with sources, DB connectivity |
| `/admin/settings` | OIDC config, auth toggles (saved to DB config table) |
| `/admin/users` | User list |
| `/admin/users/new` | Create user |
| `/admin/users/{id}/edit` | Edit user |
| `/admin/users/{id}/delete` | Delete user (POST) |
## How to extend
### 1. Add a config field
See [Config system](#config-system-srcconfigrs) above — 6 locations to update.
### 2. Add a database model
1. Define a struct with `#[cot::db::model]` in a new or existing file
2. Write a migration struct implementing `cot::db::migrations::Migration`
3. Register the migration in the `AdminApp::migrations()` method in `src/admin/mod.rs`
### 3. Add a page
1. Create a template in `templates/`
2. Write a handler function that returns `Html`
3. Add a `Route::with_handler_and_name(…)` in the appropriate `router()` method
4. If admin-only, wrap with `require_admin_or_redirect`
### 4. Add a translation
Add a line to the `translations!` macro in `src/i18n/phrases.rs`:
```rust
my_key: "English text", "Русский текст";
```bash
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets
```
Access it in handlers/templates as `i18n.t.my_key` (or `t.my_key` in templates).
### 5. Add an API endpoint
Same as adding a page, but return a JSON response instead of `Html`. The `json` feature is enabled in Cargo.toml.
## Environment variables
All prefixed with `FURU_`. Priority: env var > DB override > compiled default.
| Variable | Description | Default |
|----------|-------------|---------|
| `FURU_DATABASE_URL` | PostgreSQL connection URL | *(empty — required)* |
| `FURU_LOG_LEVEL` | Tracing filter (e.g. `info`, `debug`, `warn,furumusic=trace`) | `info` |
| `FURU_AUTH_PASSWORD_ENABLED` | Enable password login | `true` |
| `FURU_AUTH_SSO_ENABLED` | Enable SSO/OIDC login | `false` |
| `FURU_OIDC_ISSUER` | OIDC issuer URL | *(empty)* |
| `FURU_OIDC_CLIENT_ID` | OIDC client ID | *(empty)* |
| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* |
| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` |
| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* |
| `FURU_OIDC_USER_GROUPS` | Comma-separated OIDC groups allowed to access the service. Empty means any authenticated SSO user is allowed. | *(empty)* |
| `FURU_SWAGGER_ENABLED` | Serve Swagger UI at `/swagger/` | `false` |
Generated
+27
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1786106723,
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+38
View File
@@ -0,0 +1,38 @@
{
description = "Furumusic development environment";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forEachSystem = function:
nixpkgs.lib.genAttrs supportedSystems (system:
function (import nixpkgs { inherit system; }));
in
{
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
cargo
clippy
pkg-config
rustc
rustfmt
];
buildInputs = with pkgs; [
cacert
openssl
] ++ lib.optionals stdenv.isDarwin [ libiconv ];
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
};
});
};
}
+14
View File
@@ -422,6 +422,20 @@ impl App for AdminApp {
}),
"admin_v2_federation_status",
),
Route::with_handler_and_name(
"/v2/api/similarity",
get(move |session: Session, db: Database| async move {
v2::similarity_status(session, db).await
}),
"admin_v2_similarity_status",
),
Route::with_handler_and_name(
"/v2/api/similarity/clear",
cot::router::method::post(move |session: Session, db: Database| async move {
v2::similarity_clear(session, db).await
}),
"admin_v2_similarity_clear",
),
Route::with_handler_and_name(
"/v2/api/federation/sync",
cot::router::method::post(move |session: Session, db: Database| async move {
+119
View File
@@ -454,6 +454,14 @@ struct AdminSettingsValues {
federation_network_id: String,
#[serde(default)]
federation_save_on_listen: bool,
#[serde(default)]
similarity_enabled: bool,
#[serde(default = "default_similarity_model")]
similarity_model: String,
#[serde(default = "default_similarity_profile")]
similarity_profile: String,
#[serde(default = "default_similarity_workers")]
similarity_workers: String,
}
#[derive(Debug, Clone, Serialize, JsonSchema)]
@@ -481,6 +489,10 @@ struct AdminSettingsSources {
federation_enabled: &'static str,
federation_network_id: &'static str,
federation_save_on_listen: &'static str,
similarity_enabled: &'static str,
similarity_model: &'static str,
similarity_profile: &'static str,
similarity_workers: &'static str,
}
#[derive(Debug, Deserialize)]
@@ -511,6 +523,26 @@ pub(super) struct UpdateSettingsRequest {
federation_network_id: String,
#[serde(default)]
federation_save_on_listen: bool,
#[serde(default)]
similarity_enabled: bool,
#[serde(default = "default_similarity_model")]
similarity_model: String,
#[serde(default = "default_similarity_profile")]
similarity_profile: String,
#[serde(default = "default_similarity_workers")]
similarity_workers: String,
}
fn default_similarity_model() -> String {
crate::similarity::DEFAULT_MODEL_ID.to_owned()
}
fn default_similarity_profile() -> String {
crate::similarity::DEFAULT_PROFILE_ID.to_owned()
}
fn default_similarity_workers() -> String {
"1".to_owned()
}
#[derive(Debug, Serialize, JsonSchema)]
@@ -948,6 +980,29 @@ pub async fn update_settings(
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let similarity_model = body.similarity_model.trim();
if crate::similarity::model_by_id(similarity_model).is_none() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"unknown similarity model",
));
}
let similarity_profile = body.similarity_profile.trim();
if crate::similarity::profile_by_id(similarity_profile).is_none() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"unknown similarity preprocessing profile",
));
}
let similarity_workers = match body.similarity_workers.trim().parse::<u64>() {
Ok(workers @ 1..=16) => workers,
_ => {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"similarity workers must be an integer from 1 to 16",
));
}
};
let fields = [
(
"auth_password_enabled",
@@ -1002,6 +1057,10 @@ pub async fn update_settings(
"federation_save_on_listen",
body.federation_save_on_listen.to_string(),
),
("similarity_enabled", body.similarity_enabled.to_string()),
("similarity_model", similarity_model.to_string()),
("similarity_profile", similarity_profile.to_string()),
("similarity_workers", similarity_workers.to_string()),
];
for (key, value) in fields {
let mut entry = ConfigEntry::new(key.to_string(), value);
@@ -1014,6 +1073,7 @@ pub async fn update_settings(
// the freshly saved settings — no server restart involved.
let (fresh, _) = AppConfig::load_with_db(&db).await;
tokio::spawn(async move {
crate::similarity::handle().apply(&fresh);
crate::federation::handle().apply(&fresh).await;
});
Json(serde_json::json!({ "ok": true })).into_response()
@@ -1033,6 +1093,57 @@ pub async fn federation_status(
Json(crate::federation::handle().status().await).into_response()
}
pub async fn similarity_status(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let manager = crate::similarity::handle();
let status = manager.status();
let model_id = if status.model.is_empty() {
crate::similarity::DEFAULT_MODEL_ID
} else {
&status.model
};
let profiles = crate::similarity::PROFILES
.iter()
.map(|profile| {
serde_json::json!({
"id": profile.id,
"title": profile.title,
"details": crate::similarity::profile_details(
profile.id,
model_id,
).unwrap_or_default(),
})
})
.collect::<Vec<_>>();
Json(serde_json::json!({
"status": status,
"models": crate::similarity::MODELS,
"profiles": profiles,
}))
.into_response()
}
pub async fn similarity_clear(
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
match crate::similarity::handle().clear().await {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(error) => Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("embedding cleanup failed: {error:#}"),
)),
}
}
pub async fn federation_sync(
session: Session,
db: Database,
@@ -1153,6 +1264,10 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
federation_enabled: config.federation_enabled,
federation_network_id: config.federation_network_id,
federation_save_on_listen: config.federation_save_on_listen,
similarity_enabled: config.similarity_enabled,
similarity_model: config.similarity_model,
similarity_profile: config.similarity_profile,
similarity_workers: config.similarity_workers.to_string(),
},
sources: AdminSettingsSources {
auth_password_enabled: sources.auth_password_enabled.code(),
@@ -1178,6 +1293,10 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
federation_enabled: sources.federation_enabled.code(),
federation_network_id: sources.federation_network_id.code(),
federation_save_on_listen: sources.federation_save_on_listen.code(),
similarity_enabled: sources.similarity_enabled.code(),
similarity_model: sources.similarity_model.code(),
similarity_profile: sources.similarity_profile.code(),
similarity_workers: sources.similarity_workers.code(),
},
}
}
+37
View File
@@ -138,6 +138,10 @@ pub struct ConfigSources {
pub federation_enabled: ConfigSource,
pub federation_network_id: ConfigSource,
pub federation_save_on_listen: ConfigSource,
pub similarity_enabled: ConfigSource,
pub similarity_model: ConfigSource,
pub similarity_profile: ConfigSource,
pub similarity_workers: ConfigSource,
}
impl Default for ConfigSources {
@@ -168,6 +172,10 @@ impl Default for ConfigSources {
federation_enabled: ConfigSource::Default,
federation_network_id: ConfigSource::Default,
federation_save_on_listen: ConfigSource::Default,
similarity_enabled: ConfigSource::Default,
similarity_model: ConfigSource::Default,
similarity_profile: ConfigSource::Default,
similarity_workers: ConfigSource::Default,
}
}
}
@@ -285,6 +293,14 @@ pub struct AppConfig {
/// Whether a federated track requested for playback is imported into the
/// shared local library. This is a server-wide administrator policy.
pub federation_save_on_listen: bool,
/// Whether local embedding calculation and similarity search are enabled.
pub similarity_enabled: bool,
/// Embedding model selected by the administrator.
pub similarity_model: String,
/// Audio preprocessing profile selected by the administrator.
pub similarity_profile: String,
/// Maximum number of concurrent CPU embedding workers.
pub similarity_workers: u64,
}
impl Default for AppConfig {
@@ -315,6 +331,12 @@ impl Default for AppConfig {
federation_enabled: false,
federation_network_id: String::new(),
federation_save_on_listen: false,
similarity_enabled: false,
similarity_model: "discogs-effnet-bsdynamic-1".into(),
similarity_profile: "furumi-full-track-v1".into(),
similarity_workers: std::thread::available_parallelism()
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
.unwrap_or(1),
}
}
}
@@ -346,6 +368,10 @@ impl_env_overrides!(
federation_enabled,
federation_network_id,
federation_save_on_listen,
similarity_enabled,
similarity_model,
similarity_profile,
similarity_workers,
);
impl AppConfig {
@@ -476,6 +502,10 @@ impl AppConfig {
apply_db_field!(federation_enabled);
apply_db_field!(federation_network_id);
apply_db_field!(federation_save_on_listen);
apply_db_field!(similarity_enabled);
apply_db_field!(similarity_model);
apply_db_field!(similarity_profile);
apply_db_field!(similarity_workers);
}
}
@@ -495,6 +525,13 @@ mod tests {
let cfg = AppConfig::default();
assert!(cfg.database_url.is_empty());
assert_eq!(cfg.log_level, "info");
assert!(!cfg.similarity_enabled);
assert_eq!(cfg.similarity_model, crate::similarity::DEFAULT_MODEL_ID);
assert_eq!(
cfg.similarity_profile,
crate::similarity::DEFAULT_PROFILE_ID
);
assert!((1..=4).contains(&cfg.similarity_workers));
}
#[test]
+80
View File
@@ -0,0 +1,80 @@
//! Informational publication of the protocol versions exposed by this peer.
use std::time::Duration;
use anyhow::Result;
use music_dht::StreamAcceptor;
use music_dht::capabilities::{
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, SIMILARITY_ID,
read_message, write_message,
};
use super::serve::AUDIO_PROTOCOL_VERSION;
fn local_manifest() -> CapabilityManifest {
CapabilityManifest::frid("furumusic", env!("CARGO_PKG_VERSION"))
// The web server does not expose federation Jam yet.
.without_protocol(JAM_ID)
.with_protocol("audio", AUDIO_PROTOCOL_VERSION)
.with_protocol(
SIMILARITY_ID,
music_dht::similarity::SIMILARITY_PROTOCOL_VERSION,
)
}
pub async fn serve(mut acceptor: StreamAcceptor) {
while let Some(stream) = acceptor.accept().await {
tokio::spawn(async move {
if let Err(error) = serve_one(stream).await {
tracing::debug!("capability stream failed: {error:#}");
}
});
}
}
async fn serve_one(mut stream: music_dht::ByteStream) -> Result<()> {
let response = match read_message(&mut stream).await? {
CapabilityMessage::Get {
version: CAPABILITIES_PROTOCOL_VERSION,
} => CapabilityMessage::Manifest {
manifest: local_manifest(),
},
CapabilityMessage::Get { version } => CapabilityMessage::Error {
message: format!("unsupported capability protocol {version}"),
},
_ => CapabilityMessage::Error {
message: "expected capability request".to_string(),
},
};
write_message(&mut stream, &response).await?;
stream.send.finish()?;
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_describes_only_supported_player_protocols() {
let manifest = local_manifest();
assert_eq!(manifest.application, "furumusic");
assert_eq!(
manifest.protocols.get("audio"),
Some(&AUDIO_PROTOCOL_VERSION)
);
assert!(!manifest.protocols.contains_key(JAM_ID));
assert_eq!(
manifest.protocols.get(SIMILARITY_ID),
Some(&music_dht::similarity::SIMILARITY_PROTOCOL_VERSION)
);
assert_eq!(
manifest
.protocols
.get(music_dht::capabilities::SIMILARITY_DHT_ID),
Some(&music_dht::similarity_lsh::SIMILARITY_DHT_PROTOCOL_VERSION)
);
manifest.validate().unwrap();
}
}
+64
View File
@@ -85,6 +85,8 @@ pub struct TrackDto {
pub key: TrackKeyDto,
pub metadata: TrackMetadataDto,
pub availability: TrackAvailabilityDto,
#[serde(skip_serializing_if = "Option::is_none")]
pub similarity_score: Option<f32>,
}
#[derive(Debug, Clone, Serialize)]
@@ -98,6 +100,67 @@ pub struct SearchEvent {
}
impl Federation {
pub async fn prepare_similarity_tracks(
&self,
tracks: Vec<super::similarity::RemoteSimilarityTrack>,
) -> Result<Vec<TrackDto>> {
let pool = self.pool().await?;
let mut prepared = Vec::new();
for track in tracks {
let Some(content_id) = track.content_id.as_deref().and_then(normalize_content_id)
else {
continue;
};
let local = local_availability(&pool, &content_id).await?;
// A local result is already present in the first result section.
if local.is_some() {
continue;
}
let owner = track.owner;
let item_id = track.item_id;
let dto = TrackDto {
key: TrackKeyDto {
content_id: content_id.clone(),
},
metadata: TrackMetadataDto {
title: track.title,
artists: artist_refs(&track.artist_names),
featured_artists: artist_refs(&track.featured_artist_names),
release: track.release_title.map(|title| ReleaseRefDto {
key: ReleaseKeyDto {
normalized_title: music_dht::normalize_name(&title),
primary_artists: track
.artist_names
.iter()
.map(|artist| music_dht::normalize_name(artist))
.collect(),
release_type: None,
year: track.year,
},
local_id: None,
title,
}),
year: track.year,
duration_seconds: track.duration_seconds.map(|value| value as f64),
track_number: track.track_number,
disc_number: track.disc_number,
cover_url: Some(format!(
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
)),
},
availability: TrackAvailabilityDto {
state: "federated",
local: None,
federation: vec![FederationSourceDto { owner, item_id }],
},
similarity_score: Some(track.similarity_score),
};
persist_track_ref(&pool, &dto).await?;
prepared.push(dto);
}
Ok(prepared)
}
pub fn stream_artist_catalogs(
self: &std::sync::Arc<Self>,
name: String,
@@ -431,6 +494,7 @@ fn track_from_item(
local,
federation: vec![FederationSourceDto { owner, item_id }],
},
similarity_score: None,
}
}
+41 -6
View File
@@ -32,6 +32,9 @@ const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
const DEVICE_SYNC_INTERVAL: Duration = Duration::from_secs(2);
const LOCAL_SEED_RECHECK_MS: i64 = 60 * 1000;
const MAX_LINE: usize = 8 * 1024 * 1024;
/// Playback commands carry queue state but are useful only briefly and only
/// to their addressed device. They must not inflate a new peer's catch-up.
const PLAYBACK_COMMAND_TTL_MS: i64 = 5 * 60 * 1_000;
const MAX_OPS_PER_BATCH: i64 = 1000;
#[derive(Debug, Clone, Serialize)]
@@ -84,6 +87,7 @@ struct Identity {
#[derive(Debug, Clone)]
struct StoredDevice {
device_id: String,
endpoint_id: String,
endpoint_ticket: String,
}
@@ -1573,8 +1577,7 @@ async fn sync_device(
user_id: i64,
device: &StoredDevice,
) -> Result<()> {
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
let peer = service.connect(ticket).await?;
let peer = resolve_device_peer(&service, device).await?;
let own_ticket = service.ticket().await?.to_string();
let identity = ensure_identity(pool, user_id, "").await?;
let profile = own_profile(pool, user_id, "", &own_ticket).await?;
@@ -3420,12 +3423,16 @@ async fn local_playback_snapshot(
user_id: i64,
identity: &Identity,
) -> Option<PlaybackSnapshot> {
let state = hub.current_playback_state_json(user_id)?;
// Keep publishing an inactive snapshot after a handoff. Omitting the
// snapshot left the last `active: true` value alive on trusted peers until
// its TTL elapsed, allowing the always-on web peer to reclaim playback.
let active = hub.federation_playback_is_local(user_id);
let state = hub.playback_state_json_for_commands(user_id)?;
let wire = playback_state_from_browser_json(pool, state).await.ok()?;
Some(PlaybackSnapshot {
device_id: identity.device_id.clone(),
device_name: identity.name.clone(),
active: true,
active,
updated_at_ms: now_ms(),
state: wire,
})
@@ -4118,7 +4125,7 @@ pub async fn playlist_content_ids_for_removal(
async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<StoredDevice>> {
let identity = ensure_identity(pool, user_id, "").await?;
let rows = sqlx::query(
"SELECT device_id, endpoint_ticket
"SELECT device_id, endpoint_id, endpoint_ticket
FROM furumusic__fed_device
WHERE user_id = $1
AND trusted_at_ms IS NOT NULL
@@ -4134,11 +4141,31 @@ async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<
.into_iter()
.map(|row| StoredDevice {
device_id: row.get("device_id"),
endpoint_id: row.get("endpoint_id"),
endpoint_ticket: row.get("endpoint_ticket"),
})
.collect())
}
async fn resolve_device_peer(
service: &MusicDhtService,
device: &StoredDevice,
) -> Result<music_dht::EndpointId> {
if let Ok(peer) = device.endpoint_id.parse::<music_dht::EndpointId>()
&& (service.connected_peers().contains(&peer)
|| service
.known_peers()
.iter()
.any(|contact| contact.peer_id == peer))
{
// Prefer the live/current-schema DHT contact over a persisted ticket
// that may have been issued before a schema upgrade.
return Ok(peer);
}
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
service.connect(ticket).await.map_err(Into::into)
}
async fn active_device_count(pool: &sqlx::PgPool, user_id: i64) -> Result<i64> {
sqlx::query_scalar(
"SELECT COUNT(*) FROM furumusic__fed_device
@@ -4202,11 +4229,19 @@ async fn ops_for_peer(
AND a.origin_device_id = o.origin_device_id
WHERE o.user_id = $1
AND o.seq > COALESCE(a.max_seq, 0)
AND (
o.kind != 'playback_command'
OR (
o.hlc_ms >= $3
AND o.payload_json->>'target_device_id' = $2
)
)
ORDER BY o.hlc_ms, o.op_id
LIMIT $3",
LIMIT $4",
)
.bind(user_id)
.bind(peer_device_id)
.bind(now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS))
.bind(MAX_OPS_PER_BATCH)
.fetch_all(pool)
.await?;
+205 -6
View File
@@ -5,17 +5,21 @@
//! releases, tracks — names and small metadata, never files) into the
//! shared DHT and serves audio, track metadata, cover art and per-artist
//! catalogs to other peers (TUI clients) over the same wire protocols the
//! clients speak among themselves. Serve-only: the server does not search
//! or download from other peers.
//! clients speak among themselves. The web player also searches known peers
//! for catalog metadata and, when enabled, compatible similarity embeddings;
//! local playback and the local library remain independent of the network.
//!
//! Settings are the regular admin config entries (`federation_enabled`,
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
//! starts, stops or re-joins the node without a server restart.
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly —
//! saving the settings starts, stops or re-joins the node without a server
//! restart.
mod capabilities;
pub mod client;
pub mod devices;
mod receive;
mod serve;
mod similarity;
mod storage;
use std::collections::{HashMap, HashSet, VecDeque};
@@ -24,6 +28,9 @@ use std::sync::{Arc, OnceLock};
use std::time::Duration;
use anyhow::{Context, Result};
use music_dht::capabilities::CAPABILITIES_ALPN;
use music_dht::similarity_dht::SimilarityDht;
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
use music_dht::{
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
@@ -36,6 +43,7 @@ use crate::config::AppConfig;
use storage::PostgresFederationStorage;
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
pub use similarity::SIMILARITY_ALPN;
/// How often the published library is re-synchronized with the database.
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
@@ -43,6 +51,7 @@ const TRANSPORT_SAMPLE_LIMIT: usize = 16;
struct Running {
service: Arc<MusicDhtService>,
similarity_dht: Arc<SimilarityDht>,
network_name: String,
tasks: Vec<tokio::task::JoinHandle<()>>,
}
@@ -122,6 +131,7 @@ struct TransportStatsState {
audio_samples: u64,
catalog_samples: u64,
sync_samples: u64,
similarity_samples: u64,
last: VecDeque<TransportSample>,
}
@@ -155,6 +165,7 @@ impl TransportStats {
"audio" => state.audio_samples += 1,
"catalog" => state.catalog_samples += 1,
"device-sync" => state.sync_samples += 1,
"similarity" => state.similarity_samples += 1,
_ => {}
}
state.last.push_front(sample);
@@ -175,6 +186,7 @@ impl TransportStats {
"audio_samples": state.audio_samples,
"catalog_samples": state.catalog_samples,
"sync_samples": state.sync_samples,
"similarity_samples": state.similarity_samples,
"last_path": latest.map(|sample| sample.selected_path.clone()),
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
"last_peer": latest.map(|sample| sample.peer_id.clone()),
@@ -201,6 +213,50 @@ impl TransportStats {
}
}
async fn enrich_transport_users(pool: &PgPool, transport: &mut Value) {
let Some(samples) = transport.get_mut("last").and_then(Value::as_array_mut) else {
return;
};
let peer_ids: Vec<String> = samples
.iter()
.filter_map(|sample| sample.get("peer_id").and_then(Value::as_str))
.map(str::to_owned)
.collect();
if peer_ids.is_empty() {
return;
}
let Ok(rows) = sqlx::query(
"SELECT DISTINCT ON (d.endpoint_id)
d.endpoint_id,
COALESCE(NULLIF(u.display_name, ''), u.username::text) AS user_name
FROM furumusic__fed_device d
JOIN furumusic__user u ON u.id = d.user_id
WHERE d.endpoint_id = ANY($1) AND d.revoked_at_ms IS NULL
ORDER BY d.endpoint_id, d.last_seen_ms DESC NULLS LAST",
)
.bind(&peer_ids)
.fetch_all(pool)
.await
else {
return;
};
let users: HashMap<String, String> = rows
.into_iter()
.map(|row| (row.get("endpoint_id"), row.get("user_name")))
.collect();
for sample in samples {
let Some(peer_id) = sample.get("peer_id").and_then(Value::as_str) else {
continue;
};
let Some(user_name) = users.get(peer_id) else {
continue;
};
if let Some(object) = sample.as_object_mut() {
object.insert("user_name".to_owned(), Value::String(user_name.clone()));
}
}
}
pub fn record_stream_transport(
stats: &Arc<TransportStats>,
protocol: &'static str,
@@ -212,7 +268,8 @@ pub fn record_stream_transport(
}
pub struct Federation {
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
/// Transport files and replaceable similarity-routing cache. Durable
/// catalog DHT state and identity live in PostgreSQL.
data_dir: PathBuf,
database_url: std::sync::Mutex<String>,
storage_dir: std::sync::Mutex<String>,
@@ -371,6 +428,9 @@ impl Federation {
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
let secret_key = dht_storage.load_or_create_secret_key().await?;
self.transport_stats.reset();
tokio::fs::create_dir_all(&self.data_dir)
.await
.with_context(|| format!("creating {}", self.data_dir.display()))?;
let config = MusicDhtConfig::builder()
.data_dir(&self.data_dir)
@@ -380,6 +440,9 @@ impl Federation {
.stream_protocol(AUDIO_ALPN)
.stream_protocol(CATALOG_ALPN)
.stream_protocol(devices::SYNC_ALPN)
.schema_independent_stream_protocol(SIMILARITY_ALPN)
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
.schema_independent_stream_protocol(CAPABILITIES_ALPN)
.build()
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
let (service, mut events) =
@@ -393,6 +456,25 @@ impl Federation {
"federation started"
);
let similarity_dht = SimilarityDht::open(
Arc::clone(&service),
self.data_dir.join("similarity-routing.sqlite3"),
)
.await
.map_err(|error| anyhow::anyhow!("failed to start the similarity DHT: {error}"))?;
let similarity_dht_acceptor = service
.stream_acceptor(SIMILARITY_DHT_ALPN)
.map_err(|error| anyhow::anyhow!("failed to take similarity DHT acceptor: {error}"))?;
let similarity_dht_serve_task =
tokio::spawn(Arc::clone(&similarity_dht).serve(similarity_dht_acceptor));
let similarity_dht_maintenance_task =
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
let similarity_manager = crate::similarity::handle();
let similarity_dht_sync_task = tokio::spawn(similarity_route_sync_loop(
Arc::clone(&similarity_dht),
Arc::clone(&similarity_manager),
));
// Drain DHT events into the log; the channel is bounded.
let event_task = tokio::spawn(async move {
while let Some(event) = events.recv().await {
@@ -447,9 +529,23 @@ impl Federation {
device_hub,
Arc::clone(&self.transport_stats),
));
let capabilities_acceptor = service
.stream_acceptor(CAPABILITIES_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take the capabilities acceptor: {err}"))?;
let capabilities_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
let similarity_acceptor = service
.stream_acceptor(SIMILARITY_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?;
let similarity_task = tokio::spawn(similarity::serve_peers(
similarity_acceptor,
similarity_manager,
service.endpoint_id(),
Arc::clone(&self.transport_stats),
));
*guard = Some(Running {
service,
similarity_dht,
network_name,
tasks: vec![
event_task,
@@ -458,6 +554,11 @@ impl Federation {
catalog_task,
device_task,
device_sync_task,
capabilities_task,
similarity_task,
similarity_dht_serve_task,
similarity_dht_maintenance_task,
similarity_dht_sync_task,
],
});
self.set_error(None);
@@ -481,6 +582,20 @@ impl Federation {
.context("federation is not running")
}
async fn similarity_services(&self) -> Result<(Arc<MusicDhtService>, Arc<SimilarityDht>)> {
self.running
.lock()
.await
.as_ref()
.map(|running| {
(
Arc::clone(&running.service),
Arc::clone(&running.similarity_dht),
)
})
.context("federation is not running")
}
async fn spawn_sync_soon(self: &Arc<Self>) {
if let Ok(service) = self.service().await {
let fed = Arc::clone(self);
@@ -778,14 +893,19 @@ impl Federation {
.iter()
.map(|p| p.to_string())
.collect();
let mut transport = self.transport_stats.snapshot();
if let Ok(pool) = self.pool().await {
enrich_transport_users(&pool, &mut transport).await;
}
json!({
"running": true,
"network": running.network_name,
"endpoint_id": service.endpoint_id().to_string(),
"connected_peers": peers,
"known_contacts": service.known_peers().len(),
"similarity_routing_peers": running.similarity_dht.known_peers(),
"published_items": published,
"transport": self.transport_stats.snapshot(),
"transport": transport,
})
}
None => json!({ "running": false }),
@@ -819,6 +939,26 @@ impl Federation {
Ok(peer.to_string())
}
pub async fn search_similarity(
&self,
query: crate::similarity::QueryVector,
limit: usize,
) -> Result<similarity::SimilaritySearchOutcome> {
anyhow::ensure!(
crate::similarity::handle().enabled(),
"similarity search is disabled"
);
let (service, similarity_dht) = self.similarity_services().await?;
similarity::search(
service,
similarity_dht,
query,
limit,
Arc::clone(&self.transport_stats),
)
.await
}
pub async fn fed_device_status(
&self,
user_id: i64,
@@ -944,6 +1084,65 @@ impl Federation {
}
}
async fn similarity_route_sync_loop(
routing: Arc<SimilarityDht>,
manager: Arc<crate::similarity::Manager>,
) {
let mut interval = tokio::time::interval(SYNC_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut published_marker: Option<(String, blake3::Hash)> = None;
loop {
interval.tick().await;
if !manager.enabled() {
if published_marker.take().is_some() {
routing.clear_local_signatures();
tracing::info!("local similarity DHT publication disabled");
}
continue;
}
let status = manager.status();
let Some(profile_id) = status.active_profile else {
continue;
};
if status.phase != crate::similarity::Phase::Ready {
continue;
}
let signatures = match manager.routing_signatures(&profile_id).await {
Ok(signatures) => signatures,
Err(error) => {
tracing::warn!(%error, %profile_id, "similarity routing signatures unavailable");
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;
}
match routing
.sync_local_signatures(profile_id.clone(), signatures)
.await
{
Ok(stats) => {
tracing::info!(
profile = %profile_id,
records = stats.records,
keys = stats.keys,
remote_nodes = stats.remote_nodes,
"local similarity DHT index synchronized"
);
published_marker = Some(marker);
}
Err(error) => {
tracing::warn!(%error, %profile_id, "similarity DHT synchronization failed");
}
}
}
}
async fn persist_content_id(
pool: &PgPool,
media_file_id: i64,
+2
View File
@@ -21,6 +21,8 @@ use super::{TransportStats, record_stream_transport};
/// ALPN of the peer-to-peer audio streaming protocol.
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
/// Version of the peer-to-peer audio streaming protocol.
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
/// Maximum size of a JSON protocol line (request or response header).
const MAX_PROTOCOL_LINE: usize = 4096;
+398
View File
@@ -0,0 +1,398 @@
//! Furumusic policy and PostgreSQL adapter for the shared similarity protocol.
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context as _, Result};
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::{
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
};
use crate::similarity::{Manager, QueryVector};
use super::TransportStats;
pub use music_dht::similarity::SIMILARITY_ALPN;
const INITIAL_QUERY_PEERS: usize = 16;
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 MAX_PER_ARTIST: usize = 3;
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
#[derive(Debug, Clone)]
pub struct RemoteSimilarityTrack {
pub owner: String,
pub item_id: String,
pub title: String,
pub artist_names: Vec<String>,
pub featured_artist_names: Vec<String>,
pub year: Option<i32>,
pub duration_seconds: Option<i64>,
pub content_id: Option<String>,
pub release_title: Option<String>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub similarity_score: f32,
}
pub struct SimilaritySearchOutcome {
pub tracks: Vec<RemoteSimilarityTrack>,
pub queried_peers: usize,
}
pub async fn serve_peers(
mut acceptor: StreamAcceptor,
manager: Arc<Manager>,
own: EndpointId,
transport: Arc<TransportStats>,
) {
while let Some(stream) = acceptor.accept().await {
let manager = Arc::clone(&manager);
let transport = Arc::clone(&transport);
tokio::spawn(async move {
let peer = stream.peer_id;
if let Err(error) = serve_one(stream, manager, own, transport).await {
tracing::warn!(peer = %peer, "similarity request failed: {error:#}");
}
});
}
}
async fn serve_one(
mut stream: ByteStream,
manager: Arc<Manager>,
own: EndpointId,
transport: Arc<TransportStats>,
) -> Result<()> {
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
let request = wire::read_request(&mut stream).await?;
let response = if !manager.enabled() {
SimilarityResponse::refused("similarity search is disabled on this instance")?
} else {
let profile_id = request.profile_id;
let vector = request.vector;
let limit = request.limit;
let rank_manager = Arc::clone(&manager);
let ranked = tokio::task::spawn_blocking(move || {
rank_manager.rank_vector(&profile_id, &vector, None, None, limit)
})
.await
.context("local similarity task failed")
.and_then(|result| result);
match ranked {
Ok(ranked) => {
let ids = ranked
.iter()
.map(|track| track.track_id)
.collect::<Vec<_>>();
match manager.metadata_for_tracks(&ids).await {
Ok(metadata) => {
let by_id = ranked
.into_iter()
.map(|track| (track.track_id, track))
.collect::<HashMap<_, _>>();
let hits = metadata
.into_iter()
.filter_map(|track| {
let ranked = by_id.get(&track.track_id)?;
let hit = SimilarityHit {
score: ranked.score,
item_id: hex(
ItemId::derive(
&own,
ItemKind::Track,
&format!("track:{}", track.track_id),
)
.as_bytes(),
),
title: track.title,
artist_names: track.artist_names,
featured_artist_names: track.featured_artist_names,
year: track.year,
duration_seconds: Some(track.duration_seconds.round() as i64),
content_id: track.content_id,
release_title: Some(track.release_title),
track_number: track.track_number,
disc_number: track.disc_number,
embedding_signature: Some(ranked.embedding_signature),
};
match hit.validate() {
Ok(()) => Some(hit),
Err(error) => {
tracing::debug!(%error, "invalid local similarity metadata skipped");
None
}
}
})
.collect();
SimilarityResponse::success(hits)?
}
Err(error) => SimilarityResponse::refused(format!(
"similarity metadata is unavailable: {error:#}"
))?,
}
}
Err(error) => {
SimilarityResponse::refused(format!("similarity query is unavailable: {error:#}"))?
}
}
};
wire::write_response(&mut stream, &response).await?;
stream.send.finish()?;
let _ = stream.send.stopped().await;
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
Ok(())
}
pub async fn search(
service: Arc<MusicDhtService>,
routing: Arc<SimilarityDht>,
query: QueryVector,
limit: usize,
transport: Arc<TransportStats>,
) -> Result<SimilaritySearchOutcome> {
let own = service.endpoint_id();
let routed = match tokio::time::timeout(
ROUTING_TIMEOUT,
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
)
.await
{
Ok(Ok(peers)) => peers,
Err(_) => {
tracing::debug!("similarity DHT lookup timed out; using known peers");
Vec::new()
}
Ok(Err(error)) => {
tracing::debug!(%error, "similarity DHT lookup unavailable; using known peers");
Vec::new()
}
};
let mut seen = HashSet::new();
let mut peers: Vec<QueryPeer> = routed
.into_iter()
.filter_map(|ticket| {
let owner = ticket.endpoint_id();
(owner != own && seen.insert(owner)).then_some(QueryPeer {
owner,
ticket: Some(ticket),
})
})
.collect();
for peer in service
.connected_peers()
.into_iter()
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
{
if peer != own && seen.insert(peer) {
peers.push(QueryPeer {
owner: peer,
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 mut hits = Vec::new();
let initial = peers.len().min(INITIAL_QUERY_PEERS);
let mut queried_peers = initial;
let responses = query_peers(
Arc::clone(&service),
&peers[..initial],
Arc::clone(&request),
Arc::clone(&transport),
)
.await;
let mut successful = 0usize;
for response in responses {
match response {
Ok(peer_hits) => {
successful += 1;
hits.extend(peer_hits);
}
Err(error) => tracing::debug!(%error, "similarity peer query skipped"),
}
}
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
queried_peers += peers.len() - initial;
for response in query_peers(
Arc::clone(&service),
&peers[initial..],
Arc::clone(&request),
Arc::clone(&transport),
)
.await
{
match response {
Ok(peer_hits) => hits.extend(peer_hits),
Err(error) => tracing::debug!(%error, "fallback similarity peer query skipped"),
}
}
}
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> = HashMap::new();
let mut tracks = Vec::new();
for (track, _, signature) in hits {
if query
.source_content_id
.as_deref()
.is_some_and(|source| track.content_id.as_deref() == Some(source))
{
continue;
}
let key = track
.content_id
.clone()
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
if !dedup.insert(key) {
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
.artist_names
.first()
.map(|name| music_dht::normalize_name(name))
.unwrap_or_default();
let count = artist_counts.entry(artist.clone()).or_default();
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
continue;
}
*count += 1;
if let Some(signature) = signature {
signatures.push(signature);
}
tracks.push(track);
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
break;
}
}
Ok(SimilaritySearchOutcome {
tracks,
queried_peers,
})
}
type PeerHits = Vec<(
RemoteSimilarityTrack,
f32,
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
)>;
#[derive(Clone)]
struct QueryPeer {
owner: EndpointId,
ticket: Option<PeerTicket>,
}
async fn query_peers(
service: Arc<MusicDhtService>,
peers: &[QueryPeer],
request: Arc<SimilarityRequest>,
transport: Arc<TransportStats>,
) -> Vec<Result<PeerHits>> {
stream::iter(peers.iter().cloned().map(|peer| {
let service = Arc::clone(&service);
let request = Arc::clone(&request);
let transport = Arc::clone(&transport);
async move {
tokio::time::timeout(
QUERY_TIMEOUT,
query_peer(service, peer, &request, transport),
)
.await
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
}
}))
.buffer_unordered(QUERY_CONCURRENCY)
.collect()
.await
}
async fn query_peer(
service: Arc<MusicDhtService>,
peer: QueryPeer,
request: &SimilarityRequest,
transport: Arc<TransportStats>,
) -> 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,
}
.map_err(|error| anyhow::anyhow!("cannot reach similarity peer: {error}"))?;
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
let response = wire::exchange(&mut stream, request).await?;
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
anyhow::ensure!(
response.ok,
"peer refused similarity query: {}",
response.error.unwrap_or_default()
);
Ok(response
.hits
.into_iter()
.map(|hit| {
let score = hit.score;
let signature = hit.embedding_signature;
(
RemoteSimilarityTrack {
owner: owner.to_string(),
item_id: hit.item_id,
title: hit.title,
artist_names: hit.artist_names,
featured_artist_names: hit.featured_artist_names,
year: hit.year,
duration_seconds: hit.duration_seconds,
content_id: hit.content_id,
release_title: hit.release_title,
track_number: hit.track_number,
disc_number: hit.disc_number,
similarity_score: score,
},
score,
signature,
)
})
.collect())
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hamming_threshold_keeps_exact_and_near_duplicates_out() {
let query = [0u8; wire::SIMILARITY_SIGNATURE_BYTES];
let mut near = query;
near[0] = 0b0000_0111;
assert!(wire::signature_distance(&query, &near) <= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE);
}
}
+8 -2
View File
@@ -96,9 +96,9 @@ translations! {
settings_swagger: "Swagger UI" , "Swagger UI";
settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)";
settings_lastfm_api_key: "Last.fm API key" , "API ключ Last.fm";
settings_lastfm_api_key_help: "Used for Last.fm popularity and account connection" , "Используется для популярности Last.fm и подключения аккаунта";
settings_lastfm_api_key_help: "Identifies this application to Last.fm and enables metadata, popularity data, and user account connection" , "Идентифицирует приложение в Last.fm и включает метаданные, данные о популярности и подключение аккаунтов";
settings_lastfm_shared_secret: "Last.fm shared secret" , "Shared secret Last.fm";
settings_lastfm_shared_secret_help: "Required for signed Last.fm scrobbling requests" , "Нужен для подписанных запросов скробблинга Last.fm";
settings_lastfm_shared_secret_help: "Authenticates signed Last.fm requests, including scrobbling. Keep this value private" , "Подтверждает подписанные запросы Last.fm, включая скробблинг. Не раскрывайте это значение";
// OIDC login errors
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
@@ -298,6 +298,9 @@ translations! {
player_likes_playlist: "Likes" , "Лайки";
player_listened: "listened" , "прослушано";
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
player_search_similar_to: "Search similar to:" , "Поиск похожих на:";
player_find_similar: "Find similar tracks" , "Найти похожие треки";
player_similarity_failed: "Similarity search failed" , "Не удалось найти похожие треки";
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
player_active_device: "Active device" , "Активный девайс";
@@ -314,6 +317,9 @@ translations! {
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
player_rename: "Rename" , "Переименовать";
player_close: "Close" , "Закрыть";
player_interface_language: "Interface language" , "Язык интерфейса";
player_language_description: "Choose the language used by the web player." , "Выберите язык интерфейса веб-плеера.";
player_switch_language: "Русский" , "English";
player_log_out: "Log out" , "Выйти";
player_admin_panel: "Admin Panel" , "Админка";
player_info: "Info" , "Информация";
+8
View File
@@ -13,6 +13,7 @@ mod music;
mod oidc;
mod player;
mod scheduler;
mod similarity;
mod torrents;
mod user;
@@ -567,6 +568,13 @@ impl Project for FuruProject {
federation::handle().boot(&fed_config).await;
});
// Embedding calculation is an independent, server-wide background
// service. It remains useful locally when federation is disabled.
let similarity_config = Arc::clone(&self.app_config);
tokio::spawn(async move {
similarity::handle().boot(&similarity_config).await;
});
apps.register(cot::session::db::SessionApp::new());
apps.register_with_views(
FuruApp {
+138
View File
@@ -2434,6 +2434,141 @@ pub mod db_migrations {
&[Operation::custom(create_synced_listen_history).build()];
}
#[cot::db::migrations::migration_op]
async fn repair_legacy_listen_qualification(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"UPDATE furumusic__listen_event le
SET qualified = (
ph.completed
OR (
COALESCE(ph.duration_listened, 0) >= 5
AND COALESCE(t.duration_seconds, 0) > 0
AND COALESCE(ph.duration_listened, 0) >= LEAST(
COALESCE(t.duration_seconds, 0) / 2.0,
240.0
)
)
)
FROM furumusic__play_history ph
JOIN furumusic__track t ON t.id = ph.track_id
WHERE le.user_id = ph.user_id
AND le.listen_id = 'legacy-web:' || ph.id::text",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0042RepairLegacyListenQualification;
impl migrations::Migration for M0042RepairLegacyListenQualification {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0042_repair_legacy_listen_qualification";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0041_create_synced_listen_history",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(repair_legacy_listen_qualification).build()];
}
#[cot::db::migrations::migration_op]
async fn create_similarity_embeddings(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__similarity_profile (
profile_id TEXT PRIMARY KEY,
model_id TEXT NOT NULL,
model_version TEXT NOT NULL,
model_sha256 TEXT NOT NULL,
preprocessing TEXT NOT NULL,
dimensions INTEGER NOT NULL,
active BOOLEAN NOT NULL DEFAULT FALSE,
created_at TEXT NOT NULL
)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_similarity_profile_active
ON furumusic__similarity_profile (active)
WHERE active = TRUE",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__track_embedding (
track_id BIGINT NOT NULL REFERENCES furumusic__track(id)
ON DELETE CASCADE,
profile_id TEXT NOT NULL REFERENCES furumusic__similarity_profile(profile_id)
ON DELETE CASCADE,
dimensions INTEGER NOT NULL,
vector BYTEA NOT NULL,
source_sha256 TEXT NOT NULL,
source_content_id TEXT,
computed_at TEXT NOT NULL,
PRIMARY KEY (track_id, profile_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_track_embedding_profile
ON furumusic__track_embedding (profile_id, track_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0043CreateSimilarityEmbeddings;
impl migrations::Migration for M0043CreateSimilarityEmbeddings {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0043_create_similarity_embeddings";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0042_repair_legacy_listen_qualification",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_similarity_embeddings).build()];
}
#[cot::db::migrations::migration_op]
async fn add_similarity_routing_signature(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"ALTER TABLE furumusic__track_embedding
ADD COLUMN IF NOT EXISTS routing_signature BYTEA",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0044AddSimilarityRoutingSignature;
impl migrations::Migration for M0044AddSimilarityRoutingSignature {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0044_add_similarity_routing_signature";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0043_create_similarity_embeddings",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(add_similarity_routing_signature).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -2466,5 +2601,8 @@ pub mod db_migrations {
&M0039EnsureFederationContentIdCache,
&M0040CreateContentAddressedMusicRefs,
&M0041CreateSyncedListenHistory,
&M0042RepairLegacyListenQualification,
&M0043CreateSimilarityEmbeddings,
&M0044AddSimilarityRoutingSignature,
];
}
+433 -27
View File
@@ -14,6 +14,7 @@ use cot::router::method::{delete, get, post};
use cot::router::{Route, Router};
use cot::session::Session;
use cot::{App, Body, Template};
use serde::{Deserialize, Serialize};
use sqlx::Row as _;
use crate::auth;
@@ -54,6 +55,7 @@ struct LocalUploadResponse {
}
const PLAYER_DEVICE_TTL_MS: i64 = 30_000;
const PLAYER_DEVICE_RETURN_TAKEOVER_MS: i64 = 30 * 60 * 1_000;
const PLAYER_DEVICE_COMMAND_TTL_MS: i64 = 20_000;
const PLAYER_DEVICE_MAX_COMMANDS: usize = 32;
const PLAYER_JAM_IDLE_TTL_MS: i64 = 4 * 60 * 60 * 1000;
@@ -104,6 +106,7 @@ struct PlayerJamSession {
#[derive(Debug, Default)]
struct PlayerDeviceHubState {
devices_by_user: HashMap<i64, HashMap<String, PlayerDevice>>,
device_last_seen_ms: HashMap<(i64, String), i64>,
active_device_by_user: HashMap<i64, String>,
commands_by_device: HashMap<(i64, String), VecDeque<PendingPlayerDeviceCommand>>,
playback_state_by_user: HashMap<i64, PlayerDevicePlaybackStateDto>,
@@ -153,19 +156,12 @@ impl PlayerDeviceHub {
Ok(())
}
pub(crate) fn current_playback_state_json(&self, user_id: i64) -> Option<serde_json::Value> {
pub(crate) fn federation_playback_is_local(&self, user_id: i64) -> bool {
let state = self.state.lock().expect("player device hub lock");
if state
!state
.active_device_by_user
.get(&user_id)
.is_some_and(|id| is_fed_virtual_device_id(id))
{
return None;
}
state
.playback_state_by_user
.get(&user_id)
.and_then(|playback| serde_json::to_value(playback).ok())
}
pub(crate) fn playback_state_json_for_commands(
@@ -264,14 +260,58 @@ impl PlayerDeviceHub {
user_agent: Option<&str>,
current_jam_id: Option<&str>,
playback_state: Option<PlayerDevicePlaybackStateDto>,
) -> PlayerDevicesResponse {
) -> (PlayerDevicesResponse, Option<String>) {
let now = current_millis();
let mut state = self.state.lock().expect("player device hub lock");
self.prune_locked(&mut state, now);
let is_new_or_returning = state
.device_last_seen_ms
.get(&(user_id, device_id.to_string()))
.is_none_or(|last_seen| {
now.saturating_sub(*last_seen) >= PLAYER_DEVICE_RETURN_TAKEOVER_MS
});
let previous_active_id = state.active_device_by_user.get(&user_id).cloned();
self.touch_locked(&mut state, user_id, device_id, user_agent, now);
let active_is_playing = state
.playback_state_by_user
.get(&user_id)
.is_some_and(|playback| playback.track.is_some() && !playback.paused);
let should_claim_idle_playback = is_new_or_returning
&& previous_active_id.as_deref() != Some(device_id)
&& !active_is_playing;
if should_claim_idle_playback {
let transfer_state = state
.playback_state_by_user
.get(&user_id)
.cloned()
.map(|playback| playback_state_at(playback, now));
state
.active_device_by_user
.insert(user_id, device_id.to_string());
if let Some(transfer_state) = transfer_state {
state
.playback_state_by_user
.insert(user_id, transfer_state.clone());
if let Ok(payload) = serde_json::to_value(transfer_state) {
self.enqueue_command_locked(
&mut state,
user_id,
device_id,
"transfer_state",
payload,
now,
);
}
}
}
self.update_playback_state_locked(&mut state, user_id, device_id, playback_state, now);
self.touch_jam_locked(&mut state, user_id, device_id, current_jam_id, now);
self.snapshot_locked(&state, user_id, device_id, current_jam_id, now)
(
self.snapshot_locked(&state, user_id, device_id, current_jam_id, now),
should_claim_idle_playback
.then_some(previous_active_id)
.flatten(),
)
}
fn poll(
@@ -448,6 +488,9 @@ impl PlayerDeviceHub {
last_seen_ms: now,
};
devices.insert(device_id.to_string(), device);
state
.device_last_seen_ms
.insert((user_id, device_id.to_string()), now);
let active_online = state
.active_device_by_user
@@ -868,6 +911,9 @@ impl PlayerDeviceHub {
}
fn prune_locked(&self, state: &mut PlayerDeviceHubState, now: i64) {
state.device_last_seen_ms.retain(|_, last_seen| {
now.saturating_sub(*last_seen) <= PLAYER_DEVICE_RETURN_TAKEOVER_MS
});
state
.jams_by_id
.retain(|_, jam| now.saturating_sub(jam.host_last_seen_ms) <= PLAYER_JAM_IDLE_TTL_MS);
@@ -1185,6 +1231,109 @@ mod device_tests {
.is_some_and(|devices| devices.contains_key("fed:remote"))
);
}
#[test]
fn new_browser_claims_an_idle_federated_player() {
let hub = PlayerDeviceHub::default();
let user_id = 8;
hub.apply_fed_playback_state_json(
user_id,
"remote",
"Remote",
true,
serde_json::json!({
"track": {"id": 2},
"tracks": [],
"index": 0,
"position_seconds": 12.0,
"duration_seconds": 100.0,
"paused": true,
"shuffle": false,
"repeat_mode": "off",
"volume": 0.7
}),
)
.expect("valid snapshot");
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
assert_eq!(response.active_device_id.as_deref(), Some("browser"));
assert_eq!(previous.as_deref(), Some("fed:remote"));
}
#[test]
fn new_browser_does_not_claim_a_playing_federated_player() {
let hub = PlayerDeviceHub::default();
let user_id = 9;
hub.apply_fed_playback_state_json(
user_id,
"remote",
"Remote",
true,
serde_json::json!({
"track": {"id": 2},
"tracks": [],
"index": 0,
"position_seconds": 12.0,
"duration_seconds": 100.0,
"paused": false,
"shuffle": false,
"repeat_mode": "off",
"volume": 0.7
}),
)
.expect("valid snapshot");
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
assert_eq!(response.active_device_id.as_deref(), Some("fed:remote"));
assert_eq!(previous, None);
}
#[test]
fn refreshed_control_browser_keeps_its_control_role() {
let hub = PlayerDeviceHub::default();
let user_id = 10;
hub.apply_fed_playback_state_json(
user_id,
"remote",
"Remote",
true,
serde_json::json!({
"track": {"id": 2},
"tracks": [],
"index": 0,
"position_seconds": 12.0,
"duration_seconds": 100.0,
"paused": true,
"shuffle": false,
"repeat_mode": "off",
"volume": 0.7
}),
)
.expect("valid snapshot");
{
let mut state = hub.state.lock().expect("device hub");
let now = current_millis();
state.devices_by_user.entry(user_id).or_default().insert(
"browser".to_string(),
PlayerDevice {
id: "browser".to_string(),
name: "Browser".to_string(),
kind: "computer".to_string(),
last_seen_ms: now,
},
);
state
.device_last_seen_ms
.insert((user_id, "browser".to_string()), now);
}
let (response, previous) = hub.heartbeat(user_id, "browser", None, None, None);
assert_eq!(response.active_device_id.as_deref(), Some("fed:remote"));
assert_eq!(previous, None);
}
}
#[derive(Debug, sqlx::FromRow)]
@@ -1259,15 +1408,19 @@ async fn me_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let plays: (i64,) =
sqlx::query_as("SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1")
.bind(user.id)
.fetch_one(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let plays: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM furumusic__listen_event
WHERE user_id = $1 AND qualified = true",
)
.bind(user.id)
.fetch_one(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let listened_seconds: Option<i64> = sqlx::query_scalar(
"SELECT COALESCE(SUM(duration_listened), 0) FROM furumusic__play_history WHERE user_id = $1",
"SELECT (COALESCE(SUM(listened_ms), 0) / 1000)::bigint
FROM furumusic__listen_event
WHERE user_id = $1 AND qualified = true",
)
.bind(user.id)
.fetch_one(pool)
@@ -4162,6 +4315,159 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul
.collect())
}
#[derive(Debug, Serialize)]
struct SimilaritySearchResponse {
label: String,
tracks: Vec<ScoredSimilarityTrack>,
federation_tracks: Vec<crate::federation::client::TrackDto>,
federation_error: Option<String>,
queried_peers: usize,
elapsed_ms: u64,
complete: bool,
}
#[derive(Debug, Serialize)]
struct ScoredSimilarityTrack {
#[serde(flatten)]
track: TrackItem,
similarity_score: f32,
}
#[derive(Debug, Deserialize)]
struct SimilaritySearchQuery {
#[serde(default)]
local_only: bool,
}
async fn similarity_search_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
pool: &sqlx::PgPool,
Path(path): Path<PathId>,
options: cot::request::extractors::UrlQuery<SimilaritySearchQuery>,
) -> cot::Result<cot::response::Response> {
let started = std::time::Instant::now();
let Some(_user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
if path.id <= 0 {
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid track id"));
}
let mut source = load_track_items_by_ids(pool, &[path.id]).await?;
let Some(source_track) = source.pop() else {
return Ok(json_error(StatusCode::NOT_FOUND, "local track not found"));
};
let manager = crate::similarity::handle();
let query = match manager.query_for_track(path.id).await {
Ok(query) => query,
Err(error) => {
return Ok(json_error(
StatusCode::SERVICE_UNAVAILABLE,
&format!("similarity search is not ready: {error:#}"),
));
}
};
let rank_manager = std::sync::Arc::clone(&manager);
let profile_id = query.profile_id.clone();
let vector = query.vector.clone();
let source_content_id = query.source_content_id.clone();
let ranked = match tokio::task::spawn_blocking(move || {
rank_manager.rank_vector(
&profile_id,
&vector,
Some(path.id),
source_content_id.as_deref(),
49,
)
})
.await
{
Ok(Ok(ranked)) => ranked,
Ok(Err(error)) => {
return Ok(json_error(
StatusCode::SERVICE_UNAVAILABLE,
&format!("similarity search failed: {error:#}"),
));
}
Err(error) => {
return Ok(json_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("similarity worker failed: {error}"),
));
}
};
let ids = ranked
.iter()
.map(|track| track.track_id)
.collect::<Vec<_>>();
let scores: HashMap<i64, f32> = ranked
.iter()
.map(|track| (track.track_id, track.score))
.collect();
let mut local_tracks = Vec::with_capacity(ids.len() + 1);
local_tracks.push(source_track.clone());
local_tracks.extend(load_track_items_by_ids(pool, &ids).await?);
let tracks = local_tracks
.into_iter()
.map(|track| ScoredSimilarityTrack {
similarity_score: if track.id == path.id {
1.0
} else {
scores.get(&track.id).copied().unwrap_or_default()
},
track,
})
.collect();
let (config, _) = AppConfig::load_with_db(&db).await;
let (federation_tracks, federation_error, queried_peers) =
if config.federation_enabled && !options.0.local_only {
match crate::federation::handle()
.search_similarity(query, 50)
.await
{
Ok(outcome) => match crate::federation::handle()
.prepare_similarity_tracks(outcome.tracks)
.await
{
Ok(tracks) => (tracks, None, outcome.queried_peers),
Err(error) => (
Vec::new(),
Some(format!("{error:#}")),
outcome.queried_peers,
),
},
Err(error) => (Vec::new(), Some(format!("{error:#}")), 0),
}
} else {
(Vec::new(), None, 0)
};
let artists = source_track
.artists
.iter()
.map(|artist| artist.name.as_str())
.collect::<Vec<_>>()
.join(", ");
let label = if artists.is_empty() {
source_track.title.clone()
} else {
format!("{}{artists}", source_track.title)
};
Json(SimilaritySearchResponse {
label,
tracks,
federation_tracks,
federation_error,
queried_peers,
elapsed_ms: started.elapsed().as_millis() as u64,
complete: !options.0.local_only,
})
.into_response()
}
// ---------------------------------------------------------------------------
// POST /api/player/share-playlist
// ---------------------------------------------------------------------------
@@ -4842,7 +5148,7 @@ async fn devices_heartbeat_handler(
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id"));
};
let response = hub.heartbeat(
let (response, previous_active_id) = hub.heartbeat(
user.id,
&device_id,
dto.user_agent.as_deref(),
@@ -4852,6 +5158,22 @@ async fn devices_heartbeat_handler(
.as_deref(),
dto.playback_state,
);
if let Some(previous_fed_device_id) = previous_active_id
.as_deref()
.and_then(fed_device_id_from_virtual)
&& let Some(playback_state) = response.playback_state.clone()
{
let state = match serde_json::to_value(playback_state) {
Ok(state) => state,
Err(err) => return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
};
if let Err(err) = crate::federation::handle()
.fed_device_web_active_takeover(user.id, previous_fed_device_id, state)
.await
{
return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}")));
}
}
Json(response).into_response()
}
@@ -5573,7 +5895,8 @@ async fn history_list_handler(
.map_err(|e| cot::Error::internal(e.to_string()))?;
let rows = sqlx::query(
"SELECT le.listen_id, le.content_id, le.local_track_id,
"SELECT le.listen_id, le.content_id,
COALESCE(le.local_track_id, tr.local_track_id) AS local_track_id,
le.origin_device_id, le.started_at_ms, le.listened_ms,
le.track_duration_ms, le.metadata_json,
COALESCE(NULLIF(d.name, ''), le.origin_device_id) AS device_name,
@@ -5582,7 +5905,9 @@ async fn history_list_handler(
FROM furumusic__listen_event le
LEFT JOIN furumusic__fed_device d
ON d.user_id = le.user_id AND d.device_id = le.origin_device_id
LEFT JOIN furumusic__track t ON t.id = le.local_track_id
LEFT JOIN furumusic__track_ref tr ON tr.content_id = le.content_id
LEFT JOIN furumusic__track t
ON t.id = COALESCE(le.local_track_id, tr.local_track_id)
LEFT JOIN furumusic__release r ON r.id = t.release_id
WHERE le.user_id = $1 AND le.qualified = true
ORDER BY le.started_at_ms DESC, le.listen_id DESC
@@ -5595,19 +5920,62 @@ async fn history_list_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
// Listen metadata is an immutable snapshot. Once federated content has
// been materialized, resolve it through the content cache and use the
// normal local TrackItem as the presentation/action authority.
let content_ids = rows
.iter()
.map(|row| row.get::<String, _>("content_id"))
.collect::<Vec<_>>();
let local_rows = if content_ids.is_empty() {
Vec::new()
} else {
sqlx::query(
r#"SELECT DISTINCT ON (c.content_id) c.content_id, t.id AS track_id
FROM furumusic__federation_content_id_cache c
JOIN furumusic__media_file m
ON m.id = c.media_file_id AND m.sha256_hash = c.sha256_hash
JOIN furumusic__track t ON t.audio_file_id = m.id
JOIN furumusic__release r ON r.id = t.release_id
WHERE c.content_id = ANY($1)
AND t.is_hidden = false
AND r.is_hidden = false
ORDER BY c.content_id, t.id"#,
)
.bind(&content_ids)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?
};
let local_by_content = local_rows
.into_iter()
.map(|row| {
(
row.get::<String, _>("content_id"),
row.get::<i64, _>("track_id"),
)
})
.collect::<HashMap<_, _>>();
let local_ids = local_by_content.values().copied().collect::<Vec<_>>();
let local_tracks = load_track_items_by_ids(pool, &local_ids)
.await?
.into_iter()
.map(|track| (track.id, track))
.collect::<HashMap<_, _>>();
let items = rows
.into_iter()
.map(|row| {
let listen_id: String = row.get("listen_id");
let content_id: String = row.get("content_id");
let local_track_id: Option<i64> = row.get("local_track_id");
let local_track_id = local_by_content.get(&content_id).copied();
let metadata: serde_json::Value = row.get("metadata_json");
let title = metadata
let snapshot_title = metadata
.get("title")
.and_then(serde_json::Value::as_str)
.unwrap_or("Unknown track")
.to_string();
let release_title = metadata
let snapshot_release_title = metadata
.get("release_title")
.and_then(serde_json::Value::as_str)
.map(ToOwned::to_owned);
@@ -5628,20 +5996,20 @@ async fn history_list_handler(
row.get("release_cover_file_id"),
"medium",
);
let track = serde_json::json!({
let fallback_track = serde_json::json!({
"id": local_track_id.map_or_else(
|| format!("history:{content_id}"),
|id| id.to_string(),
),
"content_id": content_id,
"title": title,
"title": snapshot_title,
"track_number": null,
"disc_number": null,
"duration_seconds": duration_ms.unwrap_or_default() as f64 / 1000.0,
"artists": artist_values("artist_names"),
"featured_artists": artist_values("featured_artist_names"),
"release_id": release_id,
"release_title": release_title.clone().unwrap_or_default(),
"release_title": snapshot_release_title.clone().unwrap_or_default(),
"release_year": row.get::<Option<i32>, _>("release_year"),
"cover_url": cover_url,
"stream_url": local_track_id
@@ -5663,6 +6031,16 @@ async fn history_list_handler(
None
},
});
let local_track = local_track_id.and_then(|id| local_tracks.get(&id));
let track = local_track
.and_then(|track| serde_json::to_value(track).ok())
.unwrap_or(fallback_track);
let title = local_track
.map(|track| track.title.clone())
.unwrap_or(snapshot_title);
let release_title = local_track
.map(|track| track.release_title.clone())
.or(snapshot_release_title);
let started_at_ms: i64 = row.get("started_at_ms");
let played_at = chrono::DateTime::from_timestamp_millis(started_at_ms)
.unwrap_or_else(chrono::Utc::now)
@@ -9589,6 +9967,34 @@ impl App for PlayerApp {
}),
"player_search",
),
Route::with_handler_and_name(
"/similarity/{id}",
get({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |auth_ctx: auth::AuthContext,
session: Session,
db: Database,
path: Path<PathId>,
query: cot::request::extractors::UrlQuery<SimilaritySearchQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
similarity_search_handler(auth_ctx, session, db, pg_pool, path, query).await
}
}
}),
"player_similarity_search",
),
Route::with_handler_and_name(
"/federation/search/events",
get(
+1461
View File
File diff suppressed because it is too large Load Diff
+475 -71
View File
@@ -806,35 +806,68 @@ tbody tr:hover {
}
.settings-page {
max-width: none;
max-width: 1440px;
margin: 0 auto;
}
.settings-layout {
display: grid;
grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px);
gap: 14px;
align-items: start;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-template-areas:
"access access access access oidc oidc oidc oidc oidc oidc oidc oidc"
"agent agent agent agent agent agent agent agent agentstatus agentstatus agentstatus agentstatus"
"similarity similarity similarity similarity similarity similarity similarity similarity similaritystatus similaritystatus similaritystatus similaritystatus"
"federation federation federation federation federation federation federation federation federation federation federation federation"
"lastfm lastfm lastfm lastfm lastfm lastfm lastfm lastfm developer developer developer developer"
"actions actions actions actions actions actions actions actions actions actions actions actions";
gap: 16px;
align-items: stretch;
}
.settings-column {
display: grid;
gap: 14px;
align-content: start;
display: contents;
}
.settings-side .settings-grid {
.settings-section {
min-width: 0;
margin: 0;
}
.settings-access { grid-area: access; }
.settings-oidc { grid-area: oidc; }
.settings-agent { grid-area: agent; }
.settings-agent-status { grid-area: agentstatus; }
.settings-similarity { grid-area: similarity; }
.settings-similarity-status { grid-area: similaritystatus; }
.settings-federation { grid-area: federation; }
.settings-lastfm { grid-area: lastfm; }
.settings-developer { grid-area: developer; }
.settings-section-narrow { border-left: 2px solid rgba(29, 185, 84, 0.55); }
.settings-access .settings-grid,
.settings-developer .settings-grid {
grid-template-columns: minmax(0, 1fr);
}
.settings-section-narrow .panel-head {
background: rgba(29, 185, 84, 0.035);
}
.settings-actions {
grid-column: 1 / -1;
grid-area: actions;
position: sticky;
bottom: 0;
z-index: 5;
border: 1px solid var(--border-color);
border-radius: 8px;
background: rgba(35, 35, 35, 0.96);
backdrop-filter: blur(10px);
}
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding: 14px;
gap: 14px 16px;
padding: 16px;
}
.settings-card {
@@ -843,22 +876,26 @@ tbody tr:hover {
.setting-field {
min-width: 0;
max-width: 480px;
}
.setting-field.settings-short { max-width: 150px; }
.settings-wide { max-width: 680px; }
.setting-field label,
.setting-toggle label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
margin-bottom: 7px;
color: var(--text-secondary);
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
font-size: 12px;
font-weight: 700;
}
.setting-field input {
.setting-field input,
.setting-field select {
width: 100%;
height: 34px;
padding: 0 10px;
@@ -869,13 +906,27 @@ tbody tr:hover {
outline: none;
}
.setting-field input:focus {
.setting-field textarea {
width: 100%;
min-height: 68px;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-primary);
color: var(--text-primary);
outline: none;
resize: vertical;
}
.setting-field input:focus,
.setting-field select:focus,
.setting-field textarea:focus {
border-color: var(--accent);
}
.setting-toggle {
min-height: 74px;
padding: 12px;
min-height: 68px;
padding: 11px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
@@ -894,9 +945,12 @@ tbody tr:hover {
font-weight: 800;
}
.setting-toggle input {
.setting-toggle input,
.setting-toggle-row input[type="checkbox"] {
flex: 0 0 auto;
width: 18px;
height: 18px;
padding: 0;
accent-color: var(--accent);
}
@@ -904,7 +958,8 @@ tbody tr:hover {
margin-top: 6px;
color: var(--text-subdued);
font-size: 11px;
line-height: 1.4;
line-height: 1.45;
max-width: 68ch;
}
.source-pill {
@@ -929,6 +984,101 @@ tbody tr:hover {
grid-column: 1 / -1;
}
.settings-federation-body {
display: grid;
grid-template-columns: minmax(360px, 4fr) minmax(580px, 8fr);
gap: 20px;
padding: 16px;
}
.settings-federation-body > .settings-grid,
.settings-federation-body > .probe-body {
padding: 0;
}
.federation-status-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.transport-log {
grid-column: 1 / -1;
min-width: 0;
}
.transport-log-head {
display: flex;
align-items: end;
justify-content: space-between;
gap: 12px;
margin: 14px 0 7px;
}
.transport-log-head strong { font-size: 12px; }
.transport-log-head span { color: var(--text-subdued); font-size: 11px; }
.transport-log-scroll {
max-height: 210px;
overflow: auto;
border: 1px solid var(--border-color);
border-radius: 7px;
background: var(--bg-primary);
}
.transport-row {
display: grid;
grid-template-columns: 68px minmax(100px, 1.2fr) 84px 72px 54px 64px 64px 66px 66px 66px;
gap: 8px;
align-items: center;
min-width: 850px;
min-height: 30px;
padding: 5px 9px;
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
color: var(--text-secondary);
font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.transport-row:last-child { border-bottom: 0; }
.transport-row.transport-header {
position: sticky;
top: 0;
z-index: 1;
color: var(--text-subdued);
background: var(--bg-elevated);
font-size: 10px;
font-weight: 850;
text-transform: uppercase;
}
.transport-row > span { overflow: hidden; text-overflow: ellipsis; }
.transport-number { text-align: right; }
@media (max-width: 1000px) {
.settings-layout {
grid-template-columns: 1fr;
grid-template-areas:
"access"
"oidc"
"agent"
"agentstatus"
"similarity"
"similaritystatus"
"federation"
"lastfm"
"developer"
"actions";
}
.settings-federation-body { grid-template-columns: 1fr; }
}
@media (max-width: 700px) {
.settings-grid,
.federation-status-grid { grid-template-columns: 1fr; }
.setting-field { max-width: none; }
}
.settings-note {
padding: 14px;
color: var(--text-secondary);
@@ -937,7 +1087,7 @@ tbody tr:hover {
}
.probe-body {
padding: 14px;
padding: 16px;
}
.probe-intro {
@@ -955,9 +1105,43 @@ tbody tr:hover {
}
.probe-row {
display: flex;
justify-content: space-between;
gap: 10px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: baseline;
gap: 12px;
min-height: 22px;
}
.probe-row strong {
max-width: 210px;
overflow: hidden;
text-align: right;
text-overflow: ellipsis;
white-space: nowrap;
}
.similarity-profile-details {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid var(--border-color);
}
.similarity-profile-details > span {
display: block;
margin-bottom: 5px;
color: var(--text-subdued);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.similarity-profile-details pre {
margin: 0;
color: var(--text-secondary);
font: inherit;
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
}
.library-row {
@@ -1560,7 +1744,7 @@ tbody tr:hover {
<p x-text="pageSubtitle()"></p>
</div>
<div class="top-actions">
<button class="btn" @click="refreshAll()">
<button class="btn" @click="refreshAll()" x-show="activeView !== 'settings'">
<i data-lucide="refresh-cw"></i>
Refresh
</button>
@@ -2059,7 +2243,7 @@ tbody tr:hover {
<div class="settings-page">
<form class="settings-layout" @submit.prevent="saveSettings()">
<div class="settings-column">
<section class="panel">
<section class="panel settings-section settings-section-wide settings-oidc">
<div class="panel-head">
<div class="panel-title">
<strong>OIDC</strong>
@@ -2070,6 +2254,7 @@ tbody tr:hover {
<div class="setting-field settings-wide">
<label>Callback URL</label>
<input readonly :value="callbackUrl()" />
<div class="setting-help">Register this exact redirect URL in your identity provider.</div>
</div>
<div class="setting-field">
<label>
@@ -2105,6 +2290,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span>
</label>
<input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" />
<div class="setting-help">Comma-separated identity-provider groups whose members receive administrator access.</div>
</div>
<div class="setting-field">
<label>
@@ -2112,11 +2298,12 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span>
</label>
<input x-model="settingsDraft.oidc_user_groups" />
<div class="setting-help">Comma-separated groups allowed to sign in. Leave empty to allow any authenticated OIDC user.</div>
</div>
</div>
</section>
<section class="panel">
<section class="panel settings-section settings-section-wide settings-agent">
<div class="panel-head">
<div class="panel-title">
<strong>Agent</strong>
@@ -2134,12 +2321,13 @@ tbody tr:hover {
<input type="checkbox" x-model="settingsDraft.agent_enabled" />
</div>
</div>
<div class="setting-field">
<div class="setting-field settings-short">
<label>
<span>Concurrency</span>
<span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span>
</label>
<input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" />
<div class="setting-help">Maximum number of inbox items processed at the same time. Higher values use more CPU and LLM capacity.</div>
</div>
<div class="setting-field settings-wide">
<label>
@@ -2161,6 +2349,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span>
</label>
<input x-model="settingsDraft.agent_llm_url" />
<div class="setting-help">Base URL of an OpenAI-compatible service. The agent sends chat requests to its <code>/v1/chat/completions</code> endpoint.</div>
</div>
<div class="setting-field">
<label>
@@ -2168,6 +2357,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span>
</label>
<input x-model="settingsDraft.agent_llm_model" />
<div class="setting-help">Model identifier sent to the configured LLM service, for example the name exposed by your local model server.</div>
</div>
<div class="setting-field">
<label>
@@ -2175,6 +2365,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span>
</label>
<input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" />
<div class="setting-help">Complete HTTP Authorization value expected by the LLM endpoint, for example <code>Bearer …</code>.</div>
</div>
<div class="setting-field">
<label>
@@ -2182,6 +2373,7 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span>
</label>
<input x-model="settingsDraft.agent_confidence_threshold" />
<div class="setting-help">Minimum confidence required to accept generated metadata automatically. Lower-confidence results are sent for review.</div>
</div>
<div class="setting-field">
<label>
@@ -2189,13 +2381,71 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span>
</label>
<input x-model="settingsDraft.agent_context_limit" />
<div class="setting-help">Maximum model context budget in tokens. Reduce it for smaller models or increase it when processing large batches.</div>
</div>
</div>
</section>
<section class="panel settings-section settings-section-wide settings-similarity">
<div class="panel-head">
<div class="panel-title">
<strong>Similarity Search</strong>
<span>Local audio embeddings and federated nearest-track search</span>
</div>
</div>
<div class="settings-grid">
<div class="setting-toggle settings-wide">
<label>
<span>Similarity search enabled</span>
<span class="source-pill" :class="sourceClass('similarity_enabled')" x-text="settingSource('similarity_enabled')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.similarity_enabled ? 'Enabled for this instance' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.similarity_enabled" />
</div>
<div class="setting-help">Builds an audio fingerprint index for finding musically similar tracks. When federation is enabled, compatible peers can also participate in searches.</div>
</div>
<div class="setting-field settings-wide">
<label>
<span>Embedding model</span>
<span class="source-pill" :class="sourceClass('similarity_model')" x-text="settingSource('similarity_model')"></span>
</label>
<select x-model="settingsDraft.similarity_model">
<template x-for="model in similarityStatus.models || []" :key="model.id">
<option :value="model.id" x-text="`${model.id} · ${model.dimensions}d`"></option>
</template>
</select>
<div class="setting-help">
<span>The model converts audio into vectors used for comparison. Changing it rebuilds the search index.</span>
<span x-show="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license" x-text="' License: ' + (similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license"></span>
</div>
</div>
<div class="setting-field settings-wide">
<label>
<span>Preprocessing profile</span>
<span class="source-pill" :class="sourceClass('similarity_profile')" x-text="settingSource('similarity_profile')"></span>
</label>
<select x-model="settingsDraft.similarity_profile">
<template x-for="profile in similarityStatus.profiles || []" :key="profile.id">
<option :value="profile.id" x-text="profile.title"></option>
</template>
</select>
<div class="setting-help">Controls how audio is decoded and normalized before comparison. Changing it rebuilds the search index.</div>
</div>
<div class="setting-field settings-short">
<label>
<span>Background workers</span>
<span class="source-pill" :class="sourceClass('similarity_workers')" x-text="settingSource('similarity_workers')"></span>
</label>
<input type="number" min="1" max="16" step="1" x-model="settingsDraft.similarity_workers" />
<div class="setting-help">Number of tracks indexed in parallel. Higher values finish sooner but use more CPU and memory.</div>
</div>
</div>
</section>
</div>
<div class="settings-column settings-side">
<section class="panel">
<section class="panel settings-section settings-section-narrow settings-access">
<div class="panel-head">
<div class="panel-title">
<strong>Authentication</strong>
@@ -2226,26 +2476,15 @@ tbody tr:hover {
</div>
</section>
<section class="panel">
<section class="panel settings-section settings-lastfm">
<div class="panel-head">
<div class="panel-title">
<strong>API</strong>
<span>Developer and enrichment integrations</span>
<strong>Last.fm Integration</strong>
<span>Metadata enrichment and scrobbling credentials</span>
</div>
<span class="badge" :class="settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
</div>
<div class="settings-grid">
<div class="setting-toggle">
<label>
<span>Swagger UI</span>
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.swagger_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
</div>
<div class="setting-help">Interactive API docs at /swagger/ after restart.</div>
</div>
<div class="setting-field">
<label>
<span>{{ t.settings_lastfm_api_key }}</span>
@@ -2265,7 +2504,29 @@ tbody tr:hover {
</div>
</section>
<section class="panel">
<section class="panel settings-section settings-developer">
<div class="panel-head">
<div class="panel-title">
<strong>Developer API</strong>
<span>Interactive API documentation</span>
</div>
</div>
<div class="settings-grid">
<div class="setting-toggle settings-wide">
<label>
<span>Swagger UI</span>
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
</label>
<div class="setting-toggle-row">
<span x-text="settingsDraft.swagger_enabled ? 'Available at /swagger/' : 'Disabled' "></span>
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
</div>
<div class="setting-help">Exposes interactive API documentation at <code>/swagger/</code> for developers and integrations.</div>
</div>
</div>
</section>
<section class="panel settings-section settings-section-full settings-federation">
<div class="panel-head">
<div class="panel-title">
<strong>Federation</strong>
@@ -2273,6 +2534,7 @@ tbody tr:hover {
</div>
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
</div>
<div class="settings-federation-body">
<div class="settings-grid">
<div class="setting-toggle">
<label>
@@ -2283,7 +2545,7 @@ tbody tr:hover {
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
<input type="checkbox" x-model="settingsDraft.federation_enabled" />
</div>
<div class="setting-help">Applies immediately on save — no restart needed. Peers can browse and stream every visible track.</div>
<div class="setting-help">Lets other peers in this logical network discover the visible library and request audio streams from this instance.</div>
</div>
<div class="setting-field settings-wide">
<label>
@@ -2291,9 +2553,9 @@ tbody tr:hover {
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
</label>
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
<div class="setting-help">Every peer using the same id finds the others automatically.</div>
<div class="setting-help">Peers with the same Network ID form one isolated logical network and discover each other automatically. You may choose any private value; use the exact same value on every peer that should join this network.</div>
</div>
<div class="setting-field">
<div class="setting-field settings-wide">
<label>
<span>Save federated tracks on play</span>
<span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span>
@@ -2302,19 +2564,21 @@ tbody tr:hover {
<span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span>
<input type="checkbox" x-model="settingsDraft.federation_save_on_listen" />
</div>
<div class="setting-help">Server-wide policy. Imported tracks become available to every user and are published by this peer. Federation metadata is trusted and bypasses the AI agent.</div>
<div class="setting-help">When enabled, a federated track is permanently imported after playback and becomes available to every local user. Otherwise it remains only in the temporary cache. Imported peer metadata is trusted as provided and does not enter AI review.</div>
</div>
</div>
<div class="probe-body" x-show="federationStatus.node">
<div class="federation-status-grid">
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
<div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div>
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div>
<div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div>
<div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div>
<div class="probe-row"><span>Similarity routing peers</span><strong x-text="(federationStatus.node && federationStatus.node.similarity_routing_peers) ?? '-'"></strong></div>
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
</div>
<div class="probe-table" x-show="fedTransport().total_samples > 0" style="margin-top:10px">
<div class="probe-table" x-show="fedTransport().total_samples > 0">
<div class="probe-row">
<span>Transport path</span>
<strong>
@@ -2323,26 +2587,37 @@ tbody tr:hover {
</div>
<div class="probe-row"><span>RTT</span><strong x-text="fedRtt(fedTransport().last_rtt_ms)"></strong></div>
<div class="probe-row"><span>Path samples</span><strong x-text="`${fedTransport().direct_samples || 0} direct · ${fedTransport().relay_samples || 0} relay · ${fedTransport().custom_samples || 0} custom · ${fedTransport().unknown_samples || 0} unknown`"></strong></div>
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().sync_samples || 0} sync`"></strong></div>
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div>
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
</div>
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
<template x-for="(sample, index) in fedTransport().last.slice(0, 5)" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
<div class="probe-row">
<span x-text="`${sample.protocol} · ${sample.direction} · ${sample.phase}`"></span>
<strong>
<span class="badge" :class="fedPathBadge(sample.selected_path)" x-text="sample.selected_path || 'unknown'"></span>
<span x-text="` ${fedRtt(sample.selected_rtt_ms)} · tx ${formatBytes(sample.total_tx_bytes || 0)} · rx ${formatBytes(sample.total_rx_bytes || 0)} · lost ${formatBytes(sample.lost_bytes || 0)}`"></span>
</strong>
<div class="transport-log" x-show="fedTransport().last && fedTransport().last.length">
<div class="transport-log-head">
<strong>Recent transport operations</strong>
<span>Newest first · updates automatically</span>
</div>
<div class="transport-log-scroll">
<div class="transport-row transport-header">
<span>Time</span><span>User / peer</span><span>Protocol</span><span>Direction</span><span>Phase</span><span>Path</span><span class="transport-number">RTT</span><span class="transport-number">TX</span><span class="transport-number">RX</span><span class="transport-number">Lost</span>
</div>
</template>
<template x-for="(sample, index) in fedTransport().last" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
<div class="transport-row">
<span :title="sample.at" x-text="formatTransportTime(sample.at)"></span>
<span :title="sample.user_name || sample.peer_id" x-text="sample.user_name || fedShort(sample.peer_id)"></span>
<span x-text="sample.protocol || '-'"></span>
<span x-text="sample.direction || '-'"></span>
<span x-text="sample.phase || '-'"></span>
<span x-text="sample.selected_path || 'unknown'"></span>
<span class="transport-number" x-text="fedRtt(sample.selected_rtt_ms)"></span>
<span class="transport-number" x-text="formatBytes(sample.total_tx_bytes || 0)"></span>
<span class="transport-number" x-text="formatBytes(sample.total_rx_bytes || 0)"></span>
<span class="transport-number" x-text="formatBytes(sample.lost_bytes || 0)"></span>
</div>
</template>
</div>
</div>
</div>
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
<i data-lucide="refresh-cw"></i>
Refresh
</button>
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
<i data-lucide="upload-cloud"></i>
Publish now
@@ -2364,9 +2639,48 @@ tbody tr:hover {
</div>
</div>
</div>
</div>
</section>
<section class="panel">
<section class="panel settings-section settings-section-narrow settings-similarity-status">
<div class="panel-head">
<div class="panel-title">
<strong>Search Index Statistics</strong>
<span>Similarity model, indexing progress, and storage</span>
</div>
<span class="badge" :class="similarityBadge()" x-text="similarityStatus.status?.phase || 'disabled'"></span>
</div>
<div class="probe-body">
<div class="probe-table">
<div class="probe-row"><span>Model</span><strong x-text="similarityStatus.status?.model || settingsDraft.similarity_model || '-' "></strong></div>
<div class="probe-row"><span>Active profile</span><strong :title="similarityStatus.status?.active_profile || ''" x-text="similarityStatus.status?.active_profile ? similarityStatus.status.active_profile.slice(0, 22) + '…' : 'not ready'"></strong></div>
<div class="probe-row" x-show="similarityStatus.status?.target_profile && similarityStatus.status?.target_profile !== similarityStatus.status?.active_profile">
<span>Processing profile</span><strong :title="similarityStatus.status?.target_profile || ''" x-text="similarityStatus.status?.target_profile?.slice(0, 22) + '…'"></strong>
</div>
<div class="probe-row"><span>Visible tracks</span><strong x-text="similarityStatus.status?.total_tracks ?? 0"></strong></div>
<div class="probe-row"><span>Processed</span><strong x-text="`${similarityStatus.status?.completed_tracks || 0} · ${similarityProgress()}%`"></strong></div>
<div class="probe-row"><span>Failed</span><strong x-text="similarityStatus.status?.failed_tracks || 0"></strong></div>
<div class="probe-row"><span>Stored</span><strong x-text="`${similarityStatus.status?.stored_vectors || 0} vectors · ${formatBytes(similarityStatus.status?.stored_bytes || 0)}`"></strong></div>
<div class="probe-row" x-show="similarityStatus.status?.current_track"><span>Current track</span><strong x-text="similarityStatus.status?.current_track"></strong></div>
</div>
<div class="similarity-profile-details">
<span>Selected preprocessing profile</span>
<pre x-text="selectedSimilarityProfile()?.details || 'Profile information is not available.'"></pre>
</div>
<div style="height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-top:12px" x-show="similarityStatus.status?.phase === 'processing'">
<div style="height:100%;background:var(--accent);transition:width .25s" :style="`width:${similarityProgress()}%`"></div>
</div>
<p class="probe-intro muted" x-show="similarityStatus.status?.last_error" x-text="similarityStatus.status?.last_error"></p>
<div class="toolbar" style="margin-top:14px;flex-wrap:wrap;gap:8px">
<button class="btn danger" type="button" @click="clearSimilarityEmbeddings()" :disabled="similarityLoading || !(similarityStatus.status?.stored_vectors > 0)">
<i data-lucide="trash-2"></i>
Clear all embeddings
</button>
</div>
</div>
</section>
<section class="panel settings-section settings-section-narrow settings-agent-status">
<div class="panel-head">
<div class="panel-title">
<strong>Agent Status</strong>
@@ -2397,10 +2711,6 @@ tbody tr:hover {
<div class="action-strip settings-actions">
<span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span>
<div class="toolbar">
<button class="btn" type="button" @click="loadSettings()">
<i data-lucide="refresh-cw"></i>
Reload
</button>
<button class="btn primary" type="submit" :disabled="settingsSaving">
<i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i>
<span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span>
@@ -2472,7 +2782,7 @@ tbody tr:hover {
<div class="user-activity-row">
<div class="user-activity-cover">
<template x-if="play.cover_url">
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy">
<img :src="play.cover_url" alt="" aria-hidden="true" loading="lazy">
</template>
<template x-if="!play.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
@@ -2936,7 +3246,11 @@ function adminV2() {
agent_concurrency: '',
federation_enabled: false,
federation_network_id: '',
federation_save_on_listen: false
federation_save_on_listen: false,
similarity_enabled: false,
similarity_model: 'discogs-effnet-bsdynamic-1',
similarity_profile: 'furumi-full-track-v1',
similarity_workers: '1'
},
settingsProbe: { status: 'idle', ok: false },
settingsProbeLoading: false,
@@ -2944,6 +3258,8 @@ function adminV2() {
federationLoading: false,
federationTicket: '',
fedConnectTicket: '',
similarityStatus: { status: { phase: 'disabled' }, models: [], profiles: [] },
similarityLoading: false,
settingsSaving: false,
routeReady: false,
poller: null,
@@ -3007,6 +3323,11 @@ function adminV2() {
]);
} else if (this.activeView === 'users') {
await Promise.allSettled([this.loadUsers(false)]);
} else if (this.activeView === 'settings') {
await Promise.allSettled([
this.loadFederation(false),
this.loadSimilarity(false)
]);
} else {
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
}
@@ -3069,6 +3390,10 @@ function adminV2() {
} else if (this.activeView === 'settings') {
if (updateRoute) this.setRoute('#settings');
await this.loadSettings();
await Promise.allSettled([
this.loadFederation(false),
this.loadSimilarity(false)
]);
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
await this.loadSettingsProbe(false);
}
@@ -3216,6 +3541,21 @@ function adminV2() {
async saveSettings() {
if (this.settingsSaving) return;
const networkSimilarityWasEnabled = Boolean(
this.settings.values?.similarity_enabled
&& this.settings.values?.federation_enabled
);
const networkSimilarityWillBeEnabled = Boolean(
this.settingsDraft.similarity_enabled
&& this.settingsDraft.federation_enabled
);
if (!networkSimilarityWasEnabled && networkSimilarityWillBeEnabled) {
const accepted = window.confirm(
'Similarity searches send an anonymized embedding of the selected track to federation peers. '
+ 'Peers do not receive a user identity, but the query can technically reveal what is being searched. Enable this for the instance?'
);
if (!accepted) return;
}
this.settingsSaving = true;
try {
await this.request(`${this.apiBase}/settings`, {
@@ -3224,6 +3564,7 @@ function adminV2() {
});
await this.loadSettings(false);
await this.loadFederation(false);
await this.loadSimilarity(false);
this.showToast('Settings saved');
} catch (error) {
this.showToast(error.message);
@@ -3238,6 +3579,7 @@ function adminV2() {
this.setRoute('#settings');
await this.loadSettings();
await this.loadFederation(false);
await this.loadSimilarity(false);
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
await this.loadSettingsProbe(false);
}
@@ -3255,6 +3597,57 @@ function adminV2() {
}
},
async loadSimilarity(showErrors = true) {
this.similarityLoading = true;
try {
this.similarityStatus = await this.request(`${this.apiBase}/similarity`);
} catch (error) {
if (showErrors) this.showToast(error.message);
} finally {
this.similarityLoading = false;
this.icons();
}
},
async clearSimilarityEmbeddings() {
if (!window.confirm('Delete embeddings for every model and preprocessing profile? Audio files are not affected.')) return;
this.similarityLoading = true;
try {
await this.request(`${this.apiBase}/similarity/clear`, {
method: 'POST',
body: '{}'
});
await this.loadSimilarity(false);
this.showToast('All embeddings cleared');
} catch (error) {
this.showToast(error.message);
} finally {
this.similarityLoading = false;
this.icons();
}
},
selectedSimilarityProfile() {
return (this.similarityStatus.profiles || []).find(
profile => profile.id === this.settingsDraft.similarity_profile
) || null;
},
similarityBadge() {
const phase = this.similarityStatus.status?.phase || 'disabled';
if (phase === 'ready') return 'ok';
if (phase === 'error') return 'failed';
if (['downloading', 'loading', 'processing'].includes(phase)) return 'running';
return 'disabled';
},
similarityProgress() {
const status = this.similarityStatus.status || {};
const total = Number(status.total_tracks || 0);
const completed = Number(status.completed_tracks || 0);
return total > 0 ? Math.min(100, Math.round(completed * 100 / total)) : 0;
},
async fedSyncNow() {
this.federationLoading = true;
try {
@@ -3318,6 +3711,17 @@ function adminV2() {
return ms != null ? `${Math.round(Number(ms))} ms` : '-';
},
formatTransportTime(value) {
const date = new Date(value);
if (!value || Number.isNaN(date.getTime())) return '-';
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
},
async loadSettingsProbe(showErrors = true) {
this.settingsProbeLoading = true;
try {
+1
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111827'/%3E%3Cpath d='M27 15v31.5a9 9 0 1 1-5-8.1V22l27-6v24.5a9 9 0 1 1-5-8.1V15.9L27 20.5' fill='%2367e8f9'/%3E%3C/svg%3E">
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
{% block head_extra %}{% endblock head_extra %}
</head>
+12 -1
View File
@@ -653,6 +653,17 @@
</button>
</div>
<section class="user-settings-section">
<div class="user-settings-section-head">
<div>
<h4>{{ t.player_interface_language }}</h4>
<p>{{ t.player_language_description }}</p>
</div>
<button class="settings-secondary-btn"
onclick="location.href='/set-lang?lang={% if t.lang.code() == "en" %}ru{% else %}en{% endif %}&next='+encodeURIComponent(location.pathname+location.search+location.hash)">{{ t.player_switch_language }}</button>
</div>
</section>
<section class="user-settings-section">
<div class="user-settings-section-head">
<div>
@@ -814,7 +825,7 @@
@click.stop="$store.history.playFrom(idx)"
:title="item.track?.title || item.track_title">
<template x-if="item.track && item.track.cover_url">
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
<img :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy">
</template>
<template x-if="!item.track || !item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
+321 -17
View File
@@ -44,6 +44,9 @@ const T = {
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
startRadio: "{{ t.player_start_radio }}",
radioFailed: "{{ t.player_radio_failed }}",
searchSimilarTo: "{{ t.player_search_similar_to }}",
findSimilar: "{{ t.player_find_similar }}",
similarityFailed: "{{ t.player_similarity_failed }}",
share: "{{ t.player_share }}",
shareTrack: "{{ t.player_share_track }}",
shareQueue: "{{ t.player_share_queue }}",
@@ -735,6 +738,7 @@ document.addEventListener('alpine:init', () => {
_remoteStateReceivedAt: 0,
_remoteStateTimer: null,
_prefetchedQueueKey: null,
_baseDocumentTitle: '',
_hasInitialShareLink() {
const params = new URLSearchParams(window.location.search);
@@ -744,6 +748,10 @@ document.addEventListener('alpine:init', () => {
init() {
audio.volume = this.volume;
this._baseDocumentTitle = document.title.replace(/^▶\s*/, '');
Alpine.effect(() => {
document.title = `${this.isPlaying ? '▶ ' : ''}${this._baseDocumentTitle}`;
});
audio.addEventListener('timeupdate', () => {
this.currentTime = audio.currentTime;
@@ -2190,6 +2198,8 @@ document.addEventListener('alpine:init', () => {
_dragOverIdx: null,
_pointerDragMove: null,
_pointerDragEnd: null,
_playNextGroupId: null,
_playNextGroupSequence: 0,
add(track) {
this.addToEnd([track]);
@@ -2202,13 +2212,21 @@ document.addEventListener('alpine:init', () => {
effectiveCurrentIndex() {
const currentTrack = Alpine.store('player')?.currentTrack || null;
if (currentTrack?.id) {
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
const currentKey = this._trackIdentity(currentTrack);
if (currentKey) {
const index = this.tracks.findIndex(track => this._trackIdentity(track) === currentKey);
if (index >= 0) return index;
}
if (!this.tracks.length) return -1;
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
},
_trackIdentity(track) {
if (track?.content_id) return `content:${track.content_id}`;
if (track?.id != null && track.id !== '') return `id:${String(track.id)}`;
return '';
},
queueItemState(index) {
const current = this.effectiveCurrentIndex();
if (current < 0) return 'upcoming';
@@ -2244,8 +2262,9 @@ document.addEventListener('alpine:init', () => {
},
syncCurrentIndexToTrack(track) {
if (!track?.id || !this.tracks.length) return -1;
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
const key = this._trackIdentity(track);
if (!key || !this.tracks.length) return -1;
const index = this.tracks.findIndex(item => this._trackIdentity(item) === key);
if (index >= 0) this.currentIndex = index;
return index;
},
@@ -2272,6 +2291,7 @@ document.addEventListener('alpine:init', () => {
playRelease(tracks, startIndex) {
this.tracks = this._tracksForQueueAdd(tracks);
this._playNextGroupId = null;
this.playFromIndex(startIndex || 0);
},
@@ -2439,8 +2459,35 @@ document.addEventListener('alpine:init', () => {
_addNextLocal(tracks) {
const items = this._tracksWithJamDefaults(tracks);
if (!items.length) return;
const insertAt = Math.min(this.currentIndex + 1, this.tracks.length);
this.tracks.splice(insertAt, 0, ...items);
const current = this.effectiveCurrentIndex();
let insertAt = Math.min(Math.max(0, current + 1), this.tracks.length);
let groupId = this._playNextGroupId
|| this.tracks[insertAt]?._playNextGroupId
|| null;
if (groupId) this._playNextGroupId = groupId;
if (groupId) {
let lastGroupIndex = -1;
for (let index = current; index < this.tracks.length; index++) {
if (this.tracks[index]?._playNextGroupId === groupId) {
lastGroupIndex = index;
}
}
if (lastGroupIndex >= current) {
insertAt = lastGroupIndex + 1;
} else {
groupId = null;
}
}
if (!groupId) {
this._playNextGroupSequence += 1;
groupId = `next-${Date.now()}-${this._playNextGroupSequence}`;
this._playNextGroupId = groupId;
}
const groupedItems = items.map(item => ({
...item,
_playNextGroupId: groupId,
}));
this.tracks.splice(insertAt, 0, ...groupedItems);
},
_removeLocal(idx) {
@@ -2463,6 +2510,7 @@ document.addEventListener('alpine:init', () => {
if (toIdx < 0 || toIdx >= this.tracks.length) return;
const [track] = this.tracks.splice(fromIdx, 1);
this.tracks.splice(toIdx, 0, track);
this._playNextGroupId = null;
// Adjust currentIndex to follow the currently playing track
if (this.currentIndex === fromIdx) {
this.currentIndex = toIdx;
@@ -2476,6 +2524,7 @@ document.addEventListener('alpine:init', () => {
_clearLocal() {
this.tracks = [];
this.currentIndex = 0;
this._playNextGroupId = null;
},
});
@@ -2498,6 +2547,9 @@ document.addEventListener('alpine:init', () => {
searchQuery: '',
searchResults: null,
searchLoading: false,
similaritySearchLabel: '',
similaritySearchError: '',
similaritySearchStats: { loading: false, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 },
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
federationPreparing: {},
@@ -2589,6 +2641,8 @@ document.addEventListener('alpine:init', () => {
this.searchQuery = q;
this.search(q, options);
}
} else if (view === 'similar' && id) {
this.searchSimilar(id, options);
} else {
this.goArtists(options);
}
@@ -3042,6 +3096,7 @@ document.addEventListener('alpine:init', () => {
openTrackInfo(track) {
Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track), [
{ label: T.startRadio, run: () => this.startRadio('track', track?.id) },
{ label: T.findSimilar, run: () => this.searchSimilar(track) },
]);
},
@@ -3380,11 +3435,14 @@ document.addEventListener('alpine:init', () => {
}
this.view = 'search';
this.searchLoading = true;
this.similaritySearchLabel = '';
this.similaritySearchError = '';
this.startFederationSearch(q);
try {
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
if (!res.ok) throw new Error('failed');
this.searchResults = await res.json();
this.applyFederationArtworkFallbacks();
} catch {
this.searchResults = { artists: [], releases: [], tracks: [] };
}
@@ -3392,11 +3450,151 @@ document.addEventListener('alpine:init', () => {
this._afterNavigation(options);
},
async searchSimilar(trackOrId, options = {}) {
const id = Number(typeof trackOrId === 'object' ? trackOrId?.id : trackOrId);
if (!Number.isInteger(id) || id <= 0) return;
const track = typeof trackOrId === 'object' ? trackOrId : null;
const artist = (track?.artists || []).map(item => item.name).filter(Boolean).join(', ');
const initialLabel = track
? [track.title, artist].filter(Boolean).join(' — ')
: '';
this._beginNavigation(`#similar/${id}`, options);
if (this.view !== 'search') this._previousView = this.view;
this.stopFederationSearch();
this.view = 'search';
this.searchQuery = initialLabel;
this.similaritySearchLabel = initialLabel;
this.similaritySearchError = '';
this.searchLoading = true;
this.searchResults = null;
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
this.similaritySearchStats = { loading: true, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 };
Alpine.store('info').close();
try {
const completeRequest = fetch(`/api/player/similarity/${id}`);
const localResponse = await fetch(`/api/player/similarity/${id}?local_only=true`);
const localData = await localResponse.json().catch(() => ({}));
if (!localResponse.ok) throw new Error(localData.error || T.similarityFailed);
this.similaritySearchLabel = localData.label || initialLabel;
this.searchQuery = this.similaritySearchLabel;
this.searchResults = {
artists: [],
releases: [],
tracks: Array.isArray(localData.tracks) ? localData.tracks : [],
};
this.searchLoading = false;
this.similaritySearchStats = {
loading: true,
...this.similarityResultCounts(this.searchResults.tracks, []),
peers: 0,
elapsed_ms: localData.elapsed_ms || 0,
};
const response = await completeRequest;
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || T.similarityFailed);
this.federationSearch = {
loading: false,
error: data.federation_error || '',
artists: [],
releases: [],
tracks: Array.isArray(data.federation_tracks) ? data.federation_tracks : [],
};
this.similaritySearchStats = {
loading: false,
...this.similarityResultCounts(
this.searchResults.tracks,
this.federationSearch.tracks
),
peers: Number(data.queried_peers || 0),
elapsed_ms: Number(data.elapsed_ms || 0),
};
} catch (error) {
if (!this.searchResults) {
this.searchResults = { artists: [], releases: [], tracks: [] };
this.similaritySearchError = error?.message || T.similarityFailed;
} else {
this.federationSearch = {
...this.federationSearch,
loading: false,
error: error?.message || T.similarityFailed,
};
}
this.similaritySearchStats = {
...this.similaritySearchStats,
loading: false,
};
}
this.searchLoading = false;
this._afterNavigation(options);
},
similarityResultCounts(localTracks = [], federationTracks = []) {
const artists = new Set();
for (const track of localTracks) {
for (const artist of [...(track?.artists || []), ...(track?.featured_artists || [])]) {
const name = this.normalizeFederationSearchText(artist?.name);
if (name) artists.add(name);
}
}
for (const track of federationTracks) {
const metadata = track?.metadata || {};
for (const artist of [...(metadata.artists || []), ...(metadata.featured_artists || [])]) {
const name = this.normalizeFederationSearchText(artist?.name);
if (name) artists.add(name);
}
}
return {
tracks: localTracks.length + federationTracks.length,
artists: artists.size,
};
},
similarityTrackOrder(track) {
const score = Number(track?.similarity_score);
if (!Number.isFinite(score)) return 1000000;
return Math.max(0, Math.round((1 - score) * 100000));
},
similarityQueueTracks() {
const local = (this.searchResults?.tracks || []).map(track => ({ ...track }));
const federated = (this.federationSearch?.tracks || []).map(track => ({
...this.federationQueueTrack(track),
similarity_score: track.similarity_score,
}));
return [...local, ...federated].sort((left, right) => {
const score = Number(right?.similarity_score || 0)
- Number(left?.similarity_score || 0);
if (score) return score;
return String(left?.title || '').localeCompare(String(right?.title || ''));
});
},
playSimilarityResult(track) {
const queue = Alpine.store('queue');
const tracks = this.similarityQueueTracks();
const key = queue._trackIdentity(track);
const index = tracks.findIndex(item => queue._trackIdentity(item) === key);
if (index >= 0) queue.playRelease(tracks, index);
},
formatSearchDuration(milliseconds) {
const ms = Math.max(0, Number(milliseconds) || 0);
if (ms < 10000) return `${(ms / 1000).toFixed(1)} s`;
if (ms < 60000) return `${Math.round(ms / 1000)} s`;
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${String(seconds).padStart(2, '0')}s`;
},
clearSearch() {
this.stopFederationSearch();
this.searchQuery = '';
this.searchResults = null;
this.searchLoading = false;
this.similaritySearchLabel = '';
this.similaritySearchError = '';
this.similaritySearchStats = { loading: false, tracks: 0, artists: 0, peers: 0, elapsed_ms: 0 };
if (this.view === 'search') {
this.view = this._previousView || 'artists';
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
@@ -3455,13 +3653,17 @@ document.addEventListener('alpine:init', () => {
};
source.addEventListener('federation.track', upsertTrack);
source.addEventListener('federation.artist', event => {
const artist = JSON.parse(event.data)?.entity;
const artist = this.withFederationArtistFallback(
JSON.parse(event.data)?.entity
);
const key = artist?.key?.normalized_name;
if (!key) return;
updateResults('artists', item => item.key.normalized_name, item => item.name, artist);
});
source.addEventListener('federation.release', event => {
const release = JSON.parse(event.data)?.entity;
const release = this.hydrateFederationSearchRelease(
JSON.parse(event.data)?.entity
);
if (!release?.key) return;
updateResults('releases', item => JSON.stringify(item.key || {}), item => item.title, release);
});
@@ -3538,9 +3740,98 @@ document.addEventListener('alpine:init', () => {
federationArtistImage(artist) {
if (!artist?.name) return '';
if (artist._federationArtworkFailed) return artist.local_image_url || '';
return this.federationDiscoveredArtwork(artist.name);
},
localArtistImage(name) {
const key = this.normalizeFederationSearchText(name);
return (this.searchResults?.artists || []).find(candidate =>
this.normalizeFederationSearchText(candidate.name) === key
)?.image_url || '';
},
withFederationArtistFallback(artist) {
if (!artist) return artist;
return { ...artist, local_image_url: this.localArtistImage(artist.name) };
},
applyFederationArtworkFallbacks() {
this.federationSearch = {
...this.federationSearch,
artists: this.federationSearch.artists.map(artist =>
this.withFederationArtistFallback(artist)
),
};
},
federationArtistImageFailed(artist) {
const key = artist?.key?.normalized_name;
if (!key) return;
this.federationSearch = {
...this.federationSearch,
artists: this.federationSearch.artists.map(candidate =>
candidate?.key?.normalized_name === key
? {
...candidate,
_federationArtworkFailed: true,
local_image_url: candidate.local_image_url
|| this.localArtistImage(candidate.name),
}
: candidate
),
};
},
hydrateFederationSearchRelease(release) {
if (!release) return release;
const title = this.normalizeFederationSearchText(release.title);
const primaryArtists = (release.key?.primary_artists || [])
.map(name => this.normalizeFederationSearchText(name));
const tracks = this.federationSearch.tracks.filter(track => {
const metadata = track?.metadata || {};
if (this.normalizeFederationSearchText(metadata.release?.title) !== title) return false;
if (release.year && metadata.year && Number(release.year) !== Number(metadata.year)) return false;
if (!primaryArtists.length) return true;
const trackArtists = (metadata.artists || []).map(artist =>
this.normalizeFederationSearchText(artist.name)
);
return primaryArtists.some(artist => trackArtists.includes(artist));
});
const owners = [...new Set([
...(release.sources || []).map(source => source.owner),
...tracks.flatMap(track =>
(track.availability?.federation || []).map(source => source.owner)
),
].filter(Boolean))];
return { ...release, tracks, owners };
},
federationReleaseCover(release) {
if (!release) return '';
if (release._federationArtworkFailed) return release._discoveredCoverUrl || '';
return release.cover_url
|| this.federationDiscoveredArtwork(release.artists?.[0], release.title);
},
federationReleaseCoverFailed(release, failedUrl) {
const discovered = this.federationDiscoveredArtwork(release?.artists?.[0], release?.title);
if (!release?.key) return;
const key = JSON.stringify(release.key);
this.federationSearch = {
...this.federationSearch,
releases: this.federationSearch.releases.map(candidate =>
JSON.stringify(candidate?.key) === key
? {
...candidate,
_federationArtworkFailed: true,
_discoveredCoverUrl: failedUrl === discovered ? '' : discovered,
}
: candidate
),
};
},
federationDiscoveredArtwork(artist, release = '') {
if (!artist) return '';
const params = new URLSearchParams({ artist });
@@ -3633,22 +3924,26 @@ document.addEventListener('alpine:init', () => {
uploader_name: 'Federation',
federation_pending: true,
_federationTrack: track,
similarity_score: track.similarity_score,
};
},
openFederatedRelease(release, options = {}) {
if (!release?.key) return;
this._federatedReleaseCache[release.key] = release;
this._beginNavigation('#releasefed?key=' + encodeURIComponent(release.key), options);
const cacheKey = typeof release.key === 'string'
? release.key
: JSON.stringify(release.key);
this._federatedReleaseCache[cacheKey] = release;
this._beginNavigation('#releasefed?key=' + encodeURIComponent(cacheKey), options);
const queuedTracks = (release.tracks || []).map(track => this.federationQueueTrack(track));
const first = queuedTracks[0];
this.currentRelease = {
id: null,
title: release.title,
release_type: release.release_type || 'release',
release_type: release.release_type || release.key?.release_type || 'release',
year: release.year,
cover_url: release.cover_url,
artists: first?.artists || [],
cover_url: this.federationReleaseCover(release),
artists: first?.artists || (release.artists || []).map(name => ({ id: null, name })),
tracks: queuedTracks,
uploaders: (release.owners || []).map(owner => ({
name: `Federation ${owner.slice(0, 10)}`,
@@ -4019,6 +4314,9 @@ document.addEventListener('alpine:init', () => {
playable,
});
this.refreshMaterializedArtwork(track);
Alpine.store('likes')?.reload();
const history = Alpine.store('history');
if (history?.modal) history.load(history.page);
}
const finalProgress = this.federationPreparing[contentId] || {};
const queueIndex = Number.isInteger(finalProgress.queueIndex)
@@ -4129,10 +4427,16 @@ document.addEventListener('alpine:init', () => {
_set: new Set(),
init() {
fetch('/api/player/likes')
.then(r => r.json())
.then(d => { this._set = new Set(d.track_ids || []); })
.catch(() => {});
this.reload();
},
async reload() {
try {
const response = await fetch('/api/player/likes');
if (!response.ok) return;
const data = await response.json();
this._set = new Set(data.track_ids || []);
} catch {}
},
has(trackId) {
+119 -51
View File
@@ -17,6 +17,17 @@
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
</div>
<div class="user-widget-actions">
<button class="user-logout-btn"
x-show="$store.user.profile?.role === 'admin'"
x-cloak
@click="window.location.href = '/admin/'"
title="{{ t.player_admin_panel }}"
aria-label="{{ t.player_admin_panel }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 3l7 3v5c0 4.6-2.9 8.1-7 10-4.1-1.9-7-5.4-7-10V6l7-3z"/>
<path d="M9.5 12l1.7 1.7 3.6-4"/>
</svg>
</button>
<button class="user-logout-btn" @click="$store.user.openSettings()" title="User settings" aria-label="User settings">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<circle cx="12" cy="12" r="3"/>
@@ -80,7 +91,7 @@
@click="$store.library.openArtist(artist.id)">
<div class="following-avatar">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
@@ -143,9 +154,6 @@
</div>
</template>
</div>
<div class="sidebar-bottom">
<a href="/admin/">{{ t.player_admin_panel }}</a>
</div>
</div>
<template x-if="$store.mobile.libraryOpen">
@@ -196,7 +204,7 @@
@click="$store.library.openArtist(artist.id); $store.mobile.closeLibrary()">
<div class="following-avatar">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
@@ -359,6 +367,17 @@
</div>
</div>
<div class="mobile-account-actions">
<button class="user-logout-btn"
x-show="$store.user.profile?.role === 'admin'"
x-cloak
@click="window.location.href = '/admin/'"
title="{{ t.player_admin_panel }}"
aria-label="{{ t.player_admin_panel }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M12 3l7 3v5c0 4.6-2.9 8.1-7 10-4.1-1.9-7-5.4-7-10V6l7-3z"/>
<path d="M9.5 12l1.7 1.7 3.6-4"/>
</svg>
</button>
<button class="user-logout-btn"
@click="$store.user.menuOpen = false; $store.user.openSettings()"
title="User settings"
@@ -379,12 +398,35 @@
<!-- Search Results -->
<template x-if="$store.library.view === 'search'">
<div>
<div class="search-similarity-heading"
x-show="$store.library.similaritySearchLabel"
x-cloak>
<h2 class="search-similarity-title">
<span>{{ t.player_search_similar_to }}</span>
<strong x-text="$store.library.similaritySearchLabel"></strong>
</h2>
<div class="search-similarity-progress"
:class="{ loading: $store.library.similaritySearchStats.loading }">
<template x-if="$store.library.similaritySearchStats.loading">
<span class="search-progress-live"><i></i> Searching peers…</span>
</template>
<span x-text="`${$store.library.similaritySearchStats.tracks} tracks · ${$store.library.similaritySearchStats.artists} artists`"></span>
<template x-if="!$store.library.similaritySearchStats.loading">
<span x-text="`${$store.library.similaritySearchStats.peers} peers · ${$store.library.formatSearchDuration($store.library.similaritySearchStats.elapsed_ms)}`"></span>
</template>
</div>
</div>
<template x-if="$store.library.searchLoading">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
<template x-if="!$store.library.searchLoading && $store.library.similaritySearchError">
<div class="empty-state">
<p x-text="$store.library.similaritySearchError"></p>
</div>
</template>
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
<div>
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
<div :class="{ 'similarity-unified-results': $store.library.similaritySearchLabel }">
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
<p>{{ t.player_no_results }}</p>
@@ -398,11 +440,9 @@
<template x-for="artist in $store.library.searchResults.artists" :key="artist.id">
<div class="search-artist-card" @click="$store.library.openArtist(artist.id)">
<div class="search-artist-img">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
</template>
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
<img class="artwork-image" :src="artist.image_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
</template>
</div>
<div class="search-artist-name" x-text="artist.name"></div>
@@ -419,11 +459,9 @@
<template x-for="release in $store.library.searchResults.releases" :key="release.id">
<div class="search-release-card" @click="$store.library.openRelease(release.id)" style="position:relative">
<div class="search-release-cover" style="position:relative">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title" loading="lazy">
</template>
<template x-if="!release.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
<img class="artwork-image" :src="release.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
</template>
<button class="card-info-btn" @click.stop="$store.library.openReleaseInfo(release)" :title="$store.library.releaseInfo(release)" aria-label="{{ t.player_release_info }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
@@ -453,7 +491,8 @@
<template x-for="(track, idx) in $store.library.searchResults.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.library.playSearchTrack(idx)">
:style="$store.library.similaritySearchLabel ? `order:${$store.library.similarityTrackOrder(track)}` : ''"
@dblclick="$store.library.similaritySearchLabel ? $store.library.playSimilarityResult(track) : $store.library.playSearchTrack(idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<div class="track-title" x-text="track.title"></div>
@@ -498,8 +537,9 @@
</template>
</div>
</template>
<div class="search-section federation-search-section">
<h2 class="search-section-title">
<div class="search-section federation-search-section"
:class="{ 'similarity-federation-merged': $store.library.similaritySearchLabel }">
<h2 class="search-section-title" x-show="!$store.library.similaritySearchLabel">
Federation
<span class="federation-live-badge"
x-show="$store.library.federationSearch.loading"
@@ -509,11 +549,11 @@
<div class="federation-search-status error"
x-text="$store.library.federationSearch.error"></div>
</template>
<template x-if="$store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
<template x-if="!$store.library.similaritySearchLabel && $store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
<div class="federation-search-status">Searching peers…</div>
</template>
<div class="search-artists-row"
x-show="$store.library.federationSearch.artists.length > 0"
x-show="!$store.library.similaritySearchLabel && $store.library.federationSearch.artists.length > 0"
x-cloak>
<template x-for="artist in $store.library.federationSearch.artists"
:key="artist.key.normalized_name">
@@ -521,10 +561,12 @@
@click="$store.library.openFederatedArtist(artist)">
<div class="search-artist-img">
<img x-show="$store.library.federationArtistImage(artist)"
class="artwork-image"
:src="$store.library.federationArtistImage(artist)"
:alt="artist.name"
alt="" aria-hidden="true"
loading="lazy"
@error="$event.currentTarget.style.display = 'none'">
@load="$event.currentTarget.classList.add('artwork-loaded')"
@error="$event.currentTarget.classList.remove('artwork-loaded'); $store.library.federationArtistImageFailed(artist)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/>
</svg>
@@ -536,18 +578,21 @@
</template>
</div>
<div class="search-releases-row"
x-show="$store.library.federationSearch.releases.length > 0"
x-show="!$store.library.similaritySearchLabel && $store.library.federationSearch.releases.length > 0"
x-cloak>
<template x-for="release in $store.library.federationSearch.releases"
:key="JSON.stringify(release.key)">
<div class="search-release-card federation-entity-card">
<div class="search-release-card federation-entity-card"
@click="$store.library.openFederatedRelease($store.library.hydrateFederationSearchRelease(release))">
<div class="search-release-cover">
<img x-show="release.cover_url"
:src="release.cover_url"
:alt="release.title"
loading="lazy">
<svg x-show="!release.cover_url"
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<img x-show="$store.library.federationReleaseCover(release)"
class="artwork-image"
:src="$store.library.federationReleaseCover(release)"
alt="" aria-hidden="true"
loading="lazy"
@load="$event.currentTarget.classList.add('artwork-loaded')"
@error="$event.currentTarget.classList.remove('artwork-loaded'); $store.library.federationReleaseCoverFailed(release, $event.currentTarget.getAttribute('src'))">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
</svg>
</div>
@@ -560,7 +605,8 @@
<template x-for="(track, idx) in $store.library.federationSearch.tracks"
:key="track.key.content_id">
<div class="track-row federation-track-row"
@dblclick="$store.library.playFederatedTrack(track)">
:style="$store.library.similaritySearchLabel ? `order:${$store.library.similarityTrackOrder(track)}` : ''"
@dblclick="$store.library.similaritySearchLabel ? $store.library.playSimilarityResult($store.library.federationQueueTrack(track)) : $store.library.playFederatedTrack(track)">
<span class="track-num federation-track-status">
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
@@ -634,6 +680,12 @@
x-text="formatTime(track.metadata.duration_seconds)"></span>
</div>
</template>
<div class="similarity-search-inline-progress"
x-show="$store.library.similaritySearchLabel && $store.library.similaritySearchStats.loading"
x-cloak>
<span class="similarity-search-inline-spinner" aria-hidden="true"></span>
<span>Searching federation for more similar tracks…</span>
</div>
</div>
</div>
</template>
@@ -655,7 +707,7 @@
<div class="card" @click="$store.library.openArtist(artist.id)">
<div class="card-img">
<template x-if="artist.image_url">
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
<img :src="artist.image_url" alt="" aria-hidden="true" loading="lazy">
</template>
<template x-if="!artist.image_url">
<span class="placeholder-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg></span>
@@ -711,7 +763,7 @@
<div class="artist-img">
<template x-if="$store.library.currentArtist.image_url">
<img :src="$store.library.currentArtist.image_url"
:alt="$store.library.currentArtist.name"
alt="" aria-hidden="true"
@error="$store.library.currentArtist.image_url = null">
</template>
<template x-if="!$store.library.currentArtist.image_url">
@@ -757,8 +809,22 @@
</div>
</div>
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
<section class="artist-release-group">
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
<section class="artist-release-group" x-data="{ expanded: false }">
<div class="artist-release-group-heading">
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
<button class="artist-top-tracks-toggle"
type="button"
x-show="$store.library.currentArtist.top_tracks.length > 5"
@click="expanded = !expanded"
:aria-expanded="expanded"
x-cloak>
<span x-text="expanded ? '{{ t.player_collapse }}' : '{{ t.player_expand_all }}'"></span>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
:class="{ expanded: expanded }">
<polyline points="6 9 12 15 18 9"/>
</svg>
</button>
</div>
<div class="track-list-header">
<span>#</span>
<span>{{ t.player_title }}</span>
@@ -766,7 +832,9 @@
<span></span>
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
<template x-for="(track, idx) in (expanded
? $store.library.currentArtist.top_tracks
: $store.library.currentArtist.top_tracks.slice(0, 5))" :key="track.id">
<div class="track-row artist-appearance-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
@@ -778,7 +846,7 @@
:title="track.release_title"
aria-label="{{ t.player_release }}">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
<img :src="track.cover_url" alt="" aria-hidden="true" loading="lazy">
</template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
@@ -841,7 +909,7 @@
<div class="card" @click="$store.library.openRelease(release.id)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title"
<img :src="release.cover_url" alt="" aria-hidden="true"
loading="lazy" @error="release.cover_url = null">
</template>
<template x-if="!release.cover_url">
@@ -877,7 +945,7 @@
@click="$store.library.openFederatedRelease(release)">
<div class="card-img">
<template x-if="release.cover_url">
<img :src="release.cover_url" :alt="release.title"
<img :src="release.cover_url" alt="" aria-hidden="true"
loading="lazy" @error="release.cover_url = null">
</template>
<template x-if="!release.cover_url">
@@ -1007,7 +1075,7 @@
:title="track.release_title"
aria-label="{{ t.player_release }}">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
<img :src="track.cover_url" alt="" aria-hidden="true" loading="lazy">
</template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
@@ -1081,7 +1149,7 @@
<div class="release-header">
<div class="release-cover">
<template x-if="$store.library.currentRelease.cover_url">
<img :src="$store.library.currentRelease.cover_url" :alt="$store.library.currentRelease.title">
<img :src="$store.library.currentRelease.cover_url" alt="" aria-hidden="true">
</template>
<template x-if="!$store.library.currentRelease.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
@@ -1459,11 +1527,9 @@
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
</div>
<div class="queue-track-cover">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<template x-if="item.track.cover_url">
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template>
<template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<img class="artwork-image" :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
</template>
<span class="queue-federation-status federation-track-status"
x-show="item.track.federation_pending"
@@ -1543,7 +1609,7 @@
<div class="player-cover"
@click.stop="$store.mobile.openPlayerFullscreen()">
<template x-if="$store.player.currentTrack.cover_url">
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
<img :src="$store.player.currentTrack.cover_url" alt="" aria-hidden="true">
</template>
<template x-if="!$store.player.currentTrack.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
@@ -1626,7 +1692,11 @@
x-text="'-' + formatTime(Math.max(0, $store.player.duration - $store.player.currentTime)) + ' / ' + formatTime($store.player.duration)"></div>
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
</div>
<div class="player-version-chip">v{{ t.app_version() }}</div>
<a class="player-version-chip"
href="https://github.com/house-of-vanity/furumusic"
target="_blank"
rel="noopener noreferrer"
aria-label="Furumusic on GitHub">v{{ t.app_version() }}</a>
</div>
<div class="player-right">
@@ -1837,11 +1907,9 @@
type="button"
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
<div class="mobile-expanded-queue-cover">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<template x-if="item.track.cover_url">
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template>
<template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<img class="artwork-image" :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy" @load="$event.currentTarget.classList.add('artwork-loaded')" @error="$event.currentTarget.classList.remove('artwork-loaded')">
</template>
</div>
<div class="mobile-expanded-queue-info">
+149 -19
View File
@@ -450,22 +450,6 @@ button.user-stat:hover {
letter-spacing: 0.3px;
}
.sidebar-bottom {
padding: 12px 16px;
border-top: 1px solid var(--border-color);
}
.sidebar-bottom a {
color: var(--text-subdued);
text-decoration: none;
font-size: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.sidebar-bottom a:hover { color: var(--text-secondary); }
/* Center Content */
.center-content {
flex: 1;
@@ -676,6 +660,42 @@ button.user-stat:hover {
margin-bottom: 14px;
text-transform: capitalize;
}
.artist-release-group-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.artist-release-group-heading .artist-release-group-title {
margin-bottom: 0;
}
.artist-top-tracks-toggle {
border: 0;
background: transparent;
color: var(--text-subdued);
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 7px;
border-radius: 5px;
font: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.artist-top-tracks-toggle:hover {
color: var(--text-primary);
background: var(--bg-hover);
}
.artist-top-tracks-toggle svg {
width: 15px;
height: 15px;
transition: transform 0.18s ease;
}
.artist-top-tracks-toggle svg.expanded {
transform: rotate(180deg);
}
/* Release detail header */
.release-header {
@@ -1375,7 +1395,7 @@ button.user-stat:hover {
justify-content: center;
}
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.queue-track-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
.queue-track-cover .queue-federation-status {
@@ -2437,7 +2457,16 @@ button.user-stat:hover {
line-height: 1;
font-weight: 500;
text-align: center;
pointer-events: none;
text-decoration: none;
pointer-events: auto;
transition: opacity 120ms ease;
}
.player-version-chip:hover,
.player-version-chip:focus-visible {
color: var(--text-subdued);
opacity: 0.9;
text-decoration: none;
}
.mobile-account-chip {
@@ -2706,6 +2735,41 @@ button.user-stat:hover {
border-top: 1px solid var(--border);
padding-top: 18px;
}
.federation-search-section.similarity-federation-merged {
margin-top: -24px;
padding-top: 0;
border-top: 0;
}
.similarity-unified-results {
display: flex;
flex-direction: column;
}
.similarity-unified-results > .search-section {
display: contents;
}
.similarity-unified-results .search-section-title { order: -1000002; }
.similarity-unified-results .track-list-header { order: -1000001; }
.similarity-unified-results .federation-search-status { order: 1000001; }
.similarity-search-inline-progress {
order: 1000000;
display: flex;
align-items: center;
justify-content: center;
gap: 9px;
min-height: 42px;
margin-top: 4px;
border-top: 1px solid var(--border);
color: var(--text-muted);
font-size: 12px;
}
.similarity-search-inline-spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, .16);
border-top-color: var(--accent);
border-radius: 999px;
animation: federation-progress-spin .8s linear infinite;
}
.federation-live-badge {
margin-left: 8px;
color: var(--accent);
@@ -2827,6 +2891,65 @@ button.user-stat:hover {
margin-bottom: 12px;
}
.search-similarity-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin: 0 0 20px;
}
.search-similarity-title {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 8px;
min-width: 0;
margin: 0;
color: var(--text-muted);
font-size: 16px;
font-weight: 500;
}
.search-similarity-title strong {
color: var(--text);
font-size: 20px;
}
.search-similarity-progress {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 10px;
min-height: 30px;
padding: 5px 10px;
border: 1px solid var(--border);
border-radius: 7px;
background: var(--bg-secondary);
color: var(--text-muted);
font-size: 11px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.search-similarity-progress.loading { border-color: rgba(29, 185, 84, .38); }
.search-progress-live {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--accent);
}
.search-progress-live i {
width: 7px;
height: 7px;
border-radius: 999px;
background: currentColor;
animation: similarity-search-pulse 1.1s ease-in-out infinite;
}
@keyframes similarity-search-pulse {
50% { opacity: .3; transform: scale(.75); }
}
@media (max-width: 720px) {
.search-similarity-heading { align-items: flex-start; flex-direction: column; }
.search-similarity-progress { max-width: 100%; flex-wrap: wrap; white-space: normal; }
}
.search-artists-row {
display: flex;
gap: 16px;
@@ -2899,6 +3022,7 @@ button.user-stat:hover {
.search-release-card:hover { background: var(--bg-elevated); }
.search-release-cover {
position: relative;
width: 100%;
aspect-ratio: 1;
border-radius: 6px;
@@ -2910,9 +3034,12 @@ button.user-stat:hover {
justify-content: center;
}
.search-release-cover img { width: 100%; height: 100%; object-fit: cover; }
.search-release-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.search-release-cover svg { width: 40px; height: 40px; color: var(--text-subdued); }
.artwork-image { z-index: 1; opacity: 0; }
.artwork-image.artwork-loaded { opacity: 1; }
/* Like button */
.like-btn {
background: none;
@@ -5525,6 +5652,7 @@ button.user-stat:hover {
}
.mobile-expanded-queue-cover {
position: relative;
width: 42px;
height: 42px;
border-radius: 5px;
@@ -5536,6 +5664,8 @@ button.user-stat:hover {
}
.mobile-expanded-queue-cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;