Compare commits

..
8 Commits
Author SHA1 Message Date
Aleksandr Bogomiakov 6f337ee626 Add DHT routing for similarity search
Build and Publish / Build and Publish Docker Image (push) Successful in 3m30s
2026-08-10 19:54:40 +01:00
Aleksandr Bogomiakov 77cbb62d84 Added similarity search
Build and Publish / Build and Publish Docker Image (push) Successful in 3m29s
2026-08-10 01:44:40 +01:00
Ultradesu 05d68724f3 Fixed invite logic to be lightweight
Build and Publish / Build and Publish Docker Image (push) Successful in 4m13s
2026-07-29 14:21:32 +01:00
Ultradesu d71845509d Bump protocols. Added proto status
Build and Publish / Build and Publish Docker Image (push) Successful in 6m1s
2026-07-28 23:01:57 +01:00
Ultradesu 4efcfdc539 Bump protocols. Added proto status
Build and Publish / Build and Publish Docker Image (push) Successful in 5m25s
2026-07-28 22:46:43 +01:00
Ultradesu d61d7a6bac Bump protocols. Added proto status
Build and Publish / Build and Publish Docker Image (push) Successful in 5m13s
2026-07-28 22:20:33 +01:00
Ultradesu cad8b2280f Added favicon 2026-07-28 21:27:48 +01:00
Ultradesu 3a75c7d848 Fixed history DTO view
Build and Publish / Build and Publish Docker Image (push) Successful in 4m6s
2026-07-28 08:34:29 +01:00
26 changed files with 3839 additions and 49 deletions
+2
View File
@@ -3,3 +3,5 @@
/.claude /.claude
/media /media
/federation /federation
/similarity-models
/federation-cache
Generated
+618 -7
View File
@@ -159,6 +159,12 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index" 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
View File
@@ -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"
+7
View File
@@ -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
View File
@@ -0,0 +1,27 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1786106723,
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
+38
View File
@@ -0,0 +1,38 @@
{
description = "Furumusic development environment";
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
outputs = { nixpkgs, ... }:
let
supportedSystems = [
"x86_64-linux"
"aarch64-linux"
"x86_64-darwin"
"aarch64-darwin"
];
forEachSystem = function:
nixpkgs.lib.genAttrs supportedSystems (system:
function (import nixpkgs { inherit system; }));
in
{
devShells = forEachSystem (pkgs: {
default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
cargo
clippy
pkg-config
rustc
rustfmt
];
buildInputs = with pkgs; [
cacert
openssl
] ++ lib.optionals stdenv.isDarwin [ libiconv ];
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
};
});
};
}
+14
View File
@@ -422,6 +422,20 @@ impl App for AdminApp {
}), }),
"admin_v2_federation_status", "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
View File
@@ -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(),
}, },
} }
} }
+37
View File
@@ -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]
+80
View File
@@ -0,0 +1,80 @@
//! Informational publication of the protocol versions exposed by this peer.
use std::time::Duration;
use anyhow::Result;
use music_dht::StreamAcceptor;
use music_dht::capabilities::{
CAPABILITIES_PROTOCOL_VERSION, CapabilityManifest, CapabilityMessage, JAM_ID, SIMILARITY_ID,
read_message, write_message,
};
use super::serve::AUDIO_PROTOCOL_VERSION;
fn local_manifest() -> CapabilityManifest {
CapabilityManifest::frid("furumusic", env!("CARGO_PKG_VERSION"))
// The web server does not expose federation Jam yet.
.without_protocol(JAM_ID)
.with_protocol("audio", AUDIO_PROTOCOL_VERSION)
.with_protocol(
SIMILARITY_ID,
music_dht::similarity::SIMILARITY_PROTOCOL_VERSION,
)
}
pub async fn serve(mut acceptor: StreamAcceptor) {
while let Some(stream) = acceptor.accept().await {
tokio::spawn(async move {
if let Err(error) = serve_one(stream).await {
tracing::debug!("capability stream failed: {error:#}");
}
});
}
}
async fn serve_one(mut stream: music_dht::ByteStream) -> Result<()> {
let response = match read_message(&mut stream).await? {
CapabilityMessage::Get {
version: CAPABILITIES_PROTOCOL_VERSION,
} => CapabilityMessage::Manifest {
manifest: local_manifest(),
},
CapabilityMessage::Get { version } => CapabilityMessage::Error {
message: format!("unsupported capability protocol {version}"),
},
_ => CapabilityMessage::Error {
message: "expected capability request".to_string(),
},
};
write_message(&mut stream, &response).await?;
stream.send.finish()?;
let _ = tokio::time::timeout(Duration::from_secs(2), stream.send.stopped()).await;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn manifest_describes_only_supported_player_protocols() {
let manifest = local_manifest();
assert_eq!(manifest.application, "furumusic");
assert_eq!(
manifest.protocols.get("audio"),
Some(&AUDIO_PROTOCOL_VERSION)
);
assert!(!manifest.protocols.contains_key(JAM_ID));
assert_eq!(
manifest.protocols.get(SIMILARITY_ID),
Some(&music_dht::similarity::SIMILARITY_PROTOCOL_VERSION)
);
assert_eq!(
manifest
.protocols
.get(music_dht::capabilities::SIMILARITY_DHT_ID),
Some(&music_dht::similarity_lsh::SIMILARITY_DHT_PROTOCOL_VERSION)
);
manifest.validate().unwrap();
}
}
+60
View File
@@ -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,
+41 -6
View File
@@ -32,6 +32,9 @@ const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
const DEVICE_SYNC_INTERVAL: Duration = Duration::from_secs(2); const 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
View File
@@ -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,
+2
View File
@@ -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;
+386
View File
@@ -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);
}
}
+6
View File
@@ -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" , "Информация";
+8
View File
@@ -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 {
+95
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+181 -2
View File
@@ -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 {
+1
View File
@@ -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>
+11
View File
@@ -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>
+76 -4
View File
@@ -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) {
+31 -4
View File
@@ -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)">
+51
View File
@@ -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;