Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f337ee626 | ||
|
|
77cbb62d84 | ||
|
|
05d68724f3 | ||
|
|
d71845509d | ||
|
|
4efcfdc539 | ||
|
|
d61d7a6bac | ||
|
|
cad8b2280f | ||
|
|
3a75c7d848 |
@@ -3,3 +3,5 @@
|
|||||||
/.claude
|
/.claude
|
||||||
/media
|
/media
|
||||||
/federation
|
/federation
|
||||||
|
/similarity-models
|
||||||
|
/federation-cache
|
||||||
|
|||||||
Generated
+618
-7
@@ -159,6 +159,12 @@ version = "1.0.104"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anymap3"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "arc-swap"
|
name = "arc-swap"
|
||||||
version = "1.9.2"
|
version = "1.9.2"
|
||||||
@@ -488,6 +494,24 @@ dependencies = [
|
|||||||
"virtue",
|
"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]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "1.3.2"
|
version = "1.3.2"
|
||||||
@@ -1069,6 +1093,16 @@ dependencies = [
|
|||||||
"crossbeam-utils",
|
"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]]
|
[[package]]
|
||||||
name = "crossbeam-epoch"
|
name = "crossbeam-epoch"
|
||||||
version = "0.9.20"
|
version = "0.9.20"
|
||||||
@@ -1093,6 +1127,12 @@ version = "0.8.22"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crunchy"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-bigint"
|
name = "crypto-bigint"
|
||||||
version = "0.5.5"
|
version = "0.5.5"
|
||||||
@@ -1284,6 +1324,12 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dasp_sample"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "data-encoding"
|
name = "data-encoding"
|
||||||
version = "2.11.0"
|
version = "2.11.0"
|
||||||
@@ -1341,6 +1387,17 @@ dependencies = [
|
|||||||
"serde_core",
|
"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]]
|
[[package]]
|
||||||
name = "derive_builder"
|
name = "derive_builder"
|
||||||
version = "0.20.2"
|
version = "0.20.2"
|
||||||
@@ -1493,12 +1550,30 @@ version = "0.15.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "downcast-rs"
|
||||||
|
version = "2.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "dyn-clone"
|
name = "dyn-clone"
|
||||||
version = "1.0.20"
|
version = "1.0.20"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
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]]
|
[[package]]
|
||||||
name = "ecdsa"
|
name = "ecdsa"
|
||||||
version = "0.16.9"
|
version = "0.16.9"
|
||||||
@@ -1641,6 +1716,17 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
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]]
|
[[package]]
|
||||||
name = "errno"
|
name = "errno"
|
||||||
version = "0.3.14"
|
version = "0.3.14"
|
||||||
@@ -1707,8 +1793,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
version = "0.3.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c3e690b370c505d153bef214b21a8f2aa55d667367ac1e16bde8bc0de88963c2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1747,6 +1834,16 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
|
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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
@@ -1763,6 +1860,12 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "float-ord"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flume"
|
name = "flume"
|
||||||
version = "0.11.1"
|
version = "0.11.1"
|
||||||
@@ -1835,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.9.4"
|
version = "0.10.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-stream",
|
"async-stream",
|
||||||
@@ -1847,6 +1950,7 @@ dependencies = [
|
|||||||
"cot",
|
"cot",
|
||||||
"croner",
|
"croner",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
|
"futures-util",
|
||||||
"id3",
|
"id3",
|
||||||
"image",
|
"image",
|
||||||
"librqbit",
|
"librqbit",
|
||||||
@@ -1855,6 +1959,8 @@ dependencies = [
|
|||||||
"openidconnect",
|
"openidconnect",
|
||||||
"postcard",
|
"postcard",
|
||||||
"reqwest 0.12.28",
|
"reqwest 0.12.28",
|
||||||
|
"rodio",
|
||||||
|
"rustfft",
|
||||||
"schemars 0.9.0",
|
"schemars 0.9.0",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -1866,6 +1972,7 @@ dependencies = [
|
|||||||
"tower",
|
"tower",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"tract-onnx",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2190,6 +2297,18 @@ dependencies = [
|
|||||||
"tracing",
|
"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]]
|
[[package]]
|
||||||
name = "hash32"
|
name = "hash32"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
@@ -2235,6 +2354,8 @@ dependencies = [
|
|||||||
"allocator-api2",
|
"allocator-api2",
|
||||||
"equivalent",
|
"equivalent",
|
||||||
"foldhash 0.2.0",
|
"foldhash 0.2.0",
|
||||||
|
"serde",
|
||||||
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2798,6 +2919,15 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "inventory"
|
||||||
|
version = "0.3.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
|
||||||
|
dependencies = [
|
||||||
|
"rustversion",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ipconfig"
|
name = "ipconfig"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
@@ -3490,6 +3620,12 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "maplit"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3505,6 +3641,16 @@ version = "0.8.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
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]]
|
[[package]]
|
||||||
name = "md-5"
|
name = "md-5"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@@ -3530,6 +3676,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memo-map"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -3546,6 +3698,16 @@ dependencies = [
|
|||||||
"unicase",
|
"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]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.8.9"
|
version = "0.8.9"
|
||||||
@@ -3613,8 +3775,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.2.0"
|
version = "0.4.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#8de7d1292708fa0b225e5a4a9d5ab4f0676202d3"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0c5b429b90a8f1b0980b3a35a6fa5445d7a275c737eb04db18db4d7f14c81478"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -3702,6 +3865,21 @@ dependencies = [
|
|||||||
"tempfile",
|
"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]]
|
[[package]]
|
||||||
name = "ndk-context"
|
name = "ndk-context"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3833,6 +4011,24 @@ dependencies = [
|
|||||||
"winapi",
|
"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]]
|
[[package]]
|
||||||
name = "nonzero_ext"
|
name = "nonzero_ext"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -3916,10 +4112,20 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36"
|
checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-complex",
|
"num-complex 0.2.4",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-iter",
|
"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",
|
"num-traits",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3949,6 +4155,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3996,6 +4211,17 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -4350,6 +4576,12 @@ version = "1.0.15"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pastey"
|
||||||
|
version = "0.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pem-rfc7468"
|
name = "pem-rfc7468"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -4560,6 +4792,15 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "portmapper"
|
name = "portmapper"
|
||||||
version = "0.19.1"
|
version = "0.19.1"
|
||||||
@@ -4649,6 +4890,15 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "primeorder"
|
name = "primeorder"
|
||||||
version = "0.13.6"
|
version = "0.13.6"
|
||||||
@@ -4676,6 +4926,29 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"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]]
|
[[package]]
|
||||||
name = "pxfm"
|
name = "pxfm"
|
||||||
version = "0.1.30"
|
version = "0.1.30"
|
||||||
@@ -4881,6 +5154,16 @@ version = "0.10.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
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]]
|
[[package]]
|
||||||
name = "rand_pcg"
|
name = "rand_pcg"
|
||||||
version = "0.10.2"
|
version = "0.10.2"
|
||||||
@@ -4899,6 +5182,32 @@ dependencies = [
|
|||||||
"bitflags 2.13.1",
|
"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]]
|
[[package]]
|
||||||
name = "redox_syscall"
|
name = "redox_syscall"
|
||||||
version = "0.5.18"
|
version = "0.5.18"
|
||||||
@@ -5097,6 +5406,18 @@ dependencies = [
|
|||||||
"libc",
|
"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]]
|
[[package]]
|
||||||
name = "rsa"
|
name = "rsa"
|
||||||
version = "0.9.10"
|
version = "0.9.10"
|
||||||
@@ -5152,6 +5473,20 @@ dependencies = [
|
|||||||
"semver",
|
"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]]
|
[[package]]
|
||||||
name = "rustix"
|
name = "rustix"
|
||||||
version = "1.1.4"
|
version = "1.1.4"
|
||||||
@@ -5252,6 +5587,19 @@ version = "1.0.23"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
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]]
|
[[package]]
|
||||||
name = "same-file"
|
name = "same-file"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
@@ -5261,6 +5609,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "schannel"
|
name = "schannel"
|
||||||
version = "0.1.29"
|
version = "0.1.29"
|
||||||
@@ -5994,6 +6351,22 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
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]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -6327,6 +6700,17 @@ version = "1.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
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]]
|
[[package]]
|
||||||
name = "tempfile"
|
name = "tempfile"
|
||||||
version = "3.27.0"
|
version = "3.27.0"
|
||||||
@@ -6786,12 +7170,229 @@ dependencies = [
|
|||||||
"tracing-log",
|
"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]]
|
[[package]]
|
||||||
name = "try-lock"
|
name = "try-lock"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typeid"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.20.1"
|
version = "1.20.1"
|
||||||
@@ -7557,6 +8158,16 @@ dependencies = [
|
|||||||
"tap",
|
"tap",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "xattr"
|
||||||
|
version = "1.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"rustix",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xml-rs"
|
name = "xml-rs"
|
||||||
version = "0.8.28"
|
version = "0.8.28"
|
||||||
|
|||||||
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.9.5"
|
version = "0.10.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
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"] }
|
schemars = { version = "0.9", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
openidconnect = "4.0"
|
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"] }
|
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||||
async-stream = "0.3"
|
async-stream = "0.3"
|
||||||
bytes = "1"
|
bytes = "1"
|
||||||
@@ -31,6 +31,10 @@ md-5 = "0.10"
|
|||||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
||||||
anyhow = "1.0"
|
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"
|
tokio-cron-scheduler = "0.15"
|
||||||
croner = "3"
|
croner = "3"
|
||||||
async-trait = "0.1"
|
async-trait = "0.1"
|
||||||
@@ -39,4 +43,4 @@ uuid = "1"
|
|||||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||||
# P2P federation: publishes the library into a shared DHT and serves audio /
|
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||||
# catalogs to furumi peers (TUI clients) over the frid stack.
|
# 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"
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# furumusic
|
# furumusic
|
||||||
|
|
||||||
|
Furumusic can join the decentralized Furumi federation while remaining a
|
||||||
|
complete local web player. Optional similarity search stores versioned audio
|
||||||
|
embeddings in PostgreSQL and uses signed two-level LSH summaries in a separate
|
||||||
|
DHT to discover compatible peers without a central recommendation index. The
|
||||||
|
shared `music-dht` layer owns routing and wire compatibility; model inference
|
||||||
|
and exact cosine ranking stay local to each instance.
|
||||||
|
|
||||||
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
|
Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL.
|
||||||
|
|
||||||
Built with Rust ([cot](https://cot.rs) framework).
|
Built with Rust ([cot](https://cot.rs) framework).
|
||||||
|
|||||||
Generated
+27
@@ -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
|
||||||
|
}
|
||||||
@@ -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}";
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -422,6 +422,20 @@ impl App for AdminApp {
|
|||||||
}),
|
}),
|
||||||
"admin_v2_federation_status",
|
"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(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/federation/sync",
|
"/v2/api/federation/sync",
|
||||||
cot::router::method::post(move |session: Session, db: Database| async move {
|
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||||
|
|||||||
+119
@@ -454,6 +454,14 @@ struct AdminSettingsValues {
|
|||||||
federation_network_id: String,
|
federation_network_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
federation_save_on_listen: bool,
|
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)]
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
@@ -481,6 +489,10 @@ struct AdminSettingsSources {
|
|||||||
federation_enabled: &'static str,
|
federation_enabled: &'static str,
|
||||||
federation_network_id: &'static str,
|
federation_network_id: &'static str,
|
||||||
federation_save_on_listen: &'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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -511,6 +523,26 @@ pub(super) struct UpdateSettingsRequest {
|
|||||||
federation_network_id: String,
|
federation_network_id: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
federation_save_on_listen: bool,
|
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)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -948,6 +980,29 @@ pub async fn update_settings(
|
|||||||
if let Err(response) = require_admin_json(&session, &db).await {
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
return Ok(response);
|
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 = [
|
let fields = [
|
||||||
(
|
(
|
||||||
"auth_password_enabled",
|
"auth_password_enabled",
|
||||||
@@ -1002,6 +1057,10 @@ pub async fn update_settings(
|
|||||||
"federation_save_on_listen",
|
"federation_save_on_listen",
|
||||||
body.federation_save_on_listen.to_string(),
|
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 {
|
for (key, value) in fields {
|
||||||
let mut entry = ConfigEntry::new(key.to_string(), value);
|
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.
|
// the freshly saved settings — no server restart involved.
|
||||||
let (fresh, _) = AppConfig::load_with_db(&db).await;
|
let (fresh, _) = AppConfig::load_with_db(&db).await;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
|
crate::similarity::handle().apply(&fresh);
|
||||||
crate::federation::handle().apply(&fresh).await;
|
crate::federation::handle().apply(&fresh).await;
|
||||||
});
|
});
|
||||||
Json(serde_json::json!({ "ok": true })).into_response()
|
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()
|
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(
|
pub async fn federation_sync(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -1153,6 +1264,10 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
|||||||
federation_enabled: config.federation_enabled,
|
federation_enabled: config.federation_enabled,
|
||||||
federation_network_id: config.federation_network_id,
|
federation_network_id: config.federation_network_id,
|
||||||
federation_save_on_listen: config.federation_save_on_listen,
|
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 {
|
sources: AdminSettingsSources {
|
||||||
auth_password_enabled: sources.auth_password_enabled.code(),
|
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_enabled: sources.federation_enabled.code(),
|
||||||
federation_network_id: sources.federation_network_id.code(),
|
federation_network_id: sources.federation_network_id.code(),
|
||||||
federation_save_on_listen: sources.federation_save_on_listen.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(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,10 @@ pub struct ConfigSources {
|
|||||||
pub federation_enabled: ConfigSource,
|
pub federation_enabled: ConfigSource,
|
||||||
pub federation_network_id: ConfigSource,
|
pub federation_network_id: ConfigSource,
|
||||||
pub federation_save_on_listen: 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 {
|
impl Default for ConfigSources {
|
||||||
@@ -168,6 +172,10 @@ impl Default for ConfigSources {
|
|||||||
federation_enabled: ConfigSource::Default,
|
federation_enabled: ConfigSource::Default,
|
||||||
federation_network_id: ConfigSource::Default,
|
federation_network_id: ConfigSource::Default,
|
||||||
federation_save_on_listen: 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
|
/// Whether a federated track requested for playback is imported into the
|
||||||
/// shared local library. This is a server-wide administrator policy.
|
/// shared local library. This is a server-wide administrator policy.
|
||||||
pub federation_save_on_listen: bool,
|
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 {
|
impl Default for AppConfig {
|
||||||
@@ -315,6 +331,12 @@ impl Default for AppConfig {
|
|||||||
federation_enabled: false,
|
federation_enabled: false,
|
||||||
federation_network_id: String::new(),
|
federation_network_id: String::new(),
|
||||||
federation_save_on_listen: false,
|
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_enabled,
|
||||||
federation_network_id,
|
federation_network_id,
|
||||||
federation_save_on_listen,
|
federation_save_on_listen,
|
||||||
|
similarity_enabled,
|
||||||
|
similarity_model,
|
||||||
|
similarity_profile,
|
||||||
|
similarity_workers,
|
||||||
);
|
);
|
||||||
|
|
||||||
impl AppConfig {
|
impl AppConfig {
|
||||||
@@ -476,6 +502,10 @@ impl AppConfig {
|
|||||||
apply_db_field!(federation_enabled);
|
apply_db_field!(federation_enabled);
|
||||||
apply_db_field!(federation_network_id);
|
apply_db_field!(federation_network_id);
|
||||||
apply_db_field!(federation_save_on_listen);
|
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();
|
let cfg = AppConfig::default();
|
||||||
assert!(cfg.database_url.is_empty());
|
assert!(cfg.database_url.is_empty());
|
||||||
assert_eq!(cfg.log_level, "info");
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -98,6 +98,66 @@ pub struct SearchEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Federation {
|
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 }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
persist_track_ref(&pool, &dto).await?;
|
||||||
|
prepared.push(dto);
|
||||||
|
}
|
||||||
|
Ok(prepared)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stream_artist_catalogs(
|
pub fn stream_artist_catalogs(
|
||||||
self: &std::sync::Arc<Self>,
|
self: &std::sync::Arc<Self>,
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
|
|||||||
const DEVICE_SYNC_INTERVAL: Duration = Duration::from_secs(2);
|
const DEVICE_SYNC_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
const LOCAL_SEED_RECHECK_MS: i64 = 60 * 1000;
|
const LOCAL_SEED_RECHECK_MS: i64 = 60 * 1000;
|
||||||
const MAX_LINE: usize = 8 * 1024 * 1024;
|
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;
|
const MAX_OPS_PER_BATCH: i64 = 1000;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
@@ -84,6 +87,7 @@ struct Identity {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
struct StoredDevice {
|
struct StoredDevice {
|
||||||
device_id: String,
|
device_id: String,
|
||||||
|
endpoint_id: String,
|
||||||
endpoint_ticket: String,
|
endpoint_ticket: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1573,8 +1577,7 @@ async fn sync_device(
|
|||||||
user_id: i64,
|
user_id: i64,
|
||||||
device: &StoredDevice,
|
device: &StoredDevice,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
let peer = resolve_device_peer(&service, device).await?;
|
||||||
let peer = service.connect(ticket).await?;
|
|
||||||
let own_ticket = service.ticket().await?.to_string();
|
let own_ticket = service.ticket().await?.to_string();
|
||||||
let identity = ensure_identity(pool, user_id, "").await?;
|
let identity = ensure_identity(pool, user_id, "").await?;
|
||||||
let profile = own_profile(pool, user_id, "", &own_ticket).await?;
|
let profile = own_profile(pool, user_id, "", &own_ticket).await?;
|
||||||
@@ -3420,12 +3423,16 @@ async fn local_playback_snapshot(
|
|||||||
user_id: i64,
|
user_id: i64,
|
||||||
identity: &Identity,
|
identity: &Identity,
|
||||||
) -> Option<PlaybackSnapshot> {
|
) -> 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()?;
|
let wire = playback_state_from_browser_json(pool, state).await.ok()?;
|
||||||
Some(PlaybackSnapshot {
|
Some(PlaybackSnapshot {
|
||||||
device_id: identity.device_id.clone(),
|
device_id: identity.device_id.clone(),
|
||||||
device_name: identity.name.clone(),
|
device_name: identity.name.clone(),
|
||||||
active: true,
|
active,
|
||||||
updated_at_ms: now_ms(),
|
updated_at_ms: now_ms(),
|
||||||
state: wire,
|
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>> {
|
async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<StoredDevice>> {
|
||||||
let identity = ensure_identity(pool, user_id, "").await?;
|
let identity = ensure_identity(pool, user_id, "").await?;
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT device_id, endpoint_ticket
|
"SELECT device_id, endpoint_id, endpoint_ticket
|
||||||
FROM furumusic__fed_device
|
FROM furumusic__fed_device
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND trusted_at_ms IS NOT NULL
|
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()
|
.into_iter()
|
||||||
.map(|row| StoredDevice {
|
.map(|row| StoredDevice {
|
||||||
device_id: row.get("device_id"),
|
device_id: row.get("device_id"),
|
||||||
|
endpoint_id: row.get("endpoint_id"),
|
||||||
endpoint_ticket: row.get("endpoint_ticket"),
|
endpoint_ticket: row.get("endpoint_ticket"),
|
||||||
})
|
})
|
||||||
.collect())
|
.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> {
|
async fn active_device_count(pool: &sqlx::PgPool, user_id: i64) -> Result<i64> {
|
||||||
sqlx::query_scalar(
|
sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM furumusic__fed_device
|
"SELECT COUNT(*) FROM furumusic__fed_device
|
||||||
@@ -4202,11 +4229,19 @@ async fn ops_for_peer(
|
|||||||
AND a.origin_device_id = o.origin_device_id
|
AND a.origin_device_id = o.origin_device_id
|
||||||
WHERE o.user_id = $1
|
WHERE o.user_id = $1
|
||||||
AND o.seq > COALESCE(a.max_seq, 0)
|
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
|
ORDER BY o.hlc_ms, o.op_id
|
||||||
LIMIT $3",
|
LIMIT $4",
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
.bind(peer_device_id)
|
.bind(peer_device_id)
|
||||||
|
.bind(now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS))
|
||||||
.bind(MAX_OPS_PER_BATCH)
|
.bind(MAX_OPS_PER_BATCH)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
+156
-5
@@ -5,17 +5,21 @@
|
|||||||
//! releases, tracks — names and small metadata, never files) into the
|
//! releases, tracks — names and small metadata, never files) into the
|
||||||
//! shared DHT and serves audio, track metadata, cover art and per-artist
|
//! shared DHT and serves audio, track metadata, cover art and per-artist
|
||||||
//! catalogs to other peers (TUI clients) over the same wire protocols the
|
//! catalogs to other peers (TUI clients) over the same wire protocols the
|
||||||
//! clients speak among themselves. Serve-only: the server does not search
|
//! clients speak among themselves. The web player also searches known peers
|
||||||
//! or download from other 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`,
|
//! Settings are the regular admin config entries (`federation_enabled`,
|
||||||
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
|
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly —
|
||||||
//! starts, stops or re-joins the node without a server restart.
|
//! saving the settings starts, stops or re-joins the node without a server
|
||||||
|
//! restart.
|
||||||
|
|
||||||
|
mod capabilities;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod devices;
|
pub mod devices;
|
||||||
mod receive;
|
mod receive;
|
||||||
mod serve;
|
mod serve;
|
||||||
|
mod similarity;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
@@ -24,6 +28,9 @@ use std::sync::{Arc, OnceLock};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
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::{
|
use music_dht::{
|
||||||
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||||
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||||
@@ -36,6 +43,7 @@ use crate::config::AppConfig;
|
|||||||
use storage::PostgresFederationStorage;
|
use storage::PostgresFederationStorage;
|
||||||
|
|
||||||
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
||||||
|
pub use similarity::SIMILARITY_ALPN;
|
||||||
|
|
||||||
/// How often the published library is re-synchronized with the database.
|
/// How often the published library is re-synchronized with the database.
|
||||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||||
@@ -43,6 +51,7 @@ const TRANSPORT_SAMPLE_LIMIT: usize = 16;
|
|||||||
|
|
||||||
struct Running {
|
struct Running {
|
||||||
service: Arc<MusicDhtService>,
|
service: Arc<MusicDhtService>,
|
||||||
|
similarity_dht: Arc<SimilarityDht>,
|
||||||
network_name: String,
|
network_name: String,
|
||||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
@@ -122,6 +131,7 @@ struct TransportStatsState {
|
|||||||
audio_samples: u64,
|
audio_samples: u64,
|
||||||
catalog_samples: u64,
|
catalog_samples: u64,
|
||||||
sync_samples: u64,
|
sync_samples: u64,
|
||||||
|
similarity_samples: u64,
|
||||||
last: VecDeque<TransportSample>,
|
last: VecDeque<TransportSample>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +165,7 @@ impl TransportStats {
|
|||||||
"audio" => state.audio_samples += 1,
|
"audio" => state.audio_samples += 1,
|
||||||
"catalog" => state.catalog_samples += 1,
|
"catalog" => state.catalog_samples += 1,
|
||||||
"device-sync" => state.sync_samples += 1,
|
"device-sync" => state.sync_samples += 1,
|
||||||
|
"similarity" => state.similarity_samples += 1,
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
state.last.push_front(sample);
|
state.last.push_front(sample);
|
||||||
@@ -175,6 +186,7 @@ impl TransportStats {
|
|||||||
"audio_samples": state.audio_samples,
|
"audio_samples": state.audio_samples,
|
||||||
"catalog_samples": state.catalog_samples,
|
"catalog_samples": state.catalog_samples,
|
||||||
"sync_samples": state.sync_samples,
|
"sync_samples": state.sync_samples,
|
||||||
|
"similarity_samples": state.similarity_samples,
|
||||||
"last_path": latest.map(|sample| sample.selected_path.clone()),
|
"last_path": latest.map(|sample| sample.selected_path.clone()),
|
||||||
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
|
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
|
||||||
"last_peer": latest.map(|sample| sample.peer_id.clone()),
|
"last_peer": latest.map(|sample| sample.peer_id.clone()),
|
||||||
@@ -212,7 +224,8 @@ pub fn record_stream_transport(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Federation {
|
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,
|
data_dir: PathBuf,
|
||||||
database_url: std::sync::Mutex<String>,
|
database_url: std::sync::Mutex<String>,
|
||||||
storage_dir: std::sync::Mutex<String>,
|
storage_dir: std::sync::Mutex<String>,
|
||||||
@@ -371,6 +384,9 @@ impl Federation {
|
|||||||
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||||
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
||||||
self.transport_stats.reset();
|
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()
|
let config = MusicDhtConfig::builder()
|
||||||
.data_dir(&self.data_dir)
|
.data_dir(&self.data_dir)
|
||||||
@@ -380,6 +396,9 @@ impl Federation {
|
|||||||
.stream_protocol(AUDIO_ALPN)
|
.stream_protocol(AUDIO_ALPN)
|
||||||
.stream_protocol(CATALOG_ALPN)
|
.stream_protocol(CATALOG_ALPN)
|
||||||
.stream_protocol(devices::SYNC_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()
|
.build()
|
||||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||||
let (service, mut events) =
|
let (service, mut events) =
|
||||||
@@ -393,6 +412,25 @@ impl Federation {
|
|||||||
"federation started"
|
"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.
|
// Drain DHT events into the log; the channel is bounded.
|
||||||
let event_task = tokio::spawn(async move {
|
let event_task = tokio::spawn(async move {
|
||||||
while let Some(event) = events.recv().await {
|
while let Some(event) = events.recv().await {
|
||||||
@@ -447,9 +485,23 @@ impl Federation {
|
|||||||
device_hub,
|
device_hub,
|
||||||
Arc::clone(&self.transport_stats),
|
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 {
|
*guard = Some(Running {
|
||||||
service,
|
service,
|
||||||
|
similarity_dht,
|
||||||
network_name,
|
network_name,
|
||||||
tasks: vec![
|
tasks: vec![
|
||||||
event_task,
|
event_task,
|
||||||
@@ -458,6 +510,11 @@ impl Federation {
|
|||||||
catalog_task,
|
catalog_task,
|
||||||
device_task,
|
device_task,
|
||||||
device_sync_task,
|
device_sync_task,
|
||||||
|
capabilities_task,
|
||||||
|
similarity_task,
|
||||||
|
similarity_dht_serve_task,
|
||||||
|
similarity_dht_maintenance_task,
|
||||||
|
similarity_dht_sync_task,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
self.set_error(None);
|
self.set_error(None);
|
||||||
@@ -481,6 +538,20 @@ impl Federation {
|
|||||||
.context("federation is not running")
|
.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>) {
|
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||||
if let Ok(service) = self.service().await {
|
if let Ok(service) = self.service().await {
|
||||||
let fed = Arc::clone(self);
|
let fed = Arc::clone(self);
|
||||||
@@ -784,6 +855,7 @@ impl Federation {
|
|||||||
"endpoint_id": service.endpoint_id().to_string(),
|
"endpoint_id": service.endpoint_id().to_string(),
|
||||||
"connected_peers": peers,
|
"connected_peers": peers,
|
||||||
"known_contacts": service.known_peers().len(),
|
"known_contacts": service.known_peers().len(),
|
||||||
|
"similarity_routing_peers": running.similarity_dht.known_peers(),
|
||||||
"published_items": published,
|
"published_items": published,
|
||||||
"transport": self.transport_stats.snapshot(),
|
"transport": self.transport_stats.snapshot(),
|
||||||
})
|
})
|
||||||
@@ -819,6 +891,26 @@ impl Federation {
|
|||||||
Ok(peer.to_string())
|
Ok(peer.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn search_similarity(
|
||||||
|
&self,
|
||||||
|
query: crate::similarity::QueryVector,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<Vec<similarity::RemoteSimilarityTrack>> {
|
||||||
|
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(
|
pub async fn fed_device_status(
|
||||||
&self,
|
&self,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
@@ -944,6 +1036,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(
|
async fn persist_content_id(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
media_file_id: i64,
|
media_file_id: i64,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use super::{TransportStats, record_stream_transport};
|
|||||||
|
|
||||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
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).
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
|
|||||||
@@ -0,0 +1,386 @@
|
|||||||
|
//! 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 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<Vec<RemoteSimilarityTrack>> {
|
||||||
|
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 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)) {
|
||||||
|
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(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -298,6 +298,9 @@ translations! {
|
|||||||
player_likes_playlist: "Likes" , "Лайки";
|
player_likes_playlist: "Likes" , "Лайки";
|
||||||
player_listened: "listened" , "прослушано";
|
player_listened: "listened" , "прослушано";
|
||||||
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
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: "Server connection lost" , "Нет соединения с сервером";
|
||||||
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||||
player_active_device: "Active device" , "Активный девайс";
|
player_active_device: "Active device" , "Активный девайс";
|
||||||
@@ -314,6 +317,9 @@ translations! {
|
|||||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||||
player_rename: "Rename" , "Переименовать";
|
player_rename: "Rename" , "Переименовать";
|
||||||
player_close: "Close" , "Закрыть";
|
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_log_out: "Log out" , "Выйти";
|
||||||
player_admin_panel: "Admin Panel" , "Админка";
|
player_admin_panel: "Admin Panel" , "Админка";
|
||||||
player_info: "Info" , "Информация";
|
player_info: "Info" , "Информация";
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ mod music;
|
|||||||
mod oidc;
|
mod oidc;
|
||||||
mod player;
|
mod player;
|
||||||
mod scheduler;
|
mod scheduler;
|
||||||
|
mod similarity;
|
||||||
mod torrents;
|
mod torrents;
|
||||||
mod user;
|
mod user;
|
||||||
|
|
||||||
@@ -567,6 +568,13 @@ impl Project for FuruProject {
|
|||||||
federation::handle().boot(&fed_config).await;
|
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(cot::session::db::SessionApp::new());
|
||||||
apps.register_with_views(
|
apps.register_with_views(
|
||||||
FuruApp {
|
FuruApp {
|
||||||
|
|||||||
@@ -2476,6 +2476,99 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(repair_legacy_listen_qualification).build()];
|
&[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] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -2509,5 +2602,7 @@ pub mod db_migrations {
|
|||||||
&M0040CreateContentAddressedMusicRefs,
|
&M0040CreateContentAddressedMusicRefs,
|
||||||
&M0041CreateSyncedListenHistory,
|
&M0041CreateSyncedListenHistory,
|
||||||
&M0042RepairLegacyListenQualification,
|
&M0042RepairLegacyListenQualification,
|
||||||
|
&M0043CreateSimilarityEmbeddings,
|
||||||
|
&M0044AddSimilarityRoutingSignature,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+375
-18
@@ -14,6 +14,7 @@ use cot::router::method::{delete, get, post};
|
|||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{App, Body, Template};
|
use cot::{App, Body, Template};
|
||||||
|
use serde::Serialize;
|
||||||
use sqlx::Row as _;
|
use sqlx::Row as _;
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
@@ -54,6 +55,7 @@ struct LocalUploadResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PLAYER_DEVICE_TTL_MS: i64 = 30_000;
|
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_COMMAND_TTL_MS: i64 = 20_000;
|
||||||
const PLAYER_DEVICE_MAX_COMMANDS: usize = 32;
|
const PLAYER_DEVICE_MAX_COMMANDS: usize = 32;
|
||||||
const PLAYER_JAM_IDLE_TTL_MS: i64 = 4 * 60 * 60 * 1000;
|
const PLAYER_JAM_IDLE_TTL_MS: i64 = 4 * 60 * 60 * 1000;
|
||||||
@@ -104,6 +106,7 @@ struct PlayerJamSession {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct PlayerDeviceHubState {
|
struct PlayerDeviceHubState {
|
||||||
devices_by_user: HashMap<i64, HashMap<String, PlayerDevice>>,
|
devices_by_user: HashMap<i64, HashMap<String, PlayerDevice>>,
|
||||||
|
device_last_seen_ms: HashMap<(i64, String), i64>,
|
||||||
active_device_by_user: HashMap<i64, String>,
|
active_device_by_user: HashMap<i64, String>,
|
||||||
commands_by_device: HashMap<(i64, String), VecDeque<PendingPlayerDeviceCommand>>,
|
commands_by_device: HashMap<(i64, String), VecDeque<PendingPlayerDeviceCommand>>,
|
||||||
playback_state_by_user: HashMap<i64, PlayerDevicePlaybackStateDto>,
|
playback_state_by_user: HashMap<i64, PlayerDevicePlaybackStateDto>,
|
||||||
@@ -153,19 +156,12 @@ impl PlayerDeviceHub {
|
|||||||
Ok(())
|
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");
|
let state = self.state.lock().expect("player device hub lock");
|
||||||
if state
|
!state
|
||||||
.active_device_by_user
|
.active_device_by_user
|
||||||
.get(&user_id)
|
.get(&user_id)
|
||||||
.is_some_and(|id| is_fed_virtual_device_id(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(
|
pub(crate) fn playback_state_json_for_commands(
|
||||||
@@ -264,14 +260,58 @@ impl PlayerDeviceHub {
|
|||||||
user_agent: Option<&str>,
|
user_agent: Option<&str>,
|
||||||
current_jam_id: Option<&str>,
|
current_jam_id: Option<&str>,
|
||||||
playback_state: Option<PlayerDevicePlaybackStateDto>,
|
playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||||
) -> PlayerDevicesResponse {
|
) -> (PlayerDevicesResponse, Option<String>) {
|
||||||
let now = current_millis();
|
let now = current_millis();
|
||||||
let mut state = self.state.lock().expect("player device hub lock");
|
let mut state = self.state.lock().expect("player device hub lock");
|
||||||
self.prune_locked(&mut state, now);
|
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);
|
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.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.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(
|
fn poll(
|
||||||
@@ -448,6 +488,9 @@ impl PlayerDeviceHub {
|
|||||||
last_seen_ms: now,
|
last_seen_ms: now,
|
||||||
};
|
};
|
||||||
devices.insert(device_id.to_string(), device);
|
devices.insert(device_id.to_string(), device);
|
||||||
|
state
|
||||||
|
.device_last_seen_ms
|
||||||
|
.insert((user_id, device_id.to_string()), now);
|
||||||
|
|
||||||
let active_online = state
|
let active_online = state
|
||||||
.active_device_by_user
|
.active_device_by_user
|
||||||
@@ -868,6 +911,9 @@ impl PlayerDeviceHub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn prune_locked(&self, state: &mut PlayerDeviceHubState, now: i64) {
|
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
|
state
|
||||||
.jams_by_id
|
.jams_by_id
|
||||||
.retain(|_, jam| now.saturating_sub(jam.host_last_seen_ms) <= PLAYER_JAM_IDLE_TTL_MS);
|
.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"))
|
.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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
@@ -4166,6 +4315,118 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
struct SimilaritySearchResponse {
|
||||||
|
label: String,
|
||||||
|
tracks: Vec<TrackItem>,
|
||||||
|
federation_tracks: Vec<crate::federation::client::TrackDto>,
|
||||||
|
federation_error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn similarity_search_handler(
|
||||||
|
auth_ctx: auth::AuthContext,
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
Path(path): Path<PathId>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
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 mut tracks = Vec::with_capacity(ids.len() + 1);
|
||||||
|
tracks.push(source_track.clone());
|
||||||
|
tracks.extend(load_track_items_by_ids(pool, &ids).await?);
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let (federation_tracks, federation_error) = if config.federation_enabled {
|
||||||
|
match crate::federation::handle()
|
||||||
|
.search_similarity(query, 50)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(remote) => match crate::federation::handle()
|
||||||
|
.prepare_similarity_tracks(remote)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(tracks) => (tracks, None),
|
||||||
|
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
|
||||||
|
},
|
||||||
|
Err(error) => (Vec::new(), Some(format!("{error:#}"))),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
(Vec::new(), None)
|
||||||
|
};
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /api/player/share-playlist
|
// POST /api/player/share-playlist
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -4846,7 +5107,7 @@ async fn devices_heartbeat_handler(
|
|||||||
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id"));
|
return Ok(json_error(StatusCode::BAD_REQUEST, "invalid device id"));
|
||||||
};
|
};
|
||||||
|
|
||||||
let response = hub.heartbeat(
|
let (response, previous_active_id) = hub.heartbeat(
|
||||||
user.id,
|
user.id,
|
||||||
&device_id,
|
&device_id,
|
||||||
dto.user_agent.as_deref(),
|
dto.user_agent.as_deref(),
|
||||||
@@ -4856,6 +5117,22 @@ async fn devices_heartbeat_handler(
|
|||||||
.as_deref(),
|
.as_deref(),
|
||||||
dto.playback_state,
|
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()
|
Json(response).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5602,19 +5879,62 @@ async fn history_list_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.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
|
let items = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|row| {
|
.map(|row| {
|
||||||
let listen_id: String = row.get("listen_id");
|
let listen_id: String = row.get("listen_id");
|
||||||
let content_id: String = row.get("content_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 metadata: serde_json::Value = row.get("metadata_json");
|
||||||
let title = metadata
|
let snapshot_title = metadata
|
||||||
.get("title")
|
.get("title")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.unwrap_or("Unknown track")
|
.unwrap_or("Unknown track")
|
||||||
.to_string();
|
.to_string();
|
||||||
let release_title = metadata
|
let snapshot_release_title = metadata
|
||||||
.get("release_title")
|
.get("release_title")
|
||||||
.and_then(serde_json::Value::as_str)
|
.and_then(serde_json::Value::as_str)
|
||||||
.map(ToOwned::to_owned);
|
.map(ToOwned::to_owned);
|
||||||
@@ -5635,20 +5955,20 @@ async fn history_list_handler(
|
|||||||
row.get("release_cover_file_id"),
|
row.get("release_cover_file_id"),
|
||||||
"medium",
|
"medium",
|
||||||
);
|
);
|
||||||
let track = serde_json::json!({
|
let fallback_track = serde_json::json!({
|
||||||
"id": local_track_id.map_or_else(
|
"id": local_track_id.map_or_else(
|
||||||
|| format!("history:{content_id}"),
|
|| format!("history:{content_id}"),
|
||||||
|id| id.to_string(),
|
|id| id.to_string(),
|
||||||
),
|
),
|
||||||
"content_id": content_id,
|
"content_id": content_id,
|
||||||
"title": title,
|
"title": snapshot_title,
|
||||||
"track_number": null,
|
"track_number": null,
|
||||||
"disc_number": null,
|
"disc_number": null,
|
||||||
"duration_seconds": duration_ms.unwrap_or_default() as f64 / 1000.0,
|
"duration_seconds": duration_ms.unwrap_or_default() as f64 / 1000.0,
|
||||||
"artists": artist_values("artist_names"),
|
"artists": artist_values("artist_names"),
|
||||||
"featured_artists": artist_values("featured_artist_names"),
|
"featured_artists": artist_values("featured_artist_names"),
|
||||||
"release_id": release_id,
|
"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"),
|
"release_year": row.get::<Option<i32>, _>("release_year"),
|
||||||
"cover_url": cover_url,
|
"cover_url": cover_url,
|
||||||
"stream_url": local_track_id
|
"stream_url": local_track_id
|
||||||
@@ -5670,6 +5990,16 @@ async fn history_list_handler(
|
|||||||
None
|
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 started_at_ms: i64 = row.get("started_at_ms");
|
||||||
let played_at = chrono::DateTime::from_timestamp_millis(started_at_ms)
|
let played_at = chrono::DateTime::from_timestamp_millis(started_at_ms)
|
||||||
.unwrap_or_else(chrono::Utc::now)
|
.unwrap_or_else(chrono::Utc::now)
|
||||||
@@ -9596,6 +9926,33 @@ impl App for PlayerApp {
|
|||||||
}),
|
}),
|
||||||
"player_search",
|
"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>| {
|
||||||
|
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).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"player_similarity_search",
|
||||||
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/federation/search/events",
|
"/federation/search/events",
|
||||||
get(
|
get(
|
||||||
|
|||||||
+1410
File diff suppressed because it is too large
Load Diff
+181
-2
@@ -2192,6 +2192,63 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<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">Downloads the selected model and processes every visible local track. With federation enabled, signed anonymous LSH summaries discover likely peers; full query embeddings are sent only to those peers, and this instance answers their 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" x-text="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license || ''"></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>
|
||||||
|
<details class="setting-help" style="margin-top:8px">
|
||||||
|
<summary style="cursor:pointer">Show profile details</summary>
|
||||||
|
<pre style="white-space:pre-wrap;font:inherit;margin:8px 0 0" x-text="selectedSimilarityProfile()?.details || 'Profile details are loading…'"></pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
<div class="setting-field">
|
||||||
|
<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">Applied immediately after saving.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-column settings-side">
|
<div class="settings-column settings-side">
|
||||||
@@ -2311,6 +2368,7 @@ tbody tr:hover {
|
|||||||
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></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>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>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>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 class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -2323,7 +2381,7 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
<div class="probe-row"><span>RTT</span><strong x-text="fedRtt(fedTransport().last_rtt_ms)"></strong></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>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 class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
|
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
|
||||||
@@ -2366,6 +2424,44 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Similarity Status</strong>
|
||||||
|
<span>Model download, indexing, and active profile</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 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" type="button" @click="loadSimilarity()" :disabled="similarityLoading">
|
||||||
|
<i data-lucide="refresh-cw"></i>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
<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">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
@@ -2936,7 +3032,11 @@ function adminV2() {
|
|||||||
agent_concurrency: '',
|
agent_concurrency: '',
|
||||||
federation_enabled: false,
|
federation_enabled: false,
|
||||||
federation_network_id: '',
|
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 },
|
settingsProbe: { status: 'idle', ok: false },
|
||||||
settingsProbeLoading: false,
|
settingsProbeLoading: false,
|
||||||
@@ -2944,6 +3044,8 @@ function adminV2() {
|
|||||||
federationLoading: false,
|
federationLoading: false,
|
||||||
federationTicket: '',
|
federationTicket: '',
|
||||||
fedConnectTicket: '',
|
fedConnectTicket: '',
|
||||||
|
similarityStatus: { status: { phase: 'disabled' }, models: [], profiles: [] },
|
||||||
|
similarityLoading: false,
|
||||||
settingsSaving: false,
|
settingsSaving: false,
|
||||||
routeReady: false,
|
routeReady: false,
|
||||||
poller: null,
|
poller: null,
|
||||||
@@ -3007,6 +3109,11 @@ function adminV2() {
|
|||||||
]);
|
]);
|
||||||
} else if (this.activeView === 'users') {
|
} else if (this.activeView === 'users') {
|
||||||
await Promise.allSettled([this.loadUsers(false)]);
|
await Promise.allSettled([this.loadUsers(false)]);
|
||||||
|
} else if (this.activeView === 'settings') {
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.loadFederation(false),
|
||||||
|
this.loadSimilarity(false)
|
||||||
|
]);
|
||||||
} else {
|
} else {
|
||||||
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
||||||
}
|
}
|
||||||
@@ -3069,6 +3176,10 @@ function adminV2() {
|
|||||||
} else if (this.activeView === 'settings') {
|
} else if (this.activeView === 'settings') {
|
||||||
if (updateRoute) this.setRoute('#settings');
|
if (updateRoute) this.setRoute('#settings');
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
|
await Promise.allSettled([
|
||||||
|
this.loadFederation(false),
|
||||||
|
this.loadSimilarity(false)
|
||||||
|
]);
|
||||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||||
await this.loadSettingsProbe(false);
|
await this.loadSettingsProbe(false);
|
||||||
}
|
}
|
||||||
@@ -3216,6 +3327,21 @@ function adminV2() {
|
|||||||
|
|
||||||
async saveSettings() {
|
async saveSettings() {
|
||||||
if (this.settingsSaving) return;
|
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;
|
this.settingsSaving = true;
|
||||||
try {
|
try {
|
||||||
await this.request(`${this.apiBase}/settings`, {
|
await this.request(`${this.apiBase}/settings`, {
|
||||||
@@ -3224,6 +3350,7 @@ function adminV2() {
|
|||||||
});
|
});
|
||||||
await this.loadSettings(false);
|
await this.loadSettings(false);
|
||||||
await this.loadFederation(false);
|
await this.loadFederation(false);
|
||||||
|
await this.loadSimilarity(false);
|
||||||
this.showToast('Settings saved');
|
this.showToast('Settings saved');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.showToast(error.message);
|
this.showToast(error.message);
|
||||||
@@ -3238,6 +3365,7 @@ function adminV2() {
|
|||||||
this.setRoute('#settings');
|
this.setRoute('#settings');
|
||||||
await this.loadSettings();
|
await this.loadSettings();
|
||||||
await this.loadFederation(false);
|
await this.loadFederation(false);
|
||||||
|
await this.loadSimilarity(false);
|
||||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||||
await this.loadSettingsProbe(false);
|
await this.loadSettingsProbe(false);
|
||||||
}
|
}
|
||||||
@@ -3255,6 +3383,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() {
|
async fedSyncNow() {
|
||||||
this.federationLoading = true;
|
this.federationLoading = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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>
|
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
||||||
{% block head_extra %}{% endblock head_extra %}
|
{% block head_extra %}{% endblock head_extra %}
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -653,6 +653,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<section class="user-settings-section">
|
||||||
<div class="user-settings-section-head">
|
<div class="user-settings-section-head">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ const T = {
|
|||||||
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
||||||
startRadio: "{{ t.player_start_radio }}",
|
startRadio: "{{ t.player_start_radio }}",
|
||||||
radioFailed: "{{ t.player_radio_failed }}",
|
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 }}",
|
share: "{{ t.player_share }}",
|
||||||
shareTrack: "{{ t.player_share_track }}",
|
shareTrack: "{{ t.player_share_track }}",
|
||||||
shareQueue: "{{ t.player_share_queue }}",
|
shareQueue: "{{ t.player_share_queue }}",
|
||||||
@@ -735,6 +738,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_remoteStateReceivedAt: 0,
|
_remoteStateReceivedAt: 0,
|
||||||
_remoteStateTimer: null,
|
_remoteStateTimer: null,
|
||||||
_prefetchedQueueKey: null,
|
_prefetchedQueueKey: null,
|
||||||
|
_baseDocumentTitle: '',
|
||||||
|
|
||||||
_hasInitialShareLink() {
|
_hasInitialShareLink() {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@@ -744,6 +748,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
audio.volume = this.volume;
|
audio.volume = this.volume;
|
||||||
|
this._baseDocumentTitle = document.title.replace(/^▶\s*/, '');
|
||||||
|
Alpine.effect(() => {
|
||||||
|
document.title = `${this.isPlaying ? '▶ ' : ''}${this._baseDocumentTitle}`;
|
||||||
|
});
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', () => {
|
audio.addEventListener('timeupdate', () => {
|
||||||
this.currentTime = audio.currentTime;
|
this.currentTime = audio.currentTime;
|
||||||
@@ -2498,6 +2506,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
searchResults: null,
|
searchResults: null,
|
||||||
searchLoading: false,
|
searchLoading: false,
|
||||||
|
similaritySearchLabel: '',
|
||||||
|
similaritySearchError: '',
|
||||||
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
|
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
|
||||||
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
|
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
|
||||||
federationPreparing: {},
|
federationPreparing: {},
|
||||||
@@ -2589,6 +2599,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.searchQuery = q;
|
this.searchQuery = q;
|
||||||
this.search(q, options);
|
this.search(q, options);
|
||||||
}
|
}
|
||||||
|
} else if (view === 'similar' && id) {
|
||||||
|
this.searchSimilar(id, options);
|
||||||
} else {
|
} else {
|
||||||
this.goArtists(options);
|
this.goArtists(options);
|
||||||
}
|
}
|
||||||
@@ -3042,6 +3054,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
openTrackInfo(track) {
|
openTrackInfo(track) {
|
||||||
Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track), [
|
Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track), [
|
||||||
{ label: T.startRadio, run: () => this.startRadio('track', track?.id) },
|
{ label: T.startRadio, run: () => this.startRadio('track', track?.id) },
|
||||||
|
{ label: T.findSimilar, run: () => this.searchSimilar(track) },
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -3380,6 +3393,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
this.view = 'search';
|
this.view = 'search';
|
||||||
this.searchLoading = true;
|
this.searchLoading = true;
|
||||||
|
this.similaritySearchLabel = '';
|
||||||
|
this.similaritySearchError = '';
|
||||||
this.startFederationSearch(q);
|
this.startFederationSearch(q);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
||||||
@@ -3392,11 +3407,59 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._afterNavigation(options);
|
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: [] };
|
||||||
|
Alpine.store('info').close();
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/player/similarity/${id}`);
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
if (!response.ok) throw new Error(data.error || T.similarityFailed);
|
||||||
|
this.similaritySearchLabel = data.label || initialLabel;
|
||||||
|
this.searchQuery = this.similaritySearchLabel;
|
||||||
|
this.searchResults = {
|
||||||
|
artists: [],
|
||||||
|
releases: [],
|
||||||
|
tracks: Array.isArray(data.tracks) ? data.tracks : [],
|
||||||
|
};
|
||||||
|
this.federationSearch = {
|
||||||
|
loading: false,
|
||||||
|
error: data.federation_error || '',
|
||||||
|
artists: [],
|
||||||
|
releases: [],
|
||||||
|
tracks: Array.isArray(data.federation_tracks) ? data.federation_tracks : [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.searchResults = { artists: [], releases: [], tracks: [] };
|
||||||
|
this.federationSearch = { loading: false, error: '', artists: [], releases: [], tracks: [] };
|
||||||
|
this.similaritySearchError = error?.message || T.similarityFailed;
|
||||||
|
}
|
||||||
|
this.searchLoading = false;
|
||||||
|
this._afterNavigation(options);
|
||||||
|
},
|
||||||
|
|
||||||
clearSearch() {
|
clearSearch() {
|
||||||
this.stopFederationSearch();
|
this.stopFederationSearch();
|
||||||
this.searchQuery = '';
|
this.searchQuery = '';
|
||||||
this.searchResults = null;
|
this.searchResults = null;
|
||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
|
this.similaritySearchLabel = '';
|
||||||
|
this.similaritySearchError = '';
|
||||||
if (this.view === 'search') {
|
if (this.view === 'search') {
|
||||||
this.view = this._previousView || 'artists';
|
this.view = this._previousView || 'artists';
|
||||||
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
||||||
@@ -4019,6 +4082,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
playable,
|
playable,
|
||||||
});
|
});
|
||||||
this.refreshMaterializedArtwork(track);
|
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 finalProgress = this.federationPreparing[contentId] || {};
|
||||||
const queueIndex = Number.isInteger(finalProgress.queueIndex)
|
const queueIndex = Number.isInteger(finalProgress.queueIndex)
|
||||||
@@ -4129,10 +4195,16 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_set: new Set(),
|
_set: new Set(),
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
fetch('/api/player/likes')
|
this.reload();
|
||||||
.then(r => r.json())
|
},
|
||||||
.then(d => { this._set = new Set(d.track_ids || []); })
|
|
||||||
.catch(() => {});
|
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) {
|
has(trackId) {
|
||||||
|
|||||||
@@ -379,12 +379,23 @@
|
|||||||
<!-- Search Results -->
|
<!-- Search Results -->
|
||||||
<template x-if="$store.library.view === 'search'">
|
<template x-if="$store.library.view === 'search'">
|
||||||
<div>
|
<div>
|
||||||
|
<h2 class="search-similarity-title"
|
||||||
|
x-show="$store.library.similaritySearchLabel"
|
||||||
|
x-cloak>
|
||||||
|
<span>{{ t.player_search_similar_to }}</span>
|
||||||
|
<strong x-text="$store.library.similaritySearchLabel"></strong>
|
||||||
|
</h2>
|
||||||
<template x-if="$store.library.searchLoading">
|
<template x-if="$store.library.searchLoading">
|
||||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||||
</template>
|
</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">
|
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
||||||
<div>
|
<div>
|
||||||
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
<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">
|
<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>
|
<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>
|
<p>{{ t.player_no_results }}</p>
|
||||||
@@ -757,8 +768,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
|
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
|
||||||
<section class="artist-release-group">
|
<section class="artist-release-group" x-data="{ expanded: false }">
|
||||||
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
<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">
|
<div class="track-list-header">
|
||||||
<span>#</span>
|
<span>#</span>
|
||||||
<span>{{ t.player_title }}</span>
|
<span>{{ t.player_title }}</span>
|
||||||
@@ -766,7 +791,9 @@
|
|||||||
<span></span>
|
<span></span>
|
||||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||||
</div>
|
</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"
|
<div class="track-row artist-appearance-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
|
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
|
||||||
|
|||||||
@@ -676,6 +676,42 @@ button.user-stat:hover {
|
|||||||
margin-bottom: 14px;
|
margin-bottom: 14px;
|
||||||
text-transform: capitalize;
|
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 detail header */
|
||||||
.release-header {
|
.release-header {
|
||||||
@@ -2836,6 +2872,21 @@ button.user-stat:hover {
|
|||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-similarity-title {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.search-similarity-title strong {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
.search-artists-row {
|
.search-artists-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
|
|||||||
Reference in New Issue
Block a user