Added similarity search
Build and Publish / Build and Publish Docker Image (push) Successful in 3m29s
Build and Publish / Build and Publish Docker Image (push) Successful in 3m29s
This commit is contained in:
@@ -3,3 +3,5 @@
|
||||
/.claude
|
||||
/media
|
||||
/federation
|
||||
/similarity-models
|
||||
/federation-cache
|
||||
|
||||
Generated
+614
-5
@@ -159,6 +159,12 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "anymap3"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -488,6 +494,24 @@ dependencies = [
|
||||
"virtue",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -1069,6 +1093,16 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
@@ -1093,6 +1127,12 @@ version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
@@ -1284,6 +1324,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -1341,6 +1387,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive-new"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@@ -1493,12 +1550,30 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-eq"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-hash"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
@@ -1641,6 +1716,17 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -1748,6 +1834,16 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1764,6 +1860,12 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-ord"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1836,7 +1938,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.9.7"
|
||||
version = "0.9.8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
@@ -1848,6 +1950,7 @@ dependencies = [
|
||||
"cot",
|
||||
"croner",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"id3",
|
||||
"image",
|
||||
"librqbit",
|
||||
@@ -1856,6 +1959,8 @@ dependencies = [
|
||||
"openidconnect",
|
||||
"postcard",
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
"rustfft",
|
||||
"schemars 0.9.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1867,6 +1972,7 @@ dependencies = [
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tract-onnx",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -2191,6 +2297,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"num-traits",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.2.1"
|
||||
@@ -2236,6 +2354,8 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2799,6 +2919,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
@@ -3491,6 +3620,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -3506,6 +3641,16 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -3531,6 +3676,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3547,6 +3698,16 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42d74234349a775546a83af0f0c0c0e3a73227dee4950a542cda81a47240b3e6"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3614,9 +3775,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "music-dht"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5f32daa9edf769fb5686e92ae6884e9fda6ea082452e4208309fc14301e26aef"
|
||||
checksum = "91592b40de9f3c2158a39da105c821e9bf17f461fe142a56a8607fb0faf56a9c"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
@@ -3704,6 +3865,21 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex 0.4.6",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
@@ -3835,6 +4011,24 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom-language"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
@@ -3918,10 +4112,20 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-complex 0.2.4",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-rational 0.2.4",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
@@ -3951,6 +4155,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -3998,6 +4211,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -4352,6 +4576,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -4562,6 +4792,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portmapper"
|
||||
version = "0.19.1"
|
||||
@@ -4651,6 +4890,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
@@ -4678,6 +4926,29 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
@@ -4883,6 +5154,16 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.10.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
@@ -4901,6 +5182,32 @@ dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -5099,6 +5406,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
|
||||
dependencies = [
|
||||
"dasp_sample",
|
||||
"num-rational 0.4.2",
|
||||
"symphonia",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
@@ -5154,6 +5473,20 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfft"
|
||||
version = "6.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||
dependencies = [
|
||||
"num-complex 0.4.6",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"primal-check",
|
||||
"strength_reduce",
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -5254,6 +5587,19 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -5263,6 +5609,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scan_fmt"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -5996,6 +6351,22 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad3df9b59e2eded8d825c7c4363ad339a20fb6bc0b9a4778560f518f59910b15"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -6329,6 +6700,17 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -6788,12 +7170,229 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-core"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "608e176a669d5da02cccc92bbfe5ee4e57686ed8841022608a9eba014d3b7886"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"anymap3",
|
||||
"bit-set",
|
||||
"derive-new",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"erased-serde",
|
||||
"inventory",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"maplit",
|
||||
"ndarray",
|
||||
"num-complex 0.4.6",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"rustfft",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"tract-data",
|
||||
"tract-linalg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-data"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "870236dd45aaeb1381023cb709a67ff14ece608ee0b37f99aa166d166db9b0d0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"dyn-hash",
|
||||
"half",
|
||||
"inventory",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"maplit",
|
||||
"ndarray",
|
||||
"nom",
|
||||
"nom-language",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"parking_lot",
|
||||
"scan_fmt",
|
||||
"smallvec",
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-extra"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "effcae1ebfce133e8bf6c79ad31298cbee0a9eceb3e4642fe1d14cb54cca78e6"
|
||||
dependencies = [
|
||||
"tract-nnef",
|
||||
"tract-pulse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-hir"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28783b2bb583177685f65016a866b01eb3d383bce4fa7ae1b9692b325b64449d"
|
||||
dependencies = [
|
||||
"derive-new",
|
||||
"log",
|
||||
"tract-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-linalg"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e01491f7360806ef061af4c016a2d0a801d586768896394a0b8a7d6872c2b0"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"cc",
|
||||
"derive-new",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"dyn-hash",
|
||||
"half",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"minijinja",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"scan_fmt",
|
||||
"tract-data",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-nnef"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00417fabf01aeea7bc56107367862e5bb8da18c9ad850c9061d3823700479ecc"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"erased-serde",
|
||||
"flate2",
|
||||
"log",
|
||||
"minijinja",
|
||||
"nom",
|
||||
"nom-language",
|
||||
"safetensors",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simd-adler32",
|
||||
"tar",
|
||||
"tract-core",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-onnx"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3215dd27bddd2a041a20fee750013b400135186d3485501c9274c755b19ceb0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"derive-new",
|
||||
"dyn-eq",
|
||||
"log",
|
||||
"memmap2",
|
||||
"num-integer",
|
||||
"prost",
|
||||
"smallvec",
|
||||
"tract-extra",
|
||||
"tract-hir",
|
||||
"tract-nnef",
|
||||
"tract-onnx-opl",
|
||||
"tract-transformers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-onnx-opl"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44f549ad3f245c1c00ce710c66cc0e086be0b637f35934d37b655ff175e1ab3e"
|
||||
dependencies = [
|
||||
"dyn-eq",
|
||||
"getrandom 0.4.3",
|
||||
"log",
|
||||
"rand 0.10.2",
|
||||
"rand_distr",
|
||||
"rustfft",
|
||||
"tract-extra",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-pulse"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a164e22e96ab9b5c90458fa270700570d87963e3dec9ccaac1ab18d9fa763ce2"
|
||||
dependencies = [
|
||||
"downcast-rs",
|
||||
"dyn-eq",
|
||||
"erased-serde",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"serde",
|
||||
"tract-pulse-opl",
|
||||
"tract-transformers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-pulse-opl"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65418f9e93e0af0d567f4f2f1bf2309b53930fa2543c635a69fda10c87a04987"
|
||||
dependencies = [
|
||||
"downcast-rs",
|
||||
"dyn-eq",
|
||||
"lazy_static",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-transformers"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8471ebf7f52d226552283d9130539595540c28ed41b8e032df3830507d3ccdd5"
|
||||
dependencies = [
|
||||
"float-ord",
|
||||
"rayon",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -7559,6 +8158,16 @@ dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
|
||||
+7
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.9.8"
|
||||
version = "0.10.0"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
@@ -12,7 +12,7 @@ cot = { version = "0.6.0", default-features = false, features = ["postgres", "js
|
||||
schemars = { version = "0.9", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
openidconnect = "4.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
|
||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||
async-stream = "0.3"
|
||||
bytes = "1"
|
||||
@@ -31,6 +31,10 @@ md-5 = "0.10"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
||||
anyhow = "1.0"
|
||||
futures-util = "0.3"
|
||||
rodio = { version = "0.22.2", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac", "symphonia-adpcm", "symphonia-aiff", "symphonia-mkv", "symphonia-pcm"] }
|
||||
rustfft = "6.4.1"
|
||||
tract-onnx = "0.23.4"
|
||||
tokio-cron-scheduler = "0.15"
|
||||
croner = "3"
|
||||
async-trait = "0.1"
|
||||
@@ -39,4 +43,4 @@ uuid = "1"
|
||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||
# catalogs to furumi peers (TUI clients) over the frid stack.
|
||||
music-dht = "0.3"
|
||||
music-dht = "0.3.1"
|
||||
|
||||
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",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/similarity",
|
||||
get(move |session: Session, db: Database| async move {
|
||||
v2::similarity_status(session, db).await
|
||||
}),
|
||||
"admin_v2_similarity_status",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/similarity/clear",
|
||||
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||
v2::similarity_clear(session, db).await
|
||||
}),
|
||||
"admin_v2_similarity_clear",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/federation/sync",
|
||||
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||
|
||||
+119
@@ -454,6 +454,14 @@ struct AdminSettingsValues {
|
||||
federation_network_id: String,
|
||||
#[serde(default)]
|
||||
federation_save_on_listen: bool,
|
||||
#[serde(default)]
|
||||
similarity_enabled: bool,
|
||||
#[serde(default = "default_similarity_model")]
|
||||
similarity_model: String,
|
||||
#[serde(default = "default_similarity_profile")]
|
||||
similarity_profile: String,
|
||||
#[serde(default = "default_similarity_workers")]
|
||||
similarity_workers: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
@@ -481,6 +489,10 @@ struct AdminSettingsSources {
|
||||
federation_enabled: &'static str,
|
||||
federation_network_id: &'static str,
|
||||
federation_save_on_listen: &'static str,
|
||||
similarity_enabled: &'static str,
|
||||
similarity_model: &'static str,
|
||||
similarity_profile: &'static str,
|
||||
similarity_workers: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -511,6 +523,26 @@ pub(super) struct UpdateSettingsRequest {
|
||||
federation_network_id: String,
|
||||
#[serde(default)]
|
||||
federation_save_on_listen: bool,
|
||||
#[serde(default)]
|
||||
similarity_enabled: bool,
|
||||
#[serde(default = "default_similarity_model")]
|
||||
similarity_model: String,
|
||||
#[serde(default = "default_similarity_profile")]
|
||||
similarity_profile: String,
|
||||
#[serde(default = "default_similarity_workers")]
|
||||
similarity_workers: String,
|
||||
}
|
||||
|
||||
fn default_similarity_model() -> String {
|
||||
crate::similarity::DEFAULT_MODEL_ID.to_owned()
|
||||
}
|
||||
|
||||
fn default_similarity_profile() -> String {
|
||||
crate::similarity::DEFAULT_PROFILE_ID.to_owned()
|
||||
}
|
||||
|
||||
fn default_similarity_workers() -> String {
|
||||
"1".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -948,6 +980,29 @@ pub async fn update_settings(
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let similarity_model = body.similarity_model.trim();
|
||||
if crate::similarity::model_by_id(similarity_model).is_none() {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unknown similarity model",
|
||||
));
|
||||
}
|
||||
let similarity_profile = body.similarity_profile.trim();
|
||||
if crate::similarity::profile_by_id(similarity_profile).is_none() {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unknown similarity preprocessing profile",
|
||||
));
|
||||
}
|
||||
let similarity_workers = match body.similarity_workers.trim().parse::<u64>() {
|
||||
Ok(workers @ 1..=16) => workers,
|
||||
_ => {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"similarity workers must be an integer from 1 to 16",
|
||||
));
|
||||
}
|
||||
};
|
||||
let fields = [
|
||||
(
|
||||
"auth_password_enabled",
|
||||
@@ -1002,6 +1057,10 @@ pub async fn update_settings(
|
||||
"federation_save_on_listen",
|
||||
body.federation_save_on_listen.to_string(),
|
||||
),
|
||||
("similarity_enabled", body.similarity_enabled.to_string()),
|
||||
("similarity_model", similarity_model.to_string()),
|
||||
("similarity_profile", similarity_profile.to_string()),
|
||||
("similarity_workers", similarity_workers.to_string()),
|
||||
];
|
||||
for (key, value) in fields {
|
||||
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||
@@ -1014,6 +1073,7 @@ pub async fn update_settings(
|
||||
// the freshly saved settings — no server restart involved.
|
||||
let (fresh, _) = AppConfig::load_with_db(&db).await;
|
||||
tokio::spawn(async move {
|
||||
crate::similarity::handle().apply(&fresh);
|
||||
crate::federation::handle().apply(&fresh).await;
|
||||
});
|
||||
Json(serde_json::json!({ "ok": true })).into_response()
|
||||
@@ -1033,6 +1093,57 @@ pub async fn federation_status(
|
||||
Json(crate::federation::handle().status().await).into_response()
|
||||
}
|
||||
|
||||
pub async fn similarity_status(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let manager = crate::similarity::handle();
|
||||
let status = manager.status();
|
||||
let model_id = if status.model.is_empty() {
|
||||
crate::similarity::DEFAULT_MODEL_ID
|
||||
} else {
|
||||
&status.model
|
||||
};
|
||||
let profiles = crate::similarity::PROFILES
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
serde_json::json!({
|
||||
"id": profile.id,
|
||||
"title": profile.title,
|
||||
"details": crate::similarity::profile_details(
|
||||
profile.id,
|
||||
model_id,
|
||||
).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Json(serde_json::json!({
|
||||
"status": status,
|
||||
"models": crate::similarity::MODELS,
|
||||
"profiles": profiles,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn similarity_clear(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
match crate::similarity::handle().clear().await {
|
||||
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Err(error) => Ok(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("embedding cleanup failed: {error:#}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn federation_sync(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1153,6 +1264,10 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||
federation_enabled: config.federation_enabled,
|
||||
federation_network_id: config.federation_network_id,
|
||||
federation_save_on_listen: config.federation_save_on_listen,
|
||||
similarity_enabled: config.similarity_enabled,
|
||||
similarity_model: config.similarity_model,
|
||||
similarity_profile: config.similarity_profile,
|
||||
similarity_workers: config.similarity_workers.to_string(),
|
||||
},
|
||||
sources: AdminSettingsSources {
|
||||
auth_password_enabled: sources.auth_password_enabled.code(),
|
||||
@@ -1178,6 +1293,10 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||
federation_enabled: sources.federation_enabled.code(),
|
||||
federation_network_id: sources.federation_network_id.code(),
|
||||
federation_save_on_listen: sources.federation_save_on_listen.code(),
|
||||
similarity_enabled: sources.similarity_enabled.code(),
|
||||
similarity_model: sources.similarity_model.code(),
|
||||
similarity_profile: sources.similarity_profile.code(),
|
||||
similarity_workers: sources.similarity_workers.code(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,6 +138,10 @@ pub struct ConfigSources {
|
||||
pub federation_enabled: ConfigSource,
|
||||
pub federation_network_id: ConfigSource,
|
||||
pub federation_save_on_listen: ConfigSource,
|
||||
pub similarity_enabled: ConfigSource,
|
||||
pub similarity_model: ConfigSource,
|
||||
pub similarity_profile: ConfigSource,
|
||||
pub similarity_workers: ConfigSource,
|
||||
}
|
||||
|
||||
impl Default for ConfigSources {
|
||||
@@ -168,6 +172,10 @@ impl Default for ConfigSources {
|
||||
federation_enabled: ConfigSource::Default,
|
||||
federation_network_id: ConfigSource::Default,
|
||||
federation_save_on_listen: ConfigSource::Default,
|
||||
similarity_enabled: ConfigSource::Default,
|
||||
similarity_model: ConfigSource::Default,
|
||||
similarity_profile: ConfigSource::Default,
|
||||
similarity_workers: ConfigSource::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,6 +293,14 @@ pub struct AppConfig {
|
||||
/// Whether a federated track requested for playback is imported into the
|
||||
/// shared local library. This is a server-wide administrator policy.
|
||||
pub federation_save_on_listen: bool,
|
||||
/// Whether local embedding calculation and similarity search are enabled.
|
||||
pub similarity_enabled: bool,
|
||||
/// Embedding model selected by the administrator.
|
||||
pub similarity_model: String,
|
||||
/// Audio preprocessing profile selected by the administrator.
|
||||
pub similarity_profile: String,
|
||||
/// Maximum number of concurrent CPU embedding workers.
|
||||
pub similarity_workers: u64,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -315,6 +331,12 @@ impl Default for AppConfig {
|
||||
federation_enabled: false,
|
||||
federation_network_id: String::new(),
|
||||
federation_save_on_listen: false,
|
||||
similarity_enabled: false,
|
||||
similarity_model: "discogs-effnet-bsdynamic-1".into(),
|
||||
similarity_profile: "furumi-full-track-v1".into(),
|
||||
similarity_workers: std::thread::available_parallelism()
|
||||
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
|
||||
.unwrap_or(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,6 +368,10 @@ impl_env_overrides!(
|
||||
federation_enabled,
|
||||
federation_network_id,
|
||||
federation_save_on_listen,
|
||||
similarity_enabled,
|
||||
similarity_model,
|
||||
similarity_profile,
|
||||
similarity_workers,
|
||||
);
|
||||
|
||||
impl AppConfig {
|
||||
@@ -476,6 +502,10 @@ impl AppConfig {
|
||||
apply_db_field!(federation_enabled);
|
||||
apply_db_field!(federation_network_id);
|
||||
apply_db_field!(federation_save_on_listen);
|
||||
apply_db_field!(similarity_enabled);
|
||||
apply_db_field!(similarity_model);
|
||||
apply_db_field!(similarity_profile);
|
||||
apply_db_field!(similarity_workers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,6 +525,13 @@ mod tests {
|
||||
let cfg = AppConfig::default();
|
||||
assert!(cfg.database_url.is_empty());
|
||||
assert_eq!(cfg.log_level, "info");
|
||||
assert!(!cfg.similarity_enabled);
|
||||
assert_eq!(cfg.similarity_model, crate::similarity::DEFAULT_MODEL_ID);
|
||||
assert_eq!(
|
||||
cfg.similarity_profile,
|
||||
crate::similarity::DEFAULT_PROFILE_ID
|
||||
);
|
||||
assert!((1..=4).contains(&cfg.similarity_workers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,8 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use music_dht::StreamAcceptor;
|
||||
use music_dht::capabilities::{
|
||||
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, read_message,
|
||||
write_message,
|
||||
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, SIMILARITY_ID,
|
||||
read_message, write_message,
|
||||
};
|
||||
|
||||
use super::serve::AUDIO_PROTOCOL_VERSION;
|
||||
@@ -16,6 +16,10 @@ fn local_manifest() -> CapabilityManifest {
|
||||
// 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) {
|
||||
@@ -61,6 +65,10 @@ mod tests {
|
||||
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)
|
||||
);
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +98,66 @@ pub struct SearchEvent {
|
||||
}
|
||||
|
||||
impl Federation {
|
||||
pub async fn prepare_similarity_tracks(
|
||||
&self,
|
||||
tracks: Vec<super::similarity::RemoteSimilarityTrack>,
|
||||
) -> Result<Vec<TrackDto>> {
|
||||
let pool = self.pool().await?;
|
||||
let mut prepared = Vec::new();
|
||||
for track in tracks {
|
||||
let Some(content_id) = track.content_id.as_deref().and_then(normalize_content_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let local = local_availability(&pool, &content_id).await?;
|
||||
// A local result is already present in the first result section.
|
||||
if local.is_some() {
|
||||
continue;
|
||||
}
|
||||
let owner = track.owner;
|
||||
let item_id = track.item_id;
|
||||
let dto = TrackDto {
|
||||
key: TrackKeyDto {
|
||||
content_id: content_id.clone(),
|
||||
},
|
||||
metadata: TrackMetadataDto {
|
||||
title: track.title,
|
||||
artists: artist_refs(&track.artist_names),
|
||||
featured_artists: artist_refs(&track.featured_artist_names),
|
||||
release: track.release_title.map(|title| ReleaseRefDto {
|
||||
key: ReleaseKeyDto {
|
||||
normalized_title: music_dht::normalize_name(&title),
|
||||
primary_artists: track
|
||||
.artist_names
|
||||
.iter()
|
||||
.map(|artist| music_dht::normalize_name(artist))
|
||||
.collect(),
|
||||
release_type: None,
|
||||
year: track.year,
|
||||
},
|
||||
local_id: None,
|
||||
title,
|
||||
}),
|
||||
year: track.year,
|
||||
duration_seconds: track.duration_seconds.map(|value| value as f64),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
cover_url: Some(format!(
|
||||
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
|
||||
)),
|
||||
},
|
||||
availability: TrackAvailabilityDto {
|
||||
state: "federated",
|
||||
local: None,
|
||||
federation: vec![FederationSourceDto { owner, item_id }],
|
||||
},
|
||||
};
|
||||
persist_track_ref(&pool, &dto).await?;
|
||||
prepared.push(dto);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
pub fn stream_artist_catalogs(
|
||||
self: &std::sync::Arc<Self>,
|
||||
name: String,
|
||||
|
||||
+35
-4
@@ -5,18 +5,21 @@
|
||||
//! releases, tracks — names and small metadata, never files) into the
|
||||
//! shared DHT and serves audio, track metadata, cover art and per-artist
|
||||
//! catalogs to other peers (TUI clients) over the same wire protocols the
|
||||
//! clients speak among themselves. Serve-only: the server does not search
|
||||
//! or download from other peers.
|
||||
//! clients speak among themselves. The web player also searches known peers
|
||||
//! for catalog metadata and, when enabled, compatible similarity embeddings;
|
||||
//! local playback and the local library remain independent of the network.
|
||||
//!
|
||||
//! Settings are the regular admin config entries (`federation_enabled`,
|
||||
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly — saving the settings
|
||||
//! starts, stops or re-joins the node without a server restart.
|
||||
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly —
|
||||
//! saving the settings starts, stops or re-joins the node without a server
|
||||
//! restart.
|
||||
|
||||
mod capabilities;
|
||||
pub mod client;
|
||||
pub mod devices;
|
||||
mod receive;
|
||||
mod serve;
|
||||
mod similarity;
|
||||
mod storage;
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
@@ -38,6 +41,7 @@ use crate::config::AppConfig;
|
||||
use storage::PostgresFederationStorage;
|
||||
|
||||
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
||||
pub use similarity::SIMILARITY_ALPN;
|
||||
|
||||
/// How often the published library is re-synchronized with the database.
|
||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||
@@ -124,6 +128,7 @@ struct TransportStatsState {
|
||||
audio_samples: u64,
|
||||
catalog_samples: u64,
|
||||
sync_samples: u64,
|
||||
similarity_samples: u64,
|
||||
last: VecDeque<TransportSample>,
|
||||
}
|
||||
|
||||
@@ -157,6 +162,7 @@ impl TransportStats {
|
||||
"audio" => state.audio_samples += 1,
|
||||
"catalog" => state.catalog_samples += 1,
|
||||
"device-sync" => state.sync_samples += 1,
|
||||
"similarity" => state.similarity_samples += 1,
|
||||
_ => {}
|
||||
}
|
||||
state.last.push_front(sample);
|
||||
@@ -177,6 +183,7 @@ impl TransportStats {
|
||||
"audio_samples": state.audio_samples,
|
||||
"catalog_samples": state.catalog_samples,
|
||||
"sync_samples": state.sync_samples,
|
||||
"similarity_samples": state.similarity_samples,
|
||||
"last_path": latest.map(|sample| sample.selected_path.clone()),
|
||||
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
|
||||
"last_peer": latest.map(|sample| sample.peer_id.clone()),
|
||||
@@ -382,6 +389,7 @@ impl Federation {
|
||||
.stream_protocol(AUDIO_ALPN)
|
||||
.stream_protocol(CATALOG_ALPN)
|
||||
.stream_protocol(devices::SYNC_ALPN)
|
||||
.stream_protocol(SIMILARITY_ALPN)
|
||||
.schema_independent_stream_protocol(CAPABILITIES_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
@@ -454,6 +462,15 @@ impl Federation {
|
||||
.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,
|
||||
crate::similarity::handle(),
|
||||
service.endpoint_id(),
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
@@ -466,6 +483,7 @@ impl Federation {
|
||||
device_task,
|
||||
device_sync_task,
|
||||
capabilities_task,
|
||||
similarity_task,
|
||||
],
|
||||
});
|
||||
self.set_error(None);
|
||||
@@ -827,6 +845,19 @@ impl Federation {
|
||||
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 = self.service().await?;
|
||||
similarity::search(service, query, limit, Arc::clone(&self.transport_stats)).await
|
||||
}
|
||||
|
||||
pub async fn fed_device_status(
|
||||
&self,
|
||||
user_id: i64,
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! 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::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
||||
|
||||
use crate::similarity::{Manager, QueryVector};
|
||||
|
||||
use super::TransportStats;
|
||||
|
||||
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||
|
||||
const MAX_QUERY_PEERS: usize = 16;
|
||||
const QUERY_CONCURRENCY: usize = 6;
|
||||
const QUERY_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>,
|
||||
query: QueryVector,
|
||||
limit: usize,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<Vec<RemoteSimilarityTrack>> {
|
||||
let own = service.endpoint_id();
|
||||
let mut peers = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
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(peer);
|
||||
}
|
||||
if peers.len() >= MAX_QUERY_PEERS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||
let request = Arc::new(SimilarityRequest::new(
|
||||
query.profile_id,
|
||||
query.vector,
|
||||
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||
)?);
|
||||
|
||||
let responses = stream::iter(peers.into_iter().map(|peer| {
|
||||
let service = Arc::clone(&service);
|
||||
let request = Arc::clone(&request);
|
||||
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::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for response in responses {
|
||||
match response {
|
||||
Ok(peer_hits) => hits.extend(peer_hits),
|
||||
Err(error) => tracing::debug!(%error, "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)
|
||||
}
|
||||
|
||||
async fn query_peer(
|
||||
service: Arc<MusicDhtService>,
|
||||
owner: EndpointId,
|
||||
request: &SimilarityRequest,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<
|
||||
Vec<(
|
||||
RemoteSimilarityTrack,
|
||||
f32,
|
||||
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
)>,
|
||||
> {
|
||||
let mut stream = 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_listened: "listened" , "прослушано";
|
||||
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
||||
player_search_similar_to: "Search similar to:" , "Поиск похожих на:";
|
||||
player_find_similar: "Find similar tracks" , "Найти похожие треки";
|
||||
player_similarity_failed: "Similarity search failed" , "Не удалось найти похожие треки";
|
||||
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
||||
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||
player_active_device: "Active device" , "Активный девайс";
|
||||
@@ -314,6 +317,9 @@ translations! {
|
||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||
player_rename: "Rename" , "Переименовать";
|
||||
player_close: "Close" , "Закрыть";
|
||||
player_interface_language: "Interface language" , "Язык интерфейса";
|
||||
player_language_description: "Choose the language used by the web player." , "Выберите язык интерфейса веб-плеера.";
|
||||
player_switch_language: "Русский" , "English";
|
||||
player_log_out: "Log out" , "Выйти";
|
||||
player_admin_panel: "Admin Panel" , "Админка";
|
||||
player_info: "Info" , "Информация";
|
||||
|
||||
@@ -13,6 +13,7 @@ mod music;
|
||||
mod oidc;
|
||||
mod player;
|
||||
mod scheduler;
|
||||
mod similarity;
|
||||
mod torrents;
|
||||
mod user;
|
||||
|
||||
@@ -567,6 +568,13 @@ impl Project for FuruProject {
|
||||
federation::handle().boot(&fed_config).await;
|
||||
});
|
||||
|
||||
// Embedding calculation is an independent, server-wide background
|
||||
// service. It remains useful locally when federation is disabled.
|
||||
let similarity_config = Arc::clone(&self.app_config);
|
||||
tokio::spawn(async move {
|
||||
similarity::handle().boot(&similarity_config).await;
|
||||
});
|
||||
|
||||
apps.register(cot::session::db::SessionApp::new());
|
||||
apps.register_with_views(
|
||||
FuruApp {
|
||||
|
||||
@@ -2476,6 +2476,71 @@ pub mod db_migrations {
|
||||
&[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()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0006CreateMediaFile,
|
||||
&M0007CreateArtist,
|
||||
@@ -2509,5 +2574,6 @@ pub mod db_migrations {
|
||||
&M0040CreateContentAddressedMusicRefs,
|
||||
&M0041CreateSyncedListenHistory,
|
||||
&M0042RepairLegacyListenQualification,
|
||||
&M0043CreateSimilarityEmbeddings,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use cot::router::method::{delete, get, post};
|
||||
use cot::router::{Route, Router};
|
||||
use cot::session::Session;
|
||||
use cot::{App, Body, Template};
|
||||
use serde::Serialize;
|
||||
use sqlx::Row as _;
|
||||
|
||||
use crate::auth;
|
||||
@@ -4314,6 +4315,118 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul
|
||||
.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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -9813,6 +9926,33 @@ impl App for PlayerApp {
|
||||
}),
|
||||
"player_search",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/similarity/{id}",
|
||||
get({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |auth_ctx: auth::AuthContext,
|
||||
session: Session,
|
||||
db: Database,
|
||||
path: Path<PathId>| {
|
||||
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(
|
||||
"/federation/search/events",
|
||||
get(
|
||||
|
||||
+1323
File diff suppressed because it is too large
Load Diff
+180
-2
@@ -2192,6 +2192,63 @@ tbody tr:hover {
|
||||
</div>
|
||||
</div>
|
||||
</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. When federation is also enabled, this instance sends anonymized query embeddings to peers and 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 class="settings-column settings-side">
|
||||
@@ -2323,7 +2380,7 @@ tbody tr:hover {
|
||||
</div>
|
||||
<div class="probe-row"><span>RTT</span><strong x-text="fedRtt(fedTransport().last_rtt_ms)"></strong></div>
|
||||
<div class="probe-row"><span>Path samples</span><strong x-text="`${fedTransport().direct_samples || 0} direct · ${fedTransport().relay_samples || 0} relay · ${fedTransport().custom_samples || 0} custom · ${fedTransport().unknown_samples || 0} unknown`"></strong></div>
|
||||
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
||||
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
||||
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
||||
</div>
|
||||
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
|
||||
@@ -2366,6 +2423,44 @@ tbody tr:hover {
|
||||
</div>
|
||||
</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">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
@@ -2936,7 +3031,11 @@ function adminV2() {
|
||||
agent_concurrency: '',
|
||||
federation_enabled: false,
|
||||
federation_network_id: '',
|
||||
federation_save_on_listen: false
|
||||
federation_save_on_listen: false,
|
||||
similarity_enabled: false,
|
||||
similarity_model: 'discogs-effnet-bsdynamic-1',
|
||||
similarity_profile: 'furumi-full-track-v1',
|
||||
similarity_workers: '1'
|
||||
},
|
||||
settingsProbe: { status: 'idle', ok: false },
|
||||
settingsProbeLoading: false,
|
||||
@@ -2944,6 +3043,8 @@ function adminV2() {
|
||||
federationLoading: false,
|
||||
federationTicket: '',
|
||||
fedConnectTicket: '',
|
||||
similarityStatus: { status: { phase: 'disabled' }, models: [], profiles: [] },
|
||||
similarityLoading: false,
|
||||
settingsSaving: false,
|
||||
routeReady: false,
|
||||
poller: null,
|
||||
@@ -3007,6 +3108,11 @@ function adminV2() {
|
||||
]);
|
||||
} else if (this.activeView === 'users') {
|
||||
await Promise.allSettled([this.loadUsers(false)]);
|
||||
} else if (this.activeView === 'settings') {
|
||||
await Promise.allSettled([
|
||||
this.loadFederation(false),
|
||||
this.loadSimilarity(false)
|
||||
]);
|
||||
} else {
|
||||
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
||||
}
|
||||
@@ -3069,6 +3175,10 @@ function adminV2() {
|
||||
} else if (this.activeView === 'settings') {
|
||||
if (updateRoute) this.setRoute('#settings');
|
||||
await this.loadSettings();
|
||||
await Promise.allSettled([
|
||||
this.loadFederation(false),
|
||||
this.loadSimilarity(false)
|
||||
]);
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
@@ -3216,6 +3326,21 @@ function adminV2() {
|
||||
|
||||
async saveSettings() {
|
||||
if (this.settingsSaving) return;
|
||||
const networkSimilarityWasEnabled = Boolean(
|
||||
this.settings.values?.similarity_enabled
|
||||
&& this.settings.values?.federation_enabled
|
||||
);
|
||||
const networkSimilarityWillBeEnabled = Boolean(
|
||||
this.settingsDraft.similarity_enabled
|
||||
&& this.settingsDraft.federation_enabled
|
||||
);
|
||||
if (!networkSimilarityWasEnabled && networkSimilarityWillBeEnabled) {
|
||||
const accepted = window.confirm(
|
||||
'Similarity searches send an anonymized embedding of the selected track to federation peers. '
|
||||
+ 'Peers do not receive a user identity, but the query can technically reveal what is being searched. Enable this for the instance?'
|
||||
);
|
||||
if (!accepted) return;
|
||||
}
|
||||
this.settingsSaving = true;
|
||||
try {
|
||||
await this.request(`${this.apiBase}/settings`, {
|
||||
@@ -3224,6 +3349,7 @@ function adminV2() {
|
||||
});
|
||||
await this.loadSettings(false);
|
||||
await this.loadFederation(false);
|
||||
await this.loadSimilarity(false);
|
||||
this.showToast('Settings saved');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
@@ -3238,6 +3364,7 @@ function adminV2() {
|
||||
this.setRoute('#settings');
|
||||
await this.loadSettings();
|
||||
await this.loadFederation(false);
|
||||
await this.loadSimilarity(false);
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
@@ -3255,6 +3382,57 @@ function adminV2() {
|
||||
}
|
||||
},
|
||||
|
||||
async loadSimilarity(showErrors = true) {
|
||||
this.similarityLoading = true;
|
||||
try {
|
||||
this.similarityStatus = await this.request(`${this.apiBase}/similarity`);
|
||||
} catch (error) {
|
||||
if (showErrors) this.showToast(error.message);
|
||||
} finally {
|
||||
this.similarityLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async clearSimilarityEmbeddings() {
|
||||
if (!window.confirm('Delete embeddings for every model and preprocessing profile? Audio files are not affected.')) return;
|
||||
this.similarityLoading = true;
|
||||
try {
|
||||
await this.request(`${this.apiBase}/similarity/clear`, {
|
||||
method: 'POST',
|
||||
body: '{}'
|
||||
});
|
||||
await this.loadSimilarity(false);
|
||||
this.showToast('All embeddings cleared');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.similarityLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
selectedSimilarityProfile() {
|
||||
return (this.similarityStatus.profiles || []).find(
|
||||
profile => profile.id === this.settingsDraft.similarity_profile
|
||||
) || null;
|
||||
},
|
||||
|
||||
similarityBadge() {
|
||||
const phase = this.similarityStatus.status?.phase || 'disabled';
|
||||
if (phase === 'ready') return 'ok';
|
||||
if (phase === 'error') return 'failed';
|
||||
if (['downloading', 'loading', 'processing'].includes(phase)) return 'running';
|
||||
return 'disabled';
|
||||
},
|
||||
|
||||
similarityProgress() {
|
||||
const status = this.similarityStatus.status || {};
|
||||
const total = Number(status.total_tracks || 0);
|
||||
const completed = Number(status.completed_tracks || 0);
|
||||
return total > 0 ? Math.min(100, Math.round(completed * 100 / total)) : 0;
|
||||
},
|
||||
|
||||
async fedSyncNow() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
|
||||
@@ -653,6 +653,17 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="user-settings-section">
|
||||
<div class="user-settings-section-head">
|
||||
<div>
|
||||
<h4>{{ t.player_interface_language }}</h4>
|
||||
<p>{{ t.player_language_description }}</p>
|
||||
</div>
|
||||
<button class="settings-secondary-btn"
|
||||
onclick="location.href='/set-lang?lang={% if t.lang.code() == "en" %}ru{% else %}en{% endif %}&next='+encodeURIComponent(location.pathname+location.search+location.hash)">{{ t.player_switch_language }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="user-settings-section">
|
||||
<div class="user-settings-section-head">
|
||||
<div>
|
||||
|
||||
@@ -44,6 +44,9 @@ const T = {
|
||||
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
||||
startRadio: "{{ t.player_start_radio }}",
|
||||
radioFailed: "{{ t.player_radio_failed }}",
|
||||
searchSimilarTo: "{{ t.player_search_similar_to }}",
|
||||
findSimilar: "{{ t.player_find_similar }}",
|
||||
similarityFailed: "{{ t.player_similarity_failed }}",
|
||||
share: "{{ t.player_share }}",
|
||||
shareTrack: "{{ t.player_share_track }}",
|
||||
shareQueue: "{{ t.player_share_queue }}",
|
||||
@@ -2503,6 +2506,8 @@ document.addEventListener('alpine:init', () => {
|
||||
searchQuery: '',
|
||||
searchResults: null,
|
||||
searchLoading: false,
|
||||
similaritySearchLabel: '',
|
||||
similaritySearchError: '',
|
||||
federationSearch: { loading: false, error: '', artists: [], releases: [], tracks: [] },
|
||||
artistFederation: { loading: false, error: '', releases: [], tracks: [] },
|
||||
federationPreparing: {},
|
||||
@@ -2594,6 +2599,8 @@ document.addEventListener('alpine:init', () => {
|
||||
this.searchQuery = q;
|
||||
this.search(q, options);
|
||||
}
|
||||
} else if (view === 'similar' && id) {
|
||||
this.searchSimilar(id, options);
|
||||
} else {
|
||||
this.goArtists(options);
|
||||
}
|
||||
@@ -3047,6 +3054,7 @@ document.addEventListener('alpine:init', () => {
|
||||
openTrackInfo(track) {
|
||||
Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track), [
|
||||
{ label: T.startRadio, run: () => this.startRadio('track', track?.id) },
|
||||
{ label: T.findSimilar, run: () => this.searchSimilar(track) },
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -3385,6 +3393,8 @@ document.addEventListener('alpine:init', () => {
|
||||
}
|
||||
this.view = 'search';
|
||||
this.searchLoading = true;
|
||||
this.similaritySearchLabel = '';
|
||||
this.similaritySearchError = '';
|
||||
this.startFederationSearch(q);
|
||||
try {
|
||||
const res = await fetch(`/api/player/search?q=${encodeURIComponent(q)}&limit=10`);
|
||||
@@ -3397,11 +3407,59 @@ document.addEventListener('alpine:init', () => {
|
||||
this._afterNavigation(options);
|
||||
},
|
||||
|
||||
async searchSimilar(trackOrId, options = {}) {
|
||||
const id = Number(typeof trackOrId === 'object' ? trackOrId?.id : trackOrId);
|
||||
if (!Number.isInteger(id) || id <= 0) return;
|
||||
const track = typeof trackOrId === 'object' ? trackOrId : null;
|
||||
const artist = (track?.artists || []).map(item => item.name).filter(Boolean).join(', ');
|
||||
const initialLabel = track
|
||||
? [track.title, artist].filter(Boolean).join(' — ')
|
||||
: '';
|
||||
this._beginNavigation(`#similar/${id}`, options);
|
||||
if (this.view !== 'search') this._previousView = this.view;
|
||||
this.stopFederationSearch();
|
||||
this.view = 'search';
|
||||
this.searchQuery = initialLabel;
|
||||
this.similaritySearchLabel = initialLabel;
|
||||
this.similaritySearchError = '';
|
||||
this.searchLoading = true;
|
||||
this.searchResults = null;
|
||||
this.federationSearch = { loading: true, error: '', artists: [], releases: [], tracks: [] };
|
||||
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() {
|
||||
this.stopFederationSearch();
|
||||
this.searchQuery = '';
|
||||
this.searchResults = null;
|
||||
this.searchLoading = false;
|
||||
this.similaritySearchLabel = '';
|
||||
this.similaritySearchError = '';
|
||||
if (this.view === 'search') {
|
||||
this.view = this._previousView || 'artists';
|
||||
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
||||
|
||||
@@ -379,12 +379,23 @@
|
||||
<!-- Search Results -->
|
||||
<template x-if="$store.library.view === 'search'">
|
||||
<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">
|
||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||
</template>
|
||||
<template x-if="!$store.library.searchLoading && $store.library.similaritySearchError">
|
||||
<div class="empty-state">
|
||||
<p x-text="$store.library.similaritySearchError"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
||||
<div>
|
||||
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
||||
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<p>{{ t.player_no_results }}</p>
|
||||
|
||||
@@ -2872,6 +2872,21 @@ button.user-stat:hover {
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
|
||||
Reference in New Issue
Block a user