Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69883af8bd | ||
|
|
6f337ee626 | ||
|
|
77cbb62d84 | ||
|
|
05d68724f3 | ||
|
|
d71845509d | ||
|
|
4efcfdc539 | ||
|
|
d61d7a6bac | ||
|
|
cad8b2280f | ||
|
|
3a75c7d848 | ||
|
|
dc1fac1a94 | ||
|
|
122214a896 | ||
|
|
134f89fc9d | ||
|
|
232c171aac | ||
|
|
845df4e031 | ||
|
|
b0d8929b4c | ||
|
|
291265be7d | ||
|
|
624e75839d | ||
|
|
fd766cda24 | ||
|
|
49000f716c | ||
|
|
ee4990e2f1 | ||
|
|
e64b61c167 |
@@ -3,3 +3,5 @@
|
||||
/.claude
|
||||
/media
|
||||
/federation
|
||||
/similarity-models
|
||||
/federation-cache
|
||||
|
||||
Generated
+633
-29
@@ -159,6 +159,12 @@ version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "anymap3"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
@@ -488,6 +494,24 @@ dependencies = [
|
||||
"virtue",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
|
||||
dependencies = [
|
||||
"bit-vec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-vec"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -806,15 +830,6 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789"
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
@@ -1078,6 +1093,16 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-deque"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb"
|
||||
dependencies = [
|
||||
"crossbeam-epoch",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-epoch"
|
||||
version = "0.9.20"
|
||||
@@ -1102,6 +1127,12 @@ version = "0.8.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
|
||||
|
||||
[[package]]
|
||||
name = "crunchy"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-bigint"
|
||||
version = "0.5.5"
|
||||
@@ -1293,6 +1324,12 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dasp_sample"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -1350,6 +1387,17 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive-new"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2cdc8d50f426189eef89dac62fabfa0abb27d5cc008f25bf4156a0203325becc"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
@@ -1502,12 +1550,30 @@ version = "0.15.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "2.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-clone"
|
||||
version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-eq"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c2d035d21af5cde1a6f5c7b444a5bf963520a9f142e5d06931178433d7d5388"
|
||||
|
||||
[[package]]
|
||||
name = "dyn-hash"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fdab65db9274e0168143841eb8f864a0a21f8b1b8d2ba6812bbe6024346e99e"
|
||||
|
||||
[[package]]
|
||||
name = "ecdsa"
|
||||
version = "0.16.9"
|
||||
@@ -1575,9 +1641,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
|
||||
checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -1650,6 +1716,17 @@ version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||
|
||||
[[package]]
|
||||
name = "erased-serde"
|
||||
version = "0.4.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "errno"
|
||||
version = "0.3.14"
|
||||
@@ -1673,11 +1750,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -1717,8 +1793,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "federation-net"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e690b370c505d153bef214b21a8f2aa55d667367ac1e16bde8bc0de88963c2"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
@@ -1757,6 +1834,16 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1773,6 +1860,12 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "float-ord"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d"
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.11.1"
|
||||
@@ -1845,16 +1938,19 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.8.0"
|
||||
version = "0.10.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-stream",
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"cot",
|
||||
"croner",
|
||||
"encoding_rs",
|
||||
"futures-util",
|
||||
"id3",
|
||||
"image",
|
||||
"librqbit",
|
||||
@@ -1863,6 +1959,8 @@ dependencies = [
|
||||
"openidconnect",
|
||||
"postcard",
|
||||
"reqwest 0.12.28",
|
||||
"rodio",
|
||||
"rustfft",
|
||||
"schemars 0.9.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -1874,6 +1972,7 @@ dependencies = [
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"tract-onnx",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
@@ -2198,6 +2297,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"num-traits",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hash32"
|
||||
version = "0.2.1"
|
||||
@@ -2243,6 +2354,8 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash 0.2.0",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2806,6 +2919,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inventory"
|
||||
version = "0.3.24"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipconfig"
|
||||
version = "0.3.4"
|
||||
@@ -3498,6 +3620,12 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "maplit"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -3513,6 +3641,16 @@ version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
|
||||
|
||||
[[package]]
|
||||
name = "matrixmultiply"
|
||||
version = "0.3.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -3538,6 +3676,12 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memo-map"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3554,6 +3698,16 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minijinja"
|
||||
version = "2.23.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42d74234349a775546a83af0f0c0c0e3a73227dee4950a542cda81a47240b3e6"
|
||||
dependencies = [
|
||||
"memo-map",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3621,8 +3775,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "music-dht"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c5b429b90a8f1b0980b3a35a6fa5445d7a275c737eb04db18db4d7f14c81478"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
@@ -3710,6 +3865,21 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.17.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
|
||||
dependencies = [
|
||||
"matrixmultiply",
|
||||
"num-complex 0.4.6",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"portable-atomic",
|
||||
"portable-atomic-util",
|
||||
"rawpointer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndk-context"
|
||||
version = "0.1.1"
|
||||
@@ -3766,12 +3936,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "netlink-proto"
|
||||
version = "0.12.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128"
|
||||
checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"log",
|
||||
"netlink-packet-core",
|
||||
"netlink-sys",
|
||||
@@ -3840,6 +4011,24 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom-language"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29"
|
||||
dependencies = [
|
||||
"nom",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nonzero_ext"
|
||||
version = "0.3.0"
|
||||
@@ -3923,10 +4112,20 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36"
|
||||
dependencies = [
|
||||
"num-complex",
|
||||
"num-complex 0.2.4",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-rational 0.2.4",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
@@ -3956,6 +4155,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -4003,6 +4211,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -4357,6 +4576,12 @@ version = "1.0.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
|
||||
|
||||
[[package]]
|
||||
name = "pastey"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "0.7.0"
|
||||
@@ -4567,6 +4792,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic-util"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618"
|
||||
dependencies = [
|
||||
"portable-atomic",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portmapper"
|
||||
version = "0.19.1"
|
||||
@@ -4656,6 +4890,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primal-check"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "primeorder"
|
||||
version = "0.13.6"
|
||||
@@ -4683,6 +4926,29 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"prost-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prost-derive"
|
||||
version = "0.14.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.30"
|
||||
@@ -4888,6 +5154,16 @@ version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_distr"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"rand 0.10.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
@@ -4906,6 +5182,32 @@ dependencies = [
|
||||
"bitflags 2.13.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rawpointer"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
|
||||
dependencies = [
|
||||
"either",
|
||||
"rayon-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rayon-core"
|
||||
version = "1.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
|
||||
dependencies = [
|
||||
"crossbeam-deque",
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -5104,6 +5406,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rodio"
|
||||
version = "0.22.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb"
|
||||
dependencies = [
|
||||
"dasp_sample",
|
||||
"num-rational 0.4.2",
|
||||
"symphonia",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rsa"
|
||||
version = "0.9.10"
|
||||
@@ -5159,6 +5473,20 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustfft"
|
||||
version = "6.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
|
||||
dependencies = [
|
||||
"num-complex 0.4.6",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"primal-check",
|
||||
"strength_reduce",
|
||||
"transpose",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.4"
|
||||
@@ -5259,6 +5587,19 @@ version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"libc",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@@ -5268,6 +5609,15 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scan_fmt"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248"
|
||||
dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
@@ -5293,9 +5643,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
|
||||
checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
|
||||
dependencies = [
|
||||
"dyn-clone",
|
||||
"ref-cast",
|
||||
@@ -5556,7 +5906,7 @@ dependencies = [
|
||||
"indexmap 1.9.3",
|
||||
"indexmap 2.14.0",
|
||||
"schemars 0.9.0",
|
||||
"schemars 1.2.1",
|
||||
"schemars 1.2.2",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"serde_with_macros",
|
||||
@@ -6001,6 +6351,22 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strength_reduce"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
|
||||
|
||||
[[package]]
|
||||
name = "string-interner"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad3df9b59e2eded8d825c7c4363ad339a20fb6bc0b9a4778560f518f59910b15"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "stringprep"
|
||||
version = "0.1.5"
|
||||
@@ -6334,6 +6700,17 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.27.0"
|
||||
@@ -6613,9 +6990,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
@@ -6793,12 +7170,229 @@ dependencies = [
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-core"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "608e176a669d5da02cccc92bbfe5ee4e57686ed8841022608a9eba014d3b7886"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"anymap3",
|
||||
"bit-set",
|
||||
"derive-new",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"erased-serde",
|
||||
"inventory",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"maplit",
|
||||
"ndarray",
|
||||
"num-complex 0.4.6",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"rustfft",
|
||||
"serde",
|
||||
"smallvec",
|
||||
"tract-data",
|
||||
"tract-linalg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-data"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "870236dd45aaeb1381023cb709a67ff14ece608ee0b37f99aa166d166db9b0d0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"dyn-hash",
|
||||
"half",
|
||||
"inventory",
|
||||
"itertools 0.14.0",
|
||||
"lazy_static",
|
||||
"libm",
|
||||
"maplit",
|
||||
"ndarray",
|
||||
"nom",
|
||||
"nom-language",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"parking_lot",
|
||||
"scan_fmt",
|
||||
"smallvec",
|
||||
"string-interner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-extra"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "effcae1ebfce133e8bf6c79ad31298cbee0a9eceb3e4642fe1d14cb54cca78e6"
|
||||
dependencies = [
|
||||
"tract-nnef",
|
||||
"tract-pulse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-hir"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28783b2bb583177685f65016a866b01eb3d383bce4fa7ae1b9692b325b64449d"
|
||||
dependencies = [
|
||||
"derive-new",
|
||||
"log",
|
||||
"tract-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-linalg"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3e01491f7360806ef061af4c016a2d0a801d586768896394a0b8a7d6872c2b0"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"cc",
|
||||
"derive-new",
|
||||
"downcast-rs",
|
||||
"dyn-clone",
|
||||
"dyn-eq",
|
||||
"dyn-hash",
|
||||
"half",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"minijinja",
|
||||
"num-traits",
|
||||
"pastey",
|
||||
"scan_fmt",
|
||||
"tract-data",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-nnef"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00417fabf01aeea7bc56107367862e5bb8da18c9ad850c9061d3823700479ecc"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"erased-serde",
|
||||
"flate2",
|
||||
"log",
|
||||
"minijinja",
|
||||
"nom",
|
||||
"nom-language",
|
||||
"safetensors",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"simd-adler32",
|
||||
"tar",
|
||||
"tract-core",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-onnx"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3215dd27bddd2a041a20fee750013b400135186d3485501c9274c755b19ceb0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"derive-new",
|
||||
"dyn-eq",
|
||||
"log",
|
||||
"memmap2",
|
||||
"num-integer",
|
||||
"prost",
|
||||
"smallvec",
|
||||
"tract-extra",
|
||||
"tract-hir",
|
||||
"tract-nnef",
|
||||
"tract-onnx-opl",
|
||||
"tract-transformers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-onnx-opl"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44f549ad3f245c1c00ce710c66cc0e086be0b637f35934d37b655ff175e1ab3e"
|
||||
dependencies = [
|
||||
"dyn-eq",
|
||||
"getrandom 0.4.3",
|
||||
"log",
|
||||
"rand 0.10.2",
|
||||
"rand_distr",
|
||||
"rustfft",
|
||||
"tract-extra",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-pulse"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a164e22e96ab9b5c90458fa270700570d87963e3dec9ccaac1ab18d9fa763ce2"
|
||||
dependencies = [
|
||||
"downcast-rs",
|
||||
"dyn-eq",
|
||||
"erased-serde",
|
||||
"lazy_static",
|
||||
"log",
|
||||
"serde",
|
||||
"tract-pulse-opl",
|
||||
"tract-transformers",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-pulse-opl"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65418f9e93e0af0d567f4f2f1bf2309b53930fa2543c635a69fda10c87a04987"
|
||||
dependencies = [
|
||||
"downcast-rs",
|
||||
"dyn-eq",
|
||||
"lazy_static",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tract-transformers"
|
||||
version = "0.23.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8471ebf7f52d226552283d9130539595540c28ed41b8e032df3830507d3ccdd5"
|
||||
dependencies = [
|
||||
"float-ord",
|
||||
"rayon",
|
||||
"tract-nnef",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transpose"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"strength_reduce",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||
|
||||
[[package]]
|
||||
name = "typeid"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.1"
|
||||
@@ -7564,6 +8158,16 @@ dependencies = [
|
||||
"tap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xml-rs"
|
||||
version = "0.8.28"
|
||||
|
||||
+9
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.8.1"
|
||||
version = "0.10.2"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
@@ -12,8 +12,10 @@ cot = { version = "0.6.0", default-features = false, features = ["postgres", "js
|
||||
schemars = { version = "0.9", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
openidconnect = "4.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream"] }
|
||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||
async-stream = "0.3"
|
||||
bytes = "1"
|
||||
tower = "0.5"
|
||||
base64 = "0.22"
|
||||
blake3 = "1"
|
||||
@@ -29,6 +31,10 @@ md-5 = "0.10"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"] }
|
||||
anyhow = "1.0"
|
||||
futures-util = "0.3"
|
||||
rodio = { version = "0.22.2", default-features = false, features = ["mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac", "symphonia-adpcm", "symphonia-aiff", "symphonia-mkv", "symphonia-pcm"] }
|
||||
rustfft = "6.4.1"
|
||||
tract-onnx = "0.23.4"
|
||||
tokio-cron-scheduler = "0.15"
|
||||
croner = "3"
|
||||
async-trait = "0.1"
|
||||
@@ -37,4 +43,4 @@ uuid = "1"
|
||||
librqbit = { version = "8.1.1", features = ["disable-upload"] }
|
||||
# P2P federation: publishes the library into a shared DHT and serves audio /
|
||||
# catalogs to furumi peers (TUI clients) over the frid stack.
|
||||
music-dht = { git = "https://gt.hexor.cy/ab/frid.git" }
|
||||
music-dht = "0.4.0"
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 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.
|
||||
|
||||
Built with Rust ([cot](https://cot.rs) framework).
|
||||
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1786106723,
|
||||
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
description = "Furumusic development environment";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { nixpkgs, ... }:
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forEachSystem = function:
|
||||
nixpkgs.lib.genAttrs supportedSystems (system:
|
||||
function (import nixpkgs { inherit system; }));
|
||||
in
|
||||
{
|
||||
devShells = forEachSystem (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
cargo
|
||||
clippy
|
||||
pkg-config
|
||||
rustc
|
||||
rustfmt
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
cacert
|
||||
openssl
|
||||
] ++ lib.optionals stdenv.isDarwin [ libiconv ];
|
||||
|
||||
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -422,6 +422,20 @@ impl App for AdminApp {
|
||||
}),
|
||||
"admin_v2_federation_status",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/similarity",
|
||||
get(move |session: Session, db: Database| async move {
|
||||
v2::similarity_status(session, db).await
|
||||
}),
|
||||
"admin_v2_similarity_status",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/similarity/clear",
|
||||
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||
v2::similarity_clear(session, db).await
|
||||
}),
|
||||
"admin_v2_similarity_clear",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/federation/sync",
|
||||
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||
|
||||
+130
@@ -452,6 +452,16 @@ struct AdminSettingsValues {
|
||||
federation_enabled: bool,
|
||||
#[serde(default)]
|
||||
federation_network_id: String,
|
||||
#[serde(default)]
|
||||
federation_save_on_listen: bool,
|
||||
#[serde(default)]
|
||||
similarity_enabled: bool,
|
||||
#[serde(default = "default_similarity_model")]
|
||||
similarity_model: String,
|
||||
#[serde(default = "default_similarity_profile")]
|
||||
similarity_profile: String,
|
||||
#[serde(default = "default_similarity_workers")]
|
||||
similarity_workers: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
@@ -478,6 +488,11 @@ struct AdminSettingsSources {
|
||||
agent_concurrency: &'static str,
|
||||
federation_enabled: &'static str,
|
||||
federation_network_id: &'static str,
|
||||
federation_save_on_listen: &'static str,
|
||||
similarity_enabled: &'static str,
|
||||
similarity_model: &'static str,
|
||||
similarity_profile: &'static str,
|
||||
similarity_workers: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -506,6 +521,28 @@ pub(super) struct UpdateSettingsRequest {
|
||||
federation_enabled: bool,
|
||||
#[serde(default)]
|
||||
federation_network_id: String,
|
||||
#[serde(default)]
|
||||
federation_save_on_listen: bool,
|
||||
#[serde(default)]
|
||||
similarity_enabled: bool,
|
||||
#[serde(default = "default_similarity_model")]
|
||||
similarity_model: String,
|
||||
#[serde(default = "default_similarity_profile")]
|
||||
similarity_profile: String,
|
||||
#[serde(default = "default_similarity_workers")]
|
||||
similarity_workers: String,
|
||||
}
|
||||
|
||||
fn default_similarity_model() -> String {
|
||||
crate::similarity::DEFAULT_MODEL_ID.to_owned()
|
||||
}
|
||||
|
||||
fn default_similarity_profile() -> String {
|
||||
crate::similarity::DEFAULT_PROFILE_ID.to_owned()
|
||||
}
|
||||
|
||||
fn default_similarity_workers() -> String {
|
||||
"1".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -943,6 +980,29 @@ pub async fn update_settings(
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let similarity_model = body.similarity_model.trim();
|
||||
if crate::similarity::model_by_id(similarity_model).is_none() {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unknown similarity model",
|
||||
));
|
||||
}
|
||||
let similarity_profile = body.similarity_profile.trim();
|
||||
if crate::similarity::profile_by_id(similarity_profile).is_none() {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"unknown similarity preprocessing profile",
|
||||
));
|
||||
}
|
||||
let similarity_workers = match body.similarity_workers.trim().parse::<u64>() {
|
||||
Ok(workers @ 1..=16) => workers,
|
||||
_ => {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"similarity workers must be an integer from 1 to 16",
|
||||
));
|
||||
}
|
||||
};
|
||||
let fields = [
|
||||
(
|
||||
"auth_password_enabled",
|
||||
@@ -993,6 +1053,14 @@ pub async fn update_settings(
|
||||
"federation_network_id",
|
||||
body.federation_network_id.trim().to_string(),
|
||||
),
|
||||
(
|
||||
"federation_save_on_listen",
|
||||
body.federation_save_on_listen.to_string(),
|
||||
),
|
||||
("similarity_enabled", body.similarity_enabled.to_string()),
|
||||
("similarity_model", similarity_model.to_string()),
|
||||
("similarity_profile", similarity_profile.to_string()),
|
||||
("similarity_workers", similarity_workers.to_string()),
|
||||
];
|
||||
for (key, value) in fields {
|
||||
let mut entry = ConfigEntry::new(key.to_string(), value);
|
||||
@@ -1005,6 +1073,7 @@ pub async fn update_settings(
|
||||
// the freshly saved settings — no server restart involved.
|
||||
let (fresh, _) = AppConfig::load_with_db(&db).await;
|
||||
tokio::spawn(async move {
|
||||
crate::similarity::handle().apply(&fresh);
|
||||
crate::federation::handle().apply(&fresh).await;
|
||||
});
|
||||
Json(serde_json::json!({ "ok": true })).into_response()
|
||||
@@ -1024,6 +1093,57 @@ pub async fn federation_status(
|
||||
Json(crate::federation::handle().status().await).into_response()
|
||||
}
|
||||
|
||||
pub async fn similarity_status(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
let manager = crate::similarity::handle();
|
||||
let status = manager.status();
|
||||
let model_id = if status.model.is_empty() {
|
||||
crate::similarity::DEFAULT_MODEL_ID
|
||||
} else {
|
||||
&status.model
|
||||
};
|
||||
let profiles = crate::similarity::PROFILES
|
||||
.iter()
|
||||
.map(|profile| {
|
||||
serde_json::json!({
|
||||
"id": profile.id,
|
||||
"title": profile.title,
|
||||
"details": crate::similarity::profile_details(
|
||||
profile.id,
|
||||
model_id,
|
||||
).unwrap_or_default(),
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
Json(serde_json::json!({
|
||||
"status": status,
|
||||
"models": crate::similarity::MODELS,
|
||||
"profiles": profiles,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
pub async fn similarity_clear(
|
||||
session: Session,
|
||||
db: Database,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
match crate::similarity::handle().clear().await {
|
||||
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
|
||||
Err(error) => Ok(json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&format!("embedding cleanup failed: {error:#}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn federation_sync(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1143,6 +1263,11 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||
agent_concurrency: config.agent_concurrency.to_string(),
|
||||
federation_enabled: config.federation_enabled,
|
||||
federation_network_id: config.federation_network_id,
|
||||
federation_save_on_listen: config.federation_save_on_listen,
|
||||
similarity_enabled: config.similarity_enabled,
|
||||
similarity_model: config.similarity_model,
|
||||
similarity_profile: config.similarity_profile,
|
||||
similarity_workers: config.similarity_workers.to_string(),
|
||||
},
|
||||
sources: AdminSettingsSources {
|
||||
auth_password_enabled: sources.auth_password_enabled.code(),
|
||||
@@ -1167,6 +1292,11 @@ fn settings_dto(config: AppConfig, sources: ConfigSources) -> AdminSettingsDto {
|
||||
agent_concurrency: sources.agent_concurrency.code(),
|
||||
federation_enabled: sources.federation_enabled.code(),
|
||||
federation_network_id: sources.federation_network_id.code(),
|
||||
federation_save_on_listen: sources.federation_save_on_listen.code(),
|
||||
similarity_enabled: sources.similarity_enabled.code(),
|
||||
similarity_model: sources.similarity_model.code(),
|
||||
similarity_profile: sources.similarity_profile.code(),
|
||||
similarity_workers: sources.similarity_workers.code(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +137,11 @@ pub struct ConfigSources {
|
||||
pub lastfm_shared_secret: ConfigSource,
|
||||
pub federation_enabled: ConfigSource,
|
||||
pub federation_network_id: ConfigSource,
|
||||
pub federation_save_on_listen: ConfigSource,
|
||||
pub similarity_enabled: ConfigSource,
|
||||
pub similarity_model: ConfigSource,
|
||||
pub similarity_profile: ConfigSource,
|
||||
pub similarity_workers: ConfigSource,
|
||||
}
|
||||
|
||||
impl Default for ConfigSources {
|
||||
@@ -166,6 +171,11 @@ impl Default for ConfigSources {
|
||||
lastfm_shared_secret: ConfigSource::Default,
|
||||
federation_enabled: ConfigSource::Default,
|
||||
federation_network_id: ConfigSource::Default,
|
||||
federation_save_on_listen: ConfigSource::Default,
|
||||
similarity_enabled: ConfigSource::Default,
|
||||
similarity_model: ConfigSource::Default,
|
||||
similarity_profile: ConfigSource::Default,
|
||||
similarity_workers: ConfigSource::Default,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,6 +290,17 @@ pub struct AppConfig {
|
||||
/// Federation network id — the shared secret every peer of the network
|
||||
/// uses to find the others.
|
||||
pub federation_network_id: String,
|
||||
/// Whether a federated track requested for playback is imported into the
|
||||
/// shared local library. This is a server-wide administrator policy.
|
||||
pub federation_save_on_listen: bool,
|
||||
/// Whether local embedding calculation and similarity search are enabled.
|
||||
pub similarity_enabled: bool,
|
||||
/// Embedding model selected by the administrator.
|
||||
pub similarity_model: String,
|
||||
/// Audio preprocessing profile selected by the administrator.
|
||||
pub similarity_profile: String,
|
||||
/// Maximum number of concurrent CPU embedding workers.
|
||||
pub similarity_workers: u64,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -309,6 +330,13 @@ impl Default for AppConfig {
|
||||
lastfm_shared_secret: String::new(),
|
||||
federation_enabled: false,
|
||||
federation_network_id: String::new(),
|
||||
federation_save_on_listen: false,
|
||||
similarity_enabled: false,
|
||||
similarity_model: "discogs-effnet-bsdynamic-1".into(),
|
||||
similarity_profile: "furumi-full-track-v1".into(),
|
||||
similarity_workers: std::thread::available_parallelism()
|
||||
.map(|count| (count.get() / 2).clamp(1, 4) as u64)
|
||||
.unwrap_or(1),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +367,11 @@ impl_env_overrides!(
|
||||
lastfm_shared_secret,
|
||||
federation_enabled,
|
||||
federation_network_id,
|
||||
federation_save_on_listen,
|
||||
similarity_enabled,
|
||||
similarity_model,
|
||||
similarity_profile,
|
||||
similarity_workers,
|
||||
);
|
||||
|
||||
impl AppConfig {
|
||||
@@ -468,6 +501,11 @@ impl AppConfig {
|
||||
apply_db_field!(lastfm_shared_secret);
|
||||
apply_db_field!(federation_enabled);
|
||||
apply_db_field!(federation_network_id);
|
||||
apply_db_field!(federation_save_on_listen);
|
||||
apply_db_field!(similarity_enabled);
|
||||
apply_db_field!(similarity_model);
|
||||
apply_db_field!(similarity_profile);
|
||||
apply_db_field!(similarity_workers);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,6 +525,13 @@ mod tests {
|
||||
let cfg = AppConfig::default();
|
||||
assert!(cfg.database_url.is_empty());
|
||||
assert_eq!(cfg.log_level, "info");
|
||||
assert!(!cfg.similarity_enabled);
|
||||
assert_eq!(cfg.similarity_model, crate::similarity::DEFAULT_MODEL_ID);
|
||||
assert_eq!(
|
||||
cfg.similarity_profile,
|
||||
crate::similarity::DEFAULT_PROFILE_ID
|
||||
);
|
||||
assert!((1..=4).contains(&cfg.similarity_workers));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
//! Receiving side of music federation.
|
||||
//!
|
||||
//! User-facing identity is content-addressed. An `(owner, item_id)` pair is
|
||||
//! only a source locator and several locators may resolve the same track.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::{ItemKind, LibraryItem, normalize_content_id};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::Row as _;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use super::{Federation, now_iso};
|
||||
|
||||
const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TrackKeyDto {
|
||||
pub content_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ArtistKeyDto {
|
||||
pub normalized_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ArtistRefDto {
|
||||
pub key: ArtistKeyDto,
|
||||
pub name: String,
|
||||
pub local_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ReleaseKeyDto {
|
||||
pub normalized_title: String,
|
||||
pub primary_artists: Vec<String>,
|
||||
pub release_type: Option<String>,
|
||||
pub year: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ReleaseRefDto {
|
||||
pub key: ReleaseKeyDto,
|
||||
pub local_id: Option<i64>,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct FederationSourceDto {
|
||||
pub owner: String,
|
||||
pub item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LocalAvailabilityDto {
|
||||
pub track_id: i64,
|
||||
pub stream_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TrackMetadataDto {
|
||||
pub title: String,
|
||||
pub artists: Vec<ArtistRefDto>,
|
||||
pub featured_artists: Vec<ArtistRefDto>,
|
||||
pub release: Option<ReleaseRefDto>,
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: Option<f64>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub cover_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TrackAvailabilityDto {
|
||||
pub state: &'static str,
|
||||
pub local: Option<LocalAvailabilityDto>,
|
||||
pub federation: Vec<FederationSourceDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TrackDto {
|
||||
pub key: TrackKeyDto,
|
||||
pub metadata: TrackMetadataDto,
|
||||
pub availability: TrackAvailabilityDto,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub similarity_score: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SearchEvent {
|
||||
pub search_id: String,
|
||||
pub sequence: u64,
|
||||
pub kind: &'static str,
|
||||
pub peer: Option<String>,
|
||||
pub entity_key: Value,
|
||||
pub entity: Value,
|
||||
}
|
||||
|
||||
impl Federation {
|
||||
pub async fn prepare_similarity_tracks(
|
||||
&self,
|
||||
tracks: Vec<super::similarity::RemoteSimilarityTrack>,
|
||||
) -> Result<Vec<TrackDto>> {
|
||||
let pool = self.pool().await?;
|
||||
let mut prepared = Vec::new();
|
||||
for track in tracks {
|
||||
let Some(content_id) = track.content_id.as_deref().and_then(normalize_content_id)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let local = local_availability(&pool, &content_id).await?;
|
||||
// A local result is already present in the first result section.
|
||||
if local.is_some() {
|
||||
continue;
|
||||
}
|
||||
let owner = track.owner;
|
||||
let item_id = track.item_id;
|
||||
let dto = TrackDto {
|
||||
key: TrackKeyDto {
|
||||
content_id: content_id.clone(),
|
||||
},
|
||||
metadata: TrackMetadataDto {
|
||||
title: track.title,
|
||||
artists: artist_refs(&track.artist_names),
|
||||
featured_artists: artist_refs(&track.featured_artist_names),
|
||||
release: track.release_title.map(|title| ReleaseRefDto {
|
||||
key: ReleaseKeyDto {
|
||||
normalized_title: music_dht::normalize_name(&title),
|
||||
primary_artists: track
|
||||
.artist_names
|
||||
.iter()
|
||||
.map(|artist| music_dht::normalize_name(artist))
|
||||
.collect(),
|
||||
release_type: None,
|
||||
year: track.year,
|
||||
},
|
||||
local_id: None,
|
||||
title,
|
||||
}),
|
||||
year: track.year,
|
||||
duration_seconds: track.duration_seconds.map(|value| value as f64),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
cover_url: Some(format!(
|
||||
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
|
||||
)),
|
||||
},
|
||||
availability: TrackAvailabilityDto {
|
||||
state: "federated",
|
||||
local: None,
|
||||
federation: vec![FederationSourceDto { owner, item_id }],
|
||||
},
|
||||
similarity_score: Some(track.similarity_score),
|
||||
};
|
||||
persist_track_ref(&pool, &dto).await?;
|
||||
prepared.push(dto);
|
||||
}
|
||||
Ok(prepared)
|
||||
}
|
||||
|
||||
pub fn stream_artist_catalogs(
|
||||
self: &std::sync::Arc<Self>,
|
||||
name: String,
|
||||
) -> tokio::sync::mpsc::UnboundedReceiver<Result<(String, music_dht::catalog::CatalogArtist)>>
|
||||
{
|
||||
let (sender, receiver) = tokio::sync::mpsc::unbounded_channel();
|
||||
let federation = std::sync::Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let result = async {
|
||||
let service = federation.service().await?;
|
||||
let normalized = music_dht::normalize_name(&name);
|
||||
let outcome = service
|
||||
.search_network(&name)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("federated artist search failed: {err}"))?;
|
||||
let owners: std::collections::HashSet<_> = outcome
|
||||
.network_results
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
(item.kind == ItemKind::Artist && item.normalized_name == normalized)
|
||||
|| item
|
||||
.artist_names
|
||||
.iter()
|
||||
.chain(&item.featured_artist_names)
|
||||
.any(|artist| music_dht::normalize_name(artist) == normalized)
|
||||
})
|
||||
.map(|item| item.owner)
|
||||
.collect();
|
||||
for owner in owners {
|
||||
let service = std::sync::Arc::clone(&service);
|
||||
let sender = sender.clone();
|
||||
let name = name.clone();
|
||||
tokio::spawn(async move {
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(8),
|
||||
fetch_artist_catalog(&service, owner, &name),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("catalog request timed out"))
|
||||
.and_then(|result| result)
|
||||
.map(|artist| (owner.to_string(), artist));
|
||||
let _ = sender.send(result);
|
||||
});
|
||||
}
|
||||
Ok::<(), anyhow::Error>(())
|
||||
}
|
||||
.await;
|
||||
if let Err(err) = result {
|
||||
let _ = sender.send(Err(err));
|
||||
}
|
||||
});
|
||||
receiver
|
||||
}
|
||||
|
||||
/// Performs one bounded DHT search and returns entity upserts. The HTTP
|
||||
/// layer streams each upsert independently; catalog fan-out can append
|
||||
/// events to the same contract without changing the browser model.
|
||||
pub async fn search_events(&self, search_id: &str, query: &str) -> Result<Vec<SearchEvent>> {
|
||||
let query = query.trim();
|
||||
anyhow::ensure!(!query.is_empty(), "search query is empty");
|
||||
anyhow::ensure!(query.chars().count() <= 200, "search query is too long");
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let service = self.service().await?;
|
||||
tracing::info!(
|
||||
search_id,
|
||||
query,
|
||||
connected_peers = service.connected_peers().len(),
|
||||
known_contacts = service.known_peers().len(),
|
||||
"federated search started"
|
||||
);
|
||||
let own = service.endpoint_id();
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(20),
|
||||
service.search_network(query),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("federated search timed out after 20 seconds"))?
|
||||
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
|
||||
tracing::info!(
|
||||
search_id,
|
||||
query,
|
||||
local_results = result.local_results.len(),
|
||||
network_results = result.network_results.len(),
|
||||
queried_nodes = result.queried_nodes,
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"federated DHT search finished"
|
||||
);
|
||||
let all_items: Vec<LibraryItem> = result
|
||||
.local_results
|
||||
.into_iter()
|
||||
.chain(result.network_results)
|
||||
.collect();
|
||||
|
||||
let pool = self.pool().await?;
|
||||
let mut by_content: HashMap<String, TrackDto> = HashMap::new();
|
||||
for item in all_items.iter().filter(|item| item.kind == ItemKind::Track) {
|
||||
let Some(content_id) = item.content_id.as_deref().and_then(normalize_content_id) else {
|
||||
// A globally usable track reference must be verifiable.
|
||||
continue;
|
||||
};
|
||||
let local = local_availability(&pool, &content_id).await?;
|
||||
let source = FederationSourceDto {
|
||||
owner: item.owner.to_string(),
|
||||
item_id: hex(item.id.as_bytes()),
|
||||
};
|
||||
let entry = by_content.entry(content_id.clone()).or_insert_with(|| {
|
||||
track_from_item(content_id.clone(), item, local, item.owner == own)
|
||||
});
|
||||
if !entry.availability.federation.iter().any(|candidate| {
|
||||
candidate.owner == source.owner && candidate.item_id == source.item_id
|
||||
}) {
|
||||
entry.availability.federation.push(source);
|
||||
}
|
||||
if entry.availability.local.is_some() {
|
||||
entry.availability.state = "local";
|
||||
}
|
||||
}
|
||||
|
||||
let mut tracks: Vec<_> = by_content.into_values().collect();
|
||||
tracks.sort_by(|left, right| {
|
||||
left.metadata
|
||||
.title
|
||||
.to_lowercase()
|
||||
.cmp(&right.metadata.title.to_lowercase())
|
||||
});
|
||||
|
||||
let mut events = Vec::with_capacity(all_items.len());
|
||||
for (index, track) in tracks.into_iter().enumerate() {
|
||||
persist_track_ref(&pool, &track).await?;
|
||||
let peer = track
|
||||
.availability
|
||||
.federation
|
||||
.first()
|
||||
.map(|source| source.owner.clone());
|
||||
events.push(SearchEvent {
|
||||
search_id: search_id.to_owned(),
|
||||
sequence: index as u64 + 1,
|
||||
kind: "federation.track",
|
||||
peer,
|
||||
entity_key: serde_json::to_value(&track.key)?,
|
||||
entity: serde_json::to_value(track)?,
|
||||
});
|
||||
}
|
||||
let mut artist_peers: HashMap<String, (String, Vec<String>)> = HashMap::new();
|
||||
let mut releases: HashMap<String, Value> = HashMap::new();
|
||||
for item in &all_items {
|
||||
match item.kind {
|
||||
ItemKind::Artist => {
|
||||
let key = music_dht::normalize_name(&item.name);
|
||||
let entry = artist_peers
|
||||
.entry(key)
|
||||
.or_insert_with(|| (item.name.clone(), Vec::new()));
|
||||
let owner = item.owner.to_string();
|
||||
if !entry.1.contains(&owner) {
|
||||
entry.1.push(owner);
|
||||
}
|
||||
}
|
||||
ItemKind::Release => {
|
||||
let artist_keys: Vec<String> = item
|
||||
.artist_names
|
||||
.iter()
|
||||
.map(|name| music_dht::normalize_name(name))
|
||||
.collect();
|
||||
let normalized_title = music_dht::normalize_name(&item.name);
|
||||
let cover_url = all_items
|
||||
.iter()
|
||||
.find(|track| {
|
||||
track.kind == ItemKind::Track
|
||||
&& track.release_title.as_deref().is_some_and(|title| {
|
||||
music_dht::normalize_name(title) == normalized_title
|
||||
})
|
||||
&& track.year == item.year
|
||||
})
|
||||
.map(|track| {
|
||||
format!(
|
||||
"/api/player/federation/tracks/artwork?owner={}&item_id={}",
|
||||
track.owner,
|
||||
hex(track.id.as_bytes())
|
||||
)
|
||||
});
|
||||
let key = format!(
|
||||
"{}|{}|{}",
|
||||
normalized_title,
|
||||
artist_keys.join(","),
|
||||
item.year.map_or_else(String::new, |year| year.to_string())
|
||||
);
|
||||
releases.entry(key.clone()).or_insert_with(|| {
|
||||
json!({
|
||||
"key": {
|
||||
"normalized_title": music_dht::normalize_name(&item.name),
|
||||
"primary_artists": artist_keys,
|
||||
"release_type": null,
|
||||
"year": item.year,
|
||||
},
|
||||
"title": item.name,
|
||||
"artists": item.artist_names,
|
||||
"year": item.year,
|
||||
"cover_url": cover_url,
|
||||
"sources": [{
|
||||
"owner": item.owner.to_string(),
|
||||
"item_id": hex(item.id.as_bytes()),
|
||||
}],
|
||||
})
|
||||
});
|
||||
}
|
||||
ItemKind::Track => {}
|
||||
}
|
||||
}
|
||||
for (key, (name, peers)) in artist_peers {
|
||||
let sequence = events.len() as u64 + 1;
|
||||
events.push(SearchEvent {
|
||||
search_id: search_id.to_owned(),
|
||||
sequence,
|
||||
kind: "federation.artist",
|
||||
peer: peers.first().cloned(),
|
||||
entity_key: json!({ "normalized_name": key }),
|
||||
entity: json!({
|
||||
"key": { "normalized_name": key },
|
||||
"name": name,
|
||||
"image_url": null,
|
||||
"peers": peers,
|
||||
}),
|
||||
});
|
||||
}
|
||||
for (key, release) in releases {
|
||||
let sequence = events.len() as u64 + 1;
|
||||
events.push(SearchEvent {
|
||||
search_id: search_id.to_owned(),
|
||||
sequence,
|
||||
kind: "federation.release",
|
||||
peer: None,
|
||||
entity_key: json!({ "composite": key }),
|
||||
entity: release,
|
||||
});
|
||||
}
|
||||
tracing::info!(
|
||||
search_id,
|
||||
query,
|
||||
events = events.len(),
|
||||
elapsed_ms = started.elapsed().as_millis() as u64,
|
||||
"federated search response ready"
|
||||
);
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_artist_catalog(
|
||||
service: &music_dht::MusicDhtService,
|
||||
owner: music_dht::EndpointId,
|
||||
artist: &str,
|
||||
) -> Result<music_dht::catalog::CatalogArtist> {
|
||||
let mut stream = service
|
||||
.open_stream(owner, super::CATALOG_ALPN)
|
||||
.await
|
||||
.map_err(|err| anyhow::anyhow!("cannot reach catalog peer: {err}"))?;
|
||||
let mut request = serde_json::to_vec(&music_dht::catalog::CatalogRequest {
|
||||
artist: artist.to_owned(),
|
||||
want: Some("catalog".to_owned()),
|
||||
..Default::default()
|
||||
})?;
|
||||
request.push(b'\n');
|
||||
stream.send.write_all(&request).await?;
|
||||
stream.send.finish()?;
|
||||
let mut payload = Vec::new();
|
||||
stream
|
||||
.recv
|
||||
.take(MAX_CATALOG_BYTES + 1)
|
||||
.read_to_end(&mut payload)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
payload.len() as u64 <= MAX_CATALOG_BYTES,
|
||||
"catalog response is too large"
|
||||
);
|
||||
let response: music_dht::catalog::CatalogResponse =
|
||||
serde_json::from_slice(&payload).context("invalid catalog response")?;
|
||||
anyhow::ensure!(
|
||||
response.ok,
|
||||
"peer refused catalog: {}",
|
||||
response.error.unwrap_or_else(|| "unknown error".to_owned())
|
||||
);
|
||||
response.artist.context("peer returned no artist catalog")
|
||||
}
|
||||
|
||||
fn track_from_item(
|
||||
content_id: String,
|
||||
item: &LibraryItem,
|
||||
local: Option<LocalAvailabilityDto>,
|
||||
own: bool,
|
||||
) -> TrackDto {
|
||||
let owner = item.owner.to_string();
|
||||
let item_id = hex(item.id.as_bytes());
|
||||
let artists = artist_refs(&item.artist_names);
|
||||
let featured_artists = artist_refs(&item.featured_artist_names);
|
||||
let release = item.release_title.as_ref().map(|title| ReleaseRefDto {
|
||||
key: ReleaseKeyDto {
|
||||
normalized_title: music_dht::normalize_name(title),
|
||||
primary_artists: item
|
||||
.artist_names
|
||||
.iter()
|
||||
.map(|artist| music_dht::normalize_name(artist))
|
||||
.collect(),
|
||||
release_type: None,
|
||||
year: item.year,
|
||||
},
|
||||
local_id: None,
|
||||
title: title.clone(),
|
||||
});
|
||||
let state = if local.is_some() || own {
|
||||
"local"
|
||||
} else {
|
||||
"federated"
|
||||
};
|
||||
TrackDto {
|
||||
key: TrackKeyDto { content_id },
|
||||
metadata: TrackMetadataDto {
|
||||
title: item.name.clone(),
|
||||
artists,
|
||||
featured_artists,
|
||||
release,
|
||||
year: item.year,
|
||||
duration_seconds: item.duration_seconds,
|
||||
track_number: item.track_number,
|
||||
disc_number: item.disc_number,
|
||||
cover_url: Some(format!(
|
||||
"/api/player/federation/tracks/artwork?owner={owner}&item_id={item_id}"
|
||||
)),
|
||||
},
|
||||
availability: TrackAvailabilityDto {
|
||||
state,
|
||||
local,
|
||||
federation: vec![FederationSourceDto { owner, item_id }],
|
||||
},
|
||||
similarity_score: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn artist_refs(names: &[String]) -> Vec<ArtistRefDto> {
|
||||
names
|
||||
.iter()
|
||||
.map(|name| ArtistRefDto {
|
||||
key: ArtistKeyDto {
|
||||
normalized_name: music_dht::normalize_name(name),
|
||||
},
|
||||
name: name.clone(),
|
||||
local_id: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
async fn local_availability(
|
||||
pool: &sqlx::PgPool,
|
||||
content_id: &str,
|
||||
) -> Result<Option<LocalAvailabilityDto>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT t.id
|
||||
FROM furumusic__federation_content_id_cache c
|
||||
JOIN furumusic__track t ON t.audio_file_id = c.media_file_id
|
||||
WHERE c.content_id = $1 AND t.is_hidden = false
|
||||
LIMIT 1",
|
||||
)
|
||||
.bind(content_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.map(|row| {
|
||||
let track_id: i64 = row.get(0);
|
||||
LocalAvailabilityDto {
|
||||
track_id,
|
||||
stream_url: format!("/api/player/stream/{track_id}"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async fn persist_track_ref(pool: &sqlx::PgPool, track: &TrackDto) -> Result<()> {
|
||||
let metadata = serde_json::to_value(&track.metadata)?;
|
||||
let local_id = track
|
||||
.availability
|
||||
.local
|
||||
.as_ref()
|
||||
.map(|local| local.track_id);
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO furumusic__track_ref
|
||||
(content_id, local_track_id, title, release_title, year,
|
||||
duration_seconds, metadata_json, metadata_authority, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, 'federation', $8, $8)
|
||||
ON CONFLICT (content_id) DO UPDATE SET
|
||||
local_track_id = COALESCE(furumusic__track_ref.local_track_id, EXCLUDED.local_track_id),
|
||||
title = EXCLUDED.title,
|
||||
release_title = EXCLUDED.release_title,
|
||||
year = EXCLUDED.year,
|
||||
duration_seconds = EXCLUDED.duration_seconds,
|
||||
metadata_json = EXCLUDED.metadata_json,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(&track.key.content_id)
|
||||
.bind(local_id)
|
||||
.bind(&track.metadata.title)
|
||||
.bind(
|
||||
track
|
||||
.metadata
|
||||
.release
|
||||
.as_ref()
|
||||
.map(|release| &release.title),
|
||||
)
|
||||
.bind(track.metadata.year)
|
||||
.bind(track.metadata.duration_seconds)
|
||||
.bind(metadata)
|
||||
.bind(now_iso())
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.context("persisting content-addressed track reference failed")?;
|
||||
let track_ref_id: i64 = row.get(0);
|
||||
for source in &track.availability.federation {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_track_source
|
||||
(track_ref_id, owner_peer_id, item_id, last_seen_ms, metadata_json)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (owner_peer_id, item_id) DO UPDATE SET
|
||||
track_ref_id = EXCLUDED.track_ref_id,
|
||||
last_seen_ms = EXCLUDED.last_seen_ms,
|
||||
metadata_json = EXCLUDED.metadata_json",
|
||||
)
|
||||
.bind(track_ref_id)
|
||||
.bind(&source.owner)
|
||||
.bind(&source.item_id)
|
||||
.bind(chrono::Utc::now().timestamp_millis())
|
||||
.bind(json!({ "track": track.metadata }))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
+535
-33
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::device_sync::{ListenEndReason, ListenEvent, ListenTrackMetadata};
|
||||
use music_dht::{ByteStream, MusicDhtService, NetworkId, PeerTicket, SecretKey, StreamAcceptor};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::Row;
|
||||
@@ -18,10 +19,12 @@ use tokio::io::{AsyncRead, AsyncReadExt};
|
||||
|
||||
use crate::player::PlayerDeviceHub;
|
||||
|
||||
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1";
|
||||
use super::{TransportStats, record_stream_transport};
|
||||
|
||||
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
|
||||
|
||||
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
const PROTOCOL_VERSION: u16 = 1;
|
||||
const PROTOCOL_VERSION: u16 = 2;
|
||||
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
|
||||
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
|
||||
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
|
||||
@@ -29,6 +32,9 @@ const RESPONSE_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
|
||||
const DEVICE_SYNC_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const LOCAL_SEED_RECHECK_MS: i64 = 60 * 1000;
|
||||
const MAX_LINE: usize = 8 * 1024 * 1024;
|
||||
/// Playback commands carry queue state but are useful only briefly and only
|
||||
/// to their addressed device. They must not inflate a new peer's catch-up.
|
||||
const PLAYBACK_COMMAND_TTL_MS: i64 = 5 * 60 * 1_000;
|
||||
const MAX_OPS_PER_BATCH: i64 = 1000;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
@@ -81,6 +87,7 @@ struct Identity {
|
||||
#[derive(Debug, Clone)]
|
||||
struct StoredDevice {
|
||||
device_id: String,
|
||||
endpoint_id: String,
|
||||
endpoint_ticket: String,
|
||||
}
|
||||
|
||||
@@ -267,6 +274,9 @@ enum SyncOpPayload {
|
||||
target_device_id: String,
|
||||
command: PlaybackCommand,
|
||||
},
|
||||
ListenRecorded {
|
||||
event: ListenEvent,
|
||||
},
|
||||
}
|
||||
|
||||
impl SyncOpPayload {
|
||||
@@ -457,6 +467,7 @@ pub async fn connect_invite(
|
||||
pool: &sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
user_id: i64,
|
||||
user_name: &str,
|
||||
invite_link: &str,
|
||||
@@ -471,6 +482,7 @@ pub async fn connect_invite(
|
||||
pool,
|
||||
Arc::clone(&service),
|
||||
Arc::clone(&hub),
|
||||
Arc::clone(&transport_stats),
|
||||
user_id,
|
||||
user_name,
|
||||
&invite,
|
||||
@@ -748,13 +760,18 @@ pub async fn record_track_like(
|
||||
liked: bool,
|
||||
) -> Result<()> {
|
||||
if let Some(content_id) = track_content_id(pool, track_id).await? {
|
||||
let fed = if liked {
|
||||
synced_fed_track_for_track(pool, track_id, &content_id).await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
record_local_op(
|
||||
pool,
|
||||
user_id,
|
||||
SyncOpPayload::TrackLikeSet {
|
||||
content_id,
|
||||
liked,
|
||||
fed: None,
|
||||
fed,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -762,6 +779,184 @@ pub async fn record_track_like(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn record_track_listen(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
track_id: i64,
|
||||
listen_id: String,
|
||||
started_at_ms: i64,
|
||||
listened_ms: i64,
|
||||
ended_reason: ListenEndReason,
|
||||
) -> Result<()> {
|
||||
let content_id = track_content_id(pool, track_id)
|
||||
.await?
|
||||
.context("track content id is not ready")?;
|
||||
let track = synced_fed_track_for_track(pool, track_id, &content_id)
|
||||
.await?
|
||||
.context("track metadata is not available")?;
|
||||
let event = ListenEvent {
|
||||
listen_id,
|
||||
content_id,
|
||||
started_at_ms,
|
||||
listened_ms,
|
||||
track_duration_ms: track.duration_seconds.map(|seconds| seconds * 1_000),
|
||||
ended_reason,
|
||||
track: ListenTrackMetadata {
|
||||
title: track.title,
|
||||
artist_names: track.artist_names,
|
||||
featured_artist_names: track.featured_artist_names,
|
||||
release_title: track.release_title,
|
||||
},
|
||||
};
|
||||
if event.should_record() {
|
||||
record_local_op(pool, user_id, SyncOpPayload::ListenRecorded { event }).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Records a like for a content-addressed track that need not be local yet.
|
||||
/// The ordinary local likes table remains a projection for materialized
|
||||
/// tracks; the durable user intent lives under `content_id`.
|
||||
pub async fn record_content_like(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
content_id: &str,
|
||||
liked: bool,
|
||||
fed: Option<serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
let content_id =
|
||||
music_dht::normalize_content_id(content_id).context("invalid track content id")?;
|
||||
let fed = fed
|
||||
.map(serde_json::from_value::<SyncedFedTrack>)
|
||||
.transpose()
|
||||
.context("invalid federated track metadata")?;
|
||||
record_local_op(
|
||||
pool,
|
||||
user_id,
|
||||
SyncOpPayload::TrackLikeSet {
|
||||
content_id,
|
||||
liked,
|
||||
fed: liked.then_some(fed).flatten(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Adds a content-addressed track to a user's playlist without requiring a
|
||||
/// local numeric track row. Materialization later fills `local_track_id`
|
||||
/// without replacing this playlist item.
|
||||
pub async fn record_content_playlist_add(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
playlist_id: i64,
|
||||
content_id: &str,
|
||||
position: i64,
|
||||
fed: Option<serde_json::Value>,
|
||||
) -> Result<()> {
|
||||
let content_id =
|
||||
music_dht::normalize_content_id(content_id).context("invalid track content id")?;
|
||||
let fed = fed
|
||||
.map(serde_json::from_value::<SyncedFedTrack>)
|
||||
.transpose()
|
||||
.context("invalid federated track metadata")?;
|
||||
let title = playlist_title(pool, playlist_id)
|
||||
.await?
|
||||
.context("playlist not found")?;
|
||||
let sync_id = ensure_local_playlist_sync_id(pool, user_id, playlist_id, &title).await?;
|
||||
record_local_op(
|
||||
pool,
|
||||
user_id,
|
||||
SyncOpPayload::PlaylistTrackAdded {
|
||||
playlist_id: sync_id,
|
||||
content_id,
|
||||
position,
|
||||
fed,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reconciles content-addressed user state after a track becomes local.
|
||||
/// History is intentionally not touched: only actual web playback writes it.
|
||||
pub async fn materialize_content_state(
|
||||
pool: &sqlx::PgPool,
|
||||
content_id: &str,
|
||||
track_id: i64,
|
||||
) -> Result<()> {
|
||||
let content_id =
|
||||
music_dht::normalize_content_id(content_id).context("invalid track content id")?;
|
||||
let likes = sqlx::query(
|
||||
"SELECT user_id, liked, hlc_ms
|
||||
FROM furumusic__fed_state_like WHERE content_id = $1",
|
||||
)
|
||||
.bind(&content_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in likes {
|
||||
let user_id: i64 = row.get("user_id");
|
||||
let liked: bool = row.get("liked");
|
||||
if liked {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__user_liked_track
|
||||
(user_id, track_id, created_at)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (user_id, track_id) DO NOTHING",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(track_id)
|
||||
.bind(iso_from_ms(row.get("hlc_ms")))
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
let playlist_items = sqlx::query(
|
||||
"SELECT i.user_id, i.playlist_id, i.position
|
||||
FROM furumusic__fed_state_playlist_item i
|
||||
WHERE i.content_id = $1 AND i.present = true",
|
||||
)
|
||||
.bind(&content_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in playlist_items {
|
||||
let user_id: i64 = row.get("user_id");
|
||||
let sync_id: String = row.get("playlist_id");
|
||||
let playlist_id = ensure_playlist_for_item(pool, user_id, &sync_id).await?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__playlist_track
|
||||
(playlist_id, track_id, position, added_at, added_by_user_id)
|
||||
SELECT $1, $2, $3, $4, $5
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM furumusic__playlist_track
|
||||
WHERE playlist_id = $1 AND track_id = $2
|
||||
)",
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.bind(track_id)
|
||||
.bind(row.get::<i64, _>("position") as i32)
|
||||
.bind(now_iso())
|
||||
.bind(user_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__fed_state_like
|
||||
SET local_track_id = $2 WHERE content_id = $1",
|
||||
)
|
||||
.bind(&content_id)
|
||||
.bind(track_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__fed_state_playlist_item
|
||||
SET local_track_id = $2 WHERE content_id = $1",
|
||||
)
|
||||
.bind(&content_id)
|
||||
.bind(track_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn record_playlist_created(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
@@ -830,7 +1025,8 @@ pub async fn record_playlist_tracks_added(
|
||||
let title = playlist_title(pool, playlist_id).await?.unwrap_or_default();
|
||||
let sync_id = ensure_local_playlist_sync_id(pool, user_id, playlist_id, &title).await?;
|
||||
let rows = playlist_track_content_positions(pool, playlist_id, track_ids).await?;
|
||||
for (content_id, position) in rows {
|
||||
for (track_id, content_id, position) in rows {
|
||||
let fed = synced_fed_track_for_track(pool, track_id, &content_id).await?;
|
||||
record_local_op(
|
||||
pool,
|
||||
user_id,
|
||||
@@ -838,7 +1034,7 @@ pub async fn record_playlist_tracks_added(
|
||||
playlist_id: sync_id.clone(),
|
||||
content_id,
|
||||
position,
|
||||
fed: None,
|
||||
fed,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -878,14 +1074,16 @@ pub async fn serve_peers(
|
||||
pool: sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let pool = pool.clone();
|
||||
let service = Arc::clone(&service);
|
||||
let hub = Arc::clone(&hub);
|
||||
let transport_stats = Arc::clone(&transport_stats);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_one(stream, &pool, service, hub).await {
|
||||
if let Err(err) = serve_one(stream, &pool, service, hub, transport_stats).await {
|
||||
tracing::warn!(peer = %peer, "web fed device sync stream failed: {err:#}");
|
||||
}
|
||||
});
|
||||
@@ -896,11 +1094,19 @@ pub async fn sync_loop(
|
||||
pool: sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) {
|
||||
let mut interval = tokio::time::interval(DEVICE_SYNC_INTERVAL);
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if let Err(err) = sync_once_all(&pool, Arc::clone(&service), Arc::clone(&hub)).await {
|
||||
if let Err(err) = sync_once_all(
|
||||
&pool,
|
||||
Arc::clone(&service),
|
||||
Arc::clone(&hub),
|
||||
Arc::clone(&transport_stats),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!("web fed device sync tick failed: {err:#}");
|
||||
}
|
||||
}
|
||||
@@ -910,6 +1116,7 @@ pub async fn sync_once_all(
|
||||
pool: &sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT DISTINCT user_id FROM furumusic__fed_device
|
||||
@@ -919,7 +1126,15 @@ pub async fn sync_once_all(
|
||||
.await?;
|
||||
for row in rows {
|
||||
let user_id: i64 = row.get("user_id");
|
||||
if let Err(err) = sync_once(pool, Arc::clone(&service), Arc::clone(&hub), user_id).await {
|
||||
if let Err(err) = sync_once(
|
||||
pool,
|
||||
Arc::clone(&service),
|
||||
Arc::clone(&hub),
|
||||
Arc::clone(&transport_stats),
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
{
|
||||
set_last_error(pool, user_id, Some(&format!("{err:#}"))).await?;
|
||||
}
|
||||
}
|
||||
@@ -930,6 +1145,7 @@ pub async fn sync_once(
|
||||
pool: &sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
user_id: i64,
|
||||
) -> Result<()> {
|
||||
let devices = active_remote_devices(pool, user_id).await?;
|
||||
@@ -941,6 +1157,7 @@ pub async fn sync_once(
|
||||
pool,
|
||||
Arc::clone(&service),
|
||||
Arc::clone(&hub),
|
||||
Arc::clone(&transport_stats),
|
||||
user_id,
|
||||
&device,
|
||||
)
|
||||
@@ -962,6 +1179,7 @@ async fn try_connect_invite(
|
||||
pool: &sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
user_id: i64,
|
||||
user_name: &str,
|
||||
invite: &InviteWire,
|
||||
@@ -978,6 +1196,7 @@ async fn try_connect_invite(
|
||||
let snapshot = snapshot(pool, user_id).await?;
|
||||
let playback = local_playback_snapshot(pool, Arc::clone(&hub), user_id, &identity).await;
|
||||
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
||||
record_stream_transport(&transport_stats, "device-sync", "outbound", "open", &stream);
|
||||
write_msg(
|
||||
&mut stream,
|
||||
&WireMessage::PairRequest {
|
||||
@@ -995,10 +1214,11 @@ async fn try_connect_invite(
|
||||
)
|
||||
.await?;
|
||||
finish_send(&mut stream).await?;
|
||||
match read_msg(&mut stream)
|
||||
let response = read_msg(&mut stream)
|
||||
.await
|
||||
.context("pairing response was not received")?
|
||||
{
|
||||
.context("pairing response was not received")?;
|
||||
record_stream_transport(&transport_stats, "device-sync", "outbound", "done", &stream);
|
||||
match response {
|
||||
WireMessage::PairResponse {
|
||||
accepted: true,
|
||||
group_id: Some(group_id),
|
||||
@@ -1062,7 +1282,9 @@ async fn serve_one(
|
||||
pool: &sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
record_stream_transport(&transport_stats, "device-sync", "inbound", "open", &stream);
|
||||
match read_msg(&mut stream).await? {
|
||||
WireMessage::PairRequest {
|
||||
invite_id,
|
||||
@@ -1081,6 +1303,7 @@ async fn serve_one(
|
||||
pool,
|
||||
service,
|
||||
hub,
|
||||
transport_stats,
|
||||
invite_id,
|
||||
secret,
|
||||
profile,
|
||||
@@ -1104,7 +1327,17 @@ async fn serve_one(
|
||||
playback,
|
||||
} => {
|
||||
handle_hello(
|
||||
stream, pool, service, hub, group_id, profile, devices, vector, ops, snapshot,
|
||||
stream,
|
||||
pool,
|
||||
service,
|
||||
hub,
|
||||
transport_stats,
|
||||
group_id,
|
||||
profile,
|
||||
devices,
|
||||
vector,
|
||||
ops,
|
||||
snapshot,
|
||||
playback,
|
||||
)
|
||||
.await
|
||||
@@ -1119,6 +1352,7 @@ async fn handle_pair_request(
|
||||
pool: &sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
invite_id: String,
|
||||
secret: String,
|
||||
mut profile: DeviceProfileWire,
|
||||
@@ -1209,6 +1443,7 @@ async fn handle_pair_request(
|
||||
)
|
||||
.await?;
|
||||
finish_response(&mut stream).await?;
|
||||
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
|
||||
return Ok(());
|
||||
}
|
||||
Some("accepted") => {}
|
||||
@@ -1259,6 +1494,7 @@ async fn handle_pair_request(
|
||||
)
|
||||
.await?;
|
||||
finish_response(&mut stream).await?;
|
||||
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1268,6 +1504,7 @@ async fn handle_hello(
|
||||
pool: &sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
group_id: String,
|
||||
mut profile: DeviceProfileWire,
|
||||
devices: Vec<DeviceProfileWire>,
|
||||
@@ -1328,6 +1565,7 @@ async fn handle_hello(
|
||||
)
|
||||
.await?;
|
||||
finish_response(&mut stream).await?;
|
||||
record_stream_transport(&transport_stats, "device-sync", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1335,11 +1573,11 @@ async fn sync_device(
|
||||
pool: &sqlx::PgPool,
|
||||
service: Arc<MusicDhtService>,
|
||||
hub: Arc<PlayerDeviceHub>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
user_id: i64,
|
||||
device: &StoredDevice,
|
||||
) -> Result<()> {
|
||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||
let peer = service.connect(ticket).await?;
|
||||
let peer = resolve_device_peer(&service, device).await?;
|
||||
let own_ticket = service.ticket().await?.to_string();
|
||||
let identity = ensure_identity(pool, user_id, "").await?;
|
||||
let profile = own_profile(pool, user_id, "", &own_ticket).await?;
|
||||
@@ -1349,6 +1587,7 @@ async fn sync_device(
|
||||
let snapshot = snapshot(pool, user_id).await?;
|
||||
let playback = local_playback_snapshot(pool, Arc::clone(&hub), user_id, &identity).await;
|
||||
let mut stream = service.open_stream(peer, SYNC_ALPN).await?;
|
||||
record_stream_transport(&transport_stats, "device-sync", "outbound", "open", &stream);
|
||||
write_msg(
|
||||
&mut stream,
|
||||
&WireMessage::Hello {
|
||||
@@ -1363,10 +1602,11 @@ async fn sync_device(
|
||||
)
|
||||
.await?;
|
||||
finish_send(&mut stream).await?;
|
||||
match read_msg(&mut stream)
|
||||
let response = read_msg(&mut stream)
|
||||
.await
|
||||
.context("device sync response was not received")?
|
||||
{
|
||||
.context("device sync response was not received")?;
|
||||
record_stream_transport(&transport_stats, "device-sync", "outbound", "done", &stream);
|
||||
match response {
|
||||
WireMessage::SyncResponse {
|
||||
accepted: true,
|
||||
devices,
|
||||
@@ -1481,16 +1721,40 @@ async fn maybe_seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Resul
|
||||
let Some(last_seeded_at_ms) = last_seeded_at_ms else {
|
||||
return Ok(());
|
||||
};
|
||||
if now_ms().saturating_sub(last_seeded_at_ms) < LOCAL_SEED_RECHECK_MS {
|
||||
if now_ms().saturating_sub(last_seeded_at_ms) < LOCAL_SEED_RECHECK_MS
|
||||
&& !local_seed_needs_metadata_backfill(pool, user_id).await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
seed_local_user_state(pool, user_id).await
|
||||
}
|
||||
|
||||
async fn local_seed_needs_metadata_backfill(pool: &sqlx::PgPool, user_id: i64) -> Result<bool> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT EXISTS (
|
||||
SELECT 1 FROM furumusic__fed_state_like
|
||||
WHERE user_id = $1
|
||||
AND liked = true
|
||||
AND fed_json IS NULL
|
||||
AND local_track_id IS NOT NULL
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM furumusic__fed_state_playlist_item
|
||||
WHERE user_id = $1
|
||||
AND present = true
|
||||
AND fed_json IS NULL
|
||||
AND local_track_id IS NOT NULL
|
||||
)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()> {
|
||||
let now = now_ms();
|
||||
let like_rows = sqlx::query(
|
||||
"SELECT ult.track_id, c.content_id
|
||||
"SELECT ult.track_id, ult.created_at, c.content_id
|
||||
FROM furumusic__user_liked_track ult
|
||||
JOIN furumusic__track t ON t.id = ult.track_id
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
@@ -1507,17 +1771,25 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
|
||||
continue;
|
||||
};
|
||||
let track_id: i64 = row.get("track_id");
|
||||
let liked_hlc_ms = timestamp_from_iso(&row.get::<String, _>("created_at")).unwrap_or(now);
|
||||
let fed_json = synced_fed_track_for_track(pool, track_id, &content_id)
|
||||
.await?
|
||||
.map(serde_json::to_value)
|
||||
.transpose()?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__fed_state_like
|
||||
(user_id, content_id, liked, hlc_ms, op_id, local_track_id, fed_json)
|
||||
VALUES ($1, $2, true, $3, $4, $5, NULL)
|
||||
ON CONFLICT (user_id, content_id) DO NOTHING",
|
||||
VALUES ($1, $2, true, $3, $4, $5, $6)
|
||||
ON CONFLICT (user_id, content_id) DO UPDATE SET
|
||||
local_track_id = COALESCE(furumusic__fed_state_like.local_track_id, EXCLUDED.local_track_id),
|
||||
fed_json = COALESCE(furumusic__fed_state_like.fed_json, EXCLUDED.fed_json)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&content_id)
|
||||
.bind(now)
|
||||
.bind(liked_hlc_ms)
|
||||
.bind(format!("local_seed:like:{track_id}"))
|
||||
.bind(track_id)
|
||||
.bind(fed_json)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
@@ -1555,12 +1827,18 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
|
||||
};
|
||||
let track_id: i64 = item.get("track_id");
|
||||
let position: i32 = item.get("position");
|
||||
let fed_json = synced_fed_track_for_track(pool, track_id, &content_id)
|
||||
.await?
|
||||
.map(serde_json::to_value)
|
||||
.transpose()?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__fed_state_playlist_item
|
||||
(user_id, playlist_id, content_id, present, position, hlc_ms, op_id,
|
||||
local_track_id, fed_json)
|
||||
VALUES ($1, $2, $3, true, $4, $5, $6, $7, NULL)
|
||||
ON CONFLICT (user_id, playlist_id, content_id) DO NOTHING",
|
||||
VALUES ($1, $2, $3, true, $4, $5, $6, $7, $8)
|
||||
ON CONFLICT (user_id, playlist_id, content_id) DO UPDATE SET
|
||||
local_track_id = COALESCE(furumusic__fed_state_playlist_item.local_track_id, EXCLUDED.local_track_id),
|
||||
fed_json = COALESCE(furumusic__fed_state_playlist_item.fed_json, EXCLUDED.fed_json)",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&sync_id)
|
||||
@@ -1569,6 +1847,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
|
||||
.bind(now)
|
||||
.bind(format!("local_seed:playlist_item:{playlist_id}:{track_id}"))
|
||||
.bind(track_id)
|
||||
.bind(fed_json)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
@@ -1922,9 +2201,108 @@ async fn apply_op(
|
||||
.await?;
|
||||
Ok(false)
|
||||
}
|
||||
SyncOpPayload::ListenRecorded { event } => {
|
||||
apply_listen_event(pool, user_id, &op.origin_device_id, event).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_listen_event(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
origin_device_id: &str,
|
||||
event: &ListenEvent,
|
||||
) -> Result<bool> {
|
||||
if !event.should_record() || origin_device_id.trim().is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
let content_id =
|
||||
music_dht::normalize_content_id(&event.content_id).context("invalid listen content id")?;
|
||||
let local_track_id: Option<i64> = sqlx::query_scalar(
|
||||
"SELECT local_track_id
|
||||
FROM furumusic__track_ref
|
||||
WHERE content_id = $1",
|
||||
)
|
||||
.bind(&content_id)
|
||||
.fetch_optional(pool)
|
||||
.await?
|
||||
.flatten();
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO furumusic__listen_event
|
||||
(user_id, listen_id, content_id, local_track_id, origin_device_id,
|
||||
started_at_ms, listened_ms, track_duration_ms, ended_reason,
|
||||
qualified, metadata_json, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||
ON CONFLICT (user_id, listen_id) DO NOTHING",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(&event.listen_id)
|
||||
.bind(&content_id)
|
||||
.bind(local_track_id)
|
||||
.bind(origin_device_id)
|
||||
.bind(event.started_at_ms)
|
||||
.bind(event.listened_ms)
|
||||
.bind(event.track_duration_ms)
|
||||
.bind(serde_json::to_string(&event.ended_reason)?)
|
||||
.bind(event.qualifies_as_play())
|
||||
.bind(serde_json::to_value(&event.track)?)
|
||||
.bind(chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string())
|
||||
.execute(pool)
|
||||
.await?
|
||||
.rows_affected()
|
||||
> 0;
|
||||
if !inserted || !event.qualifies_as_play() {
|
||||
return Ok(inserted);
|
||||
}
|
||||
|
||||
let Some(artist_name) = event
|
||||
.track
|
||||
.artist_names
|
||||
.iter()
|
||||
.find(|name| !name.trim().is_empty())
|
||||
else {
|
||||
return Ok(true);
|
||||
};
|
||||
let duration_seconds = event
|
||||
.track_duration_ms
|
||||
.unwrap_or_default()
|
||||
.div_euclid(1_000)
|
||||
.clamp(0, i64::from(i32::MAX)) as i32;
|
||||
if duration_seconds <= 30 {
|
||||
return Ok(true);
|
||||
}
|
||||
let listened_seconds = event
|
||||
.listened_ms
|
||||
.div_euclid(1_000)
|
||||
.clamp(0, i64::from(i32::MAX)) as i32;
|
||||
let started_at = event.started_at_ms.div_euclid(1_000);
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__lastfm_scrobble_outbox
|
||||
(user_id, track_id, started_at, listened_seconds, duration_seconds,
|
||||
status, created_at, updated_at, dedupe_key, track_title,
|
||||
artist_name, album_title)
|
||||
SELECT $1, $2, $3, $4, $5, 'pending', $6, $6, $7, $8, $9, $10
|
||||
WHERE EXISTS (
|
||||
SELECT 1 FROM furumusic__lastfm_account WHERE user_id = $1
|
||||
)
|
||||
ON CONFLICT (dedupe_key) DO NOTHING",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(local_track_id)
|
||||
.bind(started_at)
|
||||
.bind(listened_seconds)
|
||||
.bind(duration_seconds)
|
||||
.bind(&now)
|
||||
.bind(format!("listen:{user_id}:{}", event.listen_id))
|
||||
.bind(&event.track.title)
|
||||
.bind(artist_name)
|
||||
.bind(event.track.release_title.as_deref())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn apply_like_state(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
@@ -2571,6 +2949,31 @@ pub async fn record_web_active_transfer(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn record_web_active_takeover(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
previous_device_id: &str,
|
||||
state: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
ensure_web_playback_target(pool, user_id, previous_device_id).await?;
|
||||
let identity = ensure_identity(pool, user_id, "").await?;
|
||||
let wire = playback_state_from_browser_json(pool, state).await?;
|
||||
record_local_op(
|
||||
pool,
|
||||
user_id,
|
||||
SyncOpPayload::PlaybackCommand {
|
||||
target_device_id: previous_device_id.to_string(),
|
||||
command: PlaybackCommand::ActiveChanged {
|
||||
active_device_id: identity.device_id,
|
||||
active_device_name: identity.name,
|
||||
state: wire,
|
||||
},
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn ensure_web_playback_target(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
@@ -2990,6 +3393,10 @@ fn json_f64(value: f64) -> serde_json::Value {
|
||||
.unwrap_or_else(|| serde_json::json!(0.0))
|
||||
}
|
||||
|
||||
fn cover_variant_url(file_id: Option<i64>, variant: &str) -> Option<String> {
|
||||
file_id.map(|id| format!("/api/player/cover/{id}/{variant}"))
|
||||
}
|
||||
|
||||
async fn web_playback_payload(
|
||||
pool: &sqlx::PgPool,
|
||||
state: &PlaybackStateWire,
|
||||
@@ -3016,12 +3423,16 @@ async fn local_playback_snapshot(
|
||||
user_id: i64,
|
||||
identity: &Identity,
|
||||
) -> Option<PlaybackSnapshot> {
|
||||
let state = hub.current_playback_state_json(user_id)?;
|
||||
// Keep publishing an inactive snapshot after a handoff. Omitting the
|
||||
// snapshot left the last `active: true` value alive on trusted peers until
|
||||
// its TTL elapsed, allowing the always-on web peer to reclaim playback.
|
||||
let active = hub.federation_playback_is_local(user_id);
|
||||
let state = hub.playback_state_json_for_commands(user_id)?;
|
||||
let wire = playback_state_from_browser_json(pool, state).await.ok()?;
|
||||
Some(PlaybackSnapshot {
|
||||
device_id: identity.device_id.clone(),
|
||||
device_name: identity.name.clone(),
|
||||
active: true,
|
||||
active,
|
||||
updated_at_ms: now_ms(),
|
||||
state: wire,
|
||||
})
|
||||
@@ -3221,7 +3632,8 @@ async fn web_track_json_by_id(
|
||||
"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number,
|
||||
t.duration_seconds, r.id AS release_id, r.title::text AS release_title,
|
||||
r.year AS release_year, mf.audio_format, mf.audio_bitrate,
|
||||
mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes
|
||||
mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes,
|
||||
COALESCE(t.cover_file_id, r.cover_file_id) AS cover_file_id
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||
@@ -3246,7 +3658,7 @@ async fn web_track_json_by_id(
|
||||
"release_id": row.get::<i64, _>("release_id"),
|
||||
"release_title": row.get::<String, _>("release_title"),
|
||||
"release_year": row.get::<Option<i32>, _>("release_year"),
|
||||
"cover_url": serde_json::Value::Null,
|
||||
"cover_url": cover_variant_url(row.get::<Option<i64>, _>("cover_file_id"), "medium"),
|
||||
"stream_url": format!("/api/player/stream/{track_id}"),
|
||||
"uploader_name": "Fed",
|
||||
"audio_format": row.get::<Option<String>, _>("audio_format"),
|
||||
@@ -3279,6 +3691,21 @@ async fn track_artist_json(
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn track_artist_names(pool: &sqlx::PgPool, track_id: i64, role: &str) -> Result<Vec<String>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.name::text AS name
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = $1 AND ta.role = $2
|
||||
ORDER BY ta.position",
|
||||
)
|
||||
.bind(track_id)
|
||||
.bind(role)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|row| row.get("name")).collect())
|
||||
}
|
||||
|
||||
fn placeholder_playback_track(
|
||||
track: &PlaybackTrack,
|
||||
index: usize,
|
||||
@@ -3392,6 +3819,45 @@ async fn track_content_id(pool: &sqlx::PgPool, track_id: i64) -> Result<Option<S
|
||||
Ok(value.and_then(|value| music_dht::normalize_content_id(&value)))
|
||||
}
|
||||
|
||||
async fn synced_fed_track_for_track(
|
||||
pool: &sqlx::PgPool,
|
||||
track_id: i64,
|
||||
content_id: &str,
|
||||
) -> Result<Option<SyncedFedTrack>> {
|
||||
let Some(content_id) = music_dht::normalize_content_id(content_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let row = sqlx::query(
|
||||
"SELECT t.title::text AS title, t.track_number, t.disc_number,
|
||||
t.duration_seconds, r.title::text AS release_title, r.year AS release_year
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
let Some(row) = row else {
|
||||
return Ok(None);
|
||||
};
|
||||
let duration = row.get::<f64, _>("duration_seconds");
|
||||
Ok(Some(SyncedFedTrack {
|
||||
item_id: format!("web:{track_id}"),
|
||||
owner: "WEB".to_string(),
|
||||
title: row.get("title"),
|
||||
artist_names: track_artist_names(pool, track_id, "main").await?,
|
||||
featured_artist_names: track_artist_names(pool, track_id, "featuring").await?,
|
||||
year: row.get("release_year"),
|
||||
duration_seconds: duration
|
||||
.is_finite()
|
||||
.then(|| duration.round().max(0.0) as i64),
|
||||
content_id,
|
||||
release_title: row.get("release_title"),
|
||||
track_number: row.get("track_number"),
|
||||
disc_number: row.get("disc_number"),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn ensure_materialized_playlist(
|
||||
pool: &sqlx::PgPool,
|
||||
user_id: i64,
|
||||
@@ -3582,9 +4048,9 @@ async fn playlist_track_content_positions(
|
||||
pool: &sqlx::PgPool,
|
||||
playlist_id: i64,
|
||||
track_ids: &[i64],
|
||||
) -> Result<Vec<(String, i64)>> {
|
||||
) -> Result<Vec<(i64, String, i64)>> {
|
||||
let rows = sqlx::query(
|
||||
"SELECT c.content_id, pt.position
|
||||
"SELECT pt.track_id, c.content_id, pt.position
|
||||
FROM furumusic__playlist_track pt
|
||||
JOIN furumusic__track t ON t.id = pt.track_id
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
@@ -3600,9 +4066,10 @@ async fn playlist_track_content_positions(
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.filter_map(|row| {
|
||||
let track_id: i64 = row.get("track_id");
|
||||
let content_id: String = row.get("content_id");
|
||||
music_dht::normalize_content_id(&content_id)
|
||||
.map(|content_id| (content_id, row.get::<i32, _>("position") as i64))
|
||||
.map(|content_id| (track_id, content_id, row.get::<i32, _>("position") as i64))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -3658,7 +4125,7 @@ pub async fn playlist_content_ids_for_removal(
|
||||
async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<StoredDevice>> {
|
||||
let identity = ensure_identity(pool, user_id, "").await?;
|
||||
let rows = sqlx::query(
|
||||
"SELECT device_id, endpoint_ticket
|
||||
"SELECT device_id, endpoint_id, endpoint_ticket
|
||||
FROM furumusic__fed_device
|
||||
WHERE user_id = $1
|
||||
AND trusted_at_ms IS NOT NULL
|
||||
@@ -3674,11 +4141,31 @@ async fn active_remote_devices(pool: &sqlx::PgPool, user_id: i64) -> Result<Vec<
|
||||
.into_iter()
|
||||
.map(|row| StoredDevice {
|
||||
device_id: row.get("device_id"),
|
||||
endpoint_id: row.get("endpoint_id"),
|
||||
endpoint_ticket: row.get("endpoint_ticket"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn resolve_device_peer(
|
||||
service: &MusicDhtService,
|
||||
device: &StoredDevice,
|
||||
) -> Result<music_dht::EndpointId> {
|
||||
if let Ok(peer) = device.endpoint_id.parse::<music_dht::EndpointId>()
|
||||
&& (service.connected_peers().contains(&peer)
|
||||
|| service
|
||||
.known_peers()
|
||||
.iter()
|
||||
.any(|contact| contact.peer_id == peer))
|
||||
{
|
||||
// Prefer the live/current-schema DHT contact over a persisted ticket
|
||||
// that may have been issued before a schema upgrade.
|
||||
return Ok(peer);
|
||||
}
|
||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||
service.connect(ticket).await.map_err(Into::into)
|
||||
}
|
||||
|
||||
async fn active_device_count(pool: &sqlx::PgPool, user_id: i64) -> Result<i64> {
|
||||
sqlx::query_scalar(
|
||||
"SELECT COUNT(*) FROM furumusic__fed_device
|
||||
@@ -3742,11 +4229,19 @@ async fn ops_for_peer(
|
||||
AND a.origin_device_id = o.origin_device_id
|
||||
WHERE o.user_id = $1
|
||||
AND o.seq > COALESCE(a.max_seq, 0)
|
||||
AND (
|
||||
o.kind != 'playback_command'
|
||||
OR (
|
||||
o.hlc_ms >= $3
|
||||
AND o.payload_json->>'target_device_id' = $2
|
||||
)
|
||||
)
|
||||
ORDER BY o.hlc_ms, o.op_id
|
||||
LIMIT $3",
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(peer_device_id)
|
||||
.bind(now_ms().saturating_sub(PLAYBACK_COMMAND_TTL_MS))
|
||||
.bind(MAX_OPS_PER_BATCH)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
@@ -4100,6 +4595,7 @@ fn payload_kind(payload: &SyncOpPayload) -> &'static str {
|
||||
SyncOpPayload::DeviceTrusted { .. } => "device_trusted",
|
||||
SyncOpPayload::DeviceRevoked { .. } => "device_revoked",
|
||||
SyncOpPayload::PlaybackCommand { .. } => "playback_command",
|
||||
SyncOpPayload::ListenRecorded { .. } => "listen_recorded",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4215,6 +4711,12 @@ fn iso_from_ms(ms: i64) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn timestamp_from_iso(value: &str) -> Option<i64> {
|
||||
chrono::DateTime::parse_from_rfc3339(value)
|
||||
.ok()
|
||||
.map(|value| value.timestamp_millis())
|
||||
}
|
||||
|
||||
fn now_label() -> String {
|
||||
let secs = (now_ms() / 1000).max(0);
|
||||
format!(
|
||||
|
||||
+412
-11
@@ -5,26 +5,35 @@
|
||||
//! releases, tracks — names and small metadata, never files) into the
|
||||
//! shared DHT and serves audio, track metadata, cover art and per-artist
|
||||
//! catalogs to other peers (TUI clients) over the same wire protocols the
|
||||
//! clients speak among themselves. Serve-only: the server does not search
|
||||
//! or download from other peers.
|
||||
//! clients speak among themselves. The web player also searches known peers
|
||||
//! for catalog metadata and, when enabled, compatible similarity embeddings;
|
||||
//! local playback and the local library remain independent of the network.
|
||||
//!
|
||||
//! Settings are the regular admin config entries (`federation_enabled`,
|
||||
//! `federation_network_id`) and apply on the fly — saving the settings
|
||||
//! starts, stops or re-joins the node without a server restart.
|
||||
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly —
|
||||
//! saving the settings starts, stops or re-joins the node without a server
|
||||
//! restart.
|
||||
|
||||
mod capabilities;
|
||||
pub mod client;
|
||||
pub mod devices;
|
||||
mod receive;
|
||||
mod serve;
|
||||
mod similarity;
|
||||
mod storage;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::capabilities::CAPABILITIES_ALPN;
|
||||
use music_dht::similarity_dht::SimilarityDht;
|
||||
use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN;
|
||||
use music_dht::{
|
||||
ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, PublishStats,
|
||||
RendezvousConfig, SyncStats,
|
||||
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use sqlx::PgPool;
|
||||
@@ -34,12 +43,15 @@ use crate::config::AppConfig;
|
||||
use storage::PostgresFederationStorage;
|
||||
|
||||
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
||||
pub use similarity::SIMILARITY_ALPN;
|
||||
|
||||
/// How often the published library is re-synchronized with the database.
|
||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||
const TRANSPORT_SAMPLE_LIMIT: usize = 16;
|
||||
|
||||
struct Running {
|
||||
service: Arc<MusicDhtService>,
|
||||
similarity_dht: Arc<SimilarityDht>,
|
||||
network_name: String,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
@@ -50,17 +62,228 @@ struct ContentHashJob {
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedArtwork {
|
||||
bytes: Vec<u8>,
|
||||
mime: String,
|
||||
fetched_at: std::time::Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TransportSample {
|
||||
at: String,
|
||||
protocol: &'static str,
|
||||
direction: &'static str,
|
||||
phase: &'static str,
|
||||
peer_id: String,
|
||||
selected_path: String,
|
||||
open_paths: usize,
|
||||
direct_paths: usize,
|
||||
relay_paths: usize,
|
||||
custom_paths: usize,
|
||||
selected_rtt_ms: Option<u64>,
|
||||
selected_tx_bytes: u64,
|
||||
selected_rx_bytes: u64,
|
||||
total_tx_bytes: u64,
|
||||
total_rx_bytes: u64,
|
||||
lost_packets: u64,
|
||||
lost_bytes: u64,
|
||||
}
|
||||
|
||||
impl TransportSample {
|
||||
fn from_stats(
|
||||
protocol: &'static str,
|
||||
direction: &'static str,
|
||||
phase: &'static str,
|
||||
stats: ByteStreamConnectionStats,
|
||||
) -> Self {
|
||||
Self {
|
||||
at: now_iso(),
|
||||
protocol,
|
||||
direction,
|
||||
phase,
|
||||
peer_id: stats.peer_id.to_string(),
|
||||
selected_path: stats.selected_path.as_str().to_string(),
|
||||
open_paths: stats.open_paths,
|
||||
direct_paths: stats.direct_paths,
|
||||
relay_paths: stats.relay_paths,
|
||||
custom_paths: stats.custom_paths,
|
||||
selected_rtt_ms: stats
|
||||
.selected_rtt
|
||||
.map(|duration| duration.as_millis() as u64),
|
||||
selected_tx_bytes: stats.selected_tx_bytes,
|
||||
selected_rx_bytes: stats.selected_rx_bytes,
|
||||
total_tx_bytes: stats.total_tx_bytes,
|
||||
total_rx_bytes: stats.total_rx_bytes,
|
||||
lost_packets: stats.lost_packets,
|
||||
lost_bytes: stats.lost_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct TransportStatsState {
|
||||
total_samples: u64,
|
||||
direct_samples: u64,
|
||||
relay_samples: u64,
|
||||
custom_samples: u64,
|
||||
unknown_samples: u64,
|
||||
audio_samples: u64,
|
||||
catalog_samples: u64,
|
||||
sync_samples: u64,
|
||||
similarity_samples: u64,
|
||||
last: VecDeque<TransportSample>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct TransportStats {
|
||||
inner: std::sync::Mutex<TransportStatsState>,
|
||||
}
|
||||
|
||||
impl TransportStats {
|
||||
fn reset(&self) {
|
||||
*lock(&self.inner) = TransportStatsState::default();
|
||||
}
|
||||
|
||||
fn record(
|
||||
&self,
|
||||
protocol: &'static str,
|
||||
direction: &'static str,
|
||||
phase: &'static str,
|
||||
stats: ByteStreamConnectionStats,
|
||||
) {
|
||||
let sample = TransportSample::from_stats(protocol, direction, phase, stats);
|
||||
let mut state = lock(&self.inner);
|
||||
state.total_samples += 1;
|
||||
match sample.selected_path.as_str() {
|
||||
"direct" => state.direct_samples += 1,
|
||||
"relay" => state.relay_samples += 1,
|
||||
"custom" => state.custom_samples += 1,
|
||||
_ => state.unknown_samples += 1,
|
||||
}
|
||||
match protocol {
|
||||
"audio" => state.audio_samples += 1,
|
||||
"catalog" => state.catalog_samples += 1,
|
||||
"device-sync" => state.sync_samples += 1,
|
||||
"similarity" => state.similarity_samples += 1,
|
||||
_ => {}
|
||||
}
|
||||
state.last.push_front(sample);
|
||||
while state.last.len() > TRANSPORT_SAMPLE_LIMIT {
|
||||
state.last.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Value {
|
||||
let state = lock(&self.inner);
|
||||
let latest = state.last.front();
|
||||
json!({
|
||||
"total_samples": state.total_samples,
|
||||
"direct_samples": state.direct_samples,
|
||||
"relay_samples": state.relay_samples,
|
||||
"custom_samples": state.custom_samples,
|
||||
"unknown_samples": state.unknown_samples,
|
||||
"audio_samples": state.audio_samples,
|
||||
"catalog_samples": state.catalog_samples,
|
||||
"sync_samples": state.sync_samples,
|
||||
"similarity_samples": state.similarity_samples,
|
||||
"last_path": latest.map(|sample| sample.selected_path.clone()),
|
||||
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
|
||||
"last_peer": latest.map(|sample| sample.peer_id.clone()),
|
||||
"last": state.last.iter().map(|sample| json!({
|
||||
"at": sample.at,
|
||||
"protocol": sample.protocol,
|
||||
"direction": sample.direction,
|
||||
"phase": sample.phase,
|
||||
"peer_id": sample.peer_id,
|
||||
"selected_path": sample.selected_path,
|
||||
"open_paths": sample.open_paths,
|
||||
"direct_paths": sample.direct_paths,
|
||||
"relay_paths": sample.relay_paths,
|
||||
"custom_paths": sample.custom_paths,
|
||||
"selected_rtt_ms": sample.selected_rtt_ms,
|
||||
"selected_tx_bytes": sample.selected_tx_bytes,
|
||||
"selected_rx_bytes": sample.selected_rx_bytes,
|
||||
"total_tx_bytes": sample.total_tx_bytes,
|
||||
"total_rx_bytes": sample.total_rx_bytes,
|
||||
"lost_packets": sample.lost_packets,
|
||||
"lost_bytes": sample.lost_bytes,
|
||||
})).collect::<Vec<_>>(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn enrich_transport_users(pool: &PgPool, transport: &mut Value) {
|
||||
let Some(samples) = transport.get_mut("last").and_then(Value::as_array_mut) else {
|
||||
return;
|
||||
};
|
||||
let peer_ids: Vec<String> = samples
|
||||
.iter()
|
||||
.filter_map(|sample| sample.get("peer_id").and_then(Value::as_str))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
if peer_ids.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Ok(rows) = sqlx::query(
|
||||
"SELECT DISTINCT ON (d.endpoint_id)
|
||||
d.endpoint_id,
|
||||
COALESCE(NULLIF(u.display_name, ''), u.username::text) AS user_name
|
||||
FROM furumusic__fed_device d
|
||||
JOIN furumusic__user u ON u.id = d.user_id
|
||||
WHERE d.endpoint_id = ANY($1) AND d.revoked_at_ms IS NULL
|
||||
ORDER BY d.endpoint_id, d.last_seen_ms DESC NULLS LAST",
|
||||
)
|
||||
.bind(&peer_ids)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let users: HashMap<String, String> = rows
|
||||
.into_iter()
|
||||
.map(|row| (row.get("endpoint_id"), row.get("user_name")))
|
||||
.collect();
|
||||
for sample in samples {
|
||||
let Some(peer_id) = sample.get("peer_id").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Some(user_name) = users.get(peer_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(object) = sample.as_object_mut() {
|
||||
object.insert("user_name".to_owned(), Value::String(user_name.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_stream_transport(
|
||||
stats: &Arc<TransportStats>,
|
||||
protocol: &'static str,
|
||||
direction: &'static str,
|
||||
phase: &'static str,
|
||||
stream: &ByteStream,
|
||||
) {
|
||||
stats.record(protocol, direction, phase, stream.connection_stats());
|
||||
}
|
||||
|
||||
pub struct Federation {
|
||||
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
||||
/// Transport files and replaceable similarity-routing cache. Durable
|
||||
/// catalog DHT state and identity live in PostgreSQL.
|
||||
data_dir: PathBuf,
|
||||
database_url: std::sync::Mutex<String>,
|
||||
storage_dir: std::sync::Mutex<String>,
|
||||
save_on_listen: std::sync::atomic::AtomicBool,
|
||||
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
|
||||
content_pending: std::sync::Mutex<HashSet<i64>>,
|
||||
prepared_cache: std::sync::Mutex<HashMap<String, (PathBuf, String)>>,
|
||||
artwork_cache: std::sync::Mutex<HashMap<String, CachedArtwork>>,
|
||||
download_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
pool: tokio::sync::OnceCell<PgPool>,
|
||||
running: tokio::sync::Mutex<Option<Running>>,
|
||||
last_sync: std::sync::Mutex<Option<String>>,
|
||||
last_error: std::sync::Mutex<Option<String>>,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
@@ -81,12 +304,17 @@ pub fn handle() -> Arc<Federation> {
|
||||
data_dir: PathBuf::from(crate::media_paths::resolve_config_path("federation")),
|
||||
database_url: std::sync::Mutex::new(String::new()),
|
||||
storage_dir: std::sync::Mutex::new(String::new()),
|
||||
save_on_listen: std::sync::atomic::AtomicBool::new(false),
|
||||
content_cache: std::sync::Mutex::new(Default::default()),
|
||||
content_pending: std::sync::Mutex::new(Default::default()),
|
||||
prepared_cache: std::sync::Mutex::new(Default::default()),
|
||||
artwork_cache: std::sync::Mutex::new(Default::default()),
|
||||
download_locks: std::sync::Mutex::new(Default::default()),
|
||||
pool: tokio::sync::OnceCell::new(),
|
||||
running: tokio::sync::Mutex::new(None),
|
||||
last_sync: std::sync::Mutex::new(None),
|
||||
last_error: std::sync::Mutex::new(None),
|
||||
transport_stats: Arc::new(TransportStats::default()),
|
||||
})
|
||||
}))
|
||||
}
|
||||
@@ -131,7 +359,8 @@ impl Federation {
|
||||
let mut effective = config.clone();
|
||||
let rows = sqlx::query(
|
||||
"SELECT key, value FROM furumusic__config_entry
|
||||
WHERE key IN ('federation_enabled', 'federation_network_id', 'agent_storage_dir')",
|
||||
WHERE key IN ('federation_enabled', 'federation_network_id',
|
||||
'federation_save_on_listen', 'agent_storage_dir')",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
@@ -150,6 +379,11 @@ impl Federation {
|
||||
}
|
||||
}
|
||||
"federation_network_id" => effective.federation_network_id = value,
|
||||
"federation_save_on_listen" => {
|
||||
if let Ok(parsed) = value.parse() {
|
||||
effective.federation_save_on_listen = parsed;
|
||||
}
|
||||
}
|
||||
"agent_storage_dir" => {
|
||||
effective.agent_storage_dir = crate::media_paths::resolve_config_path(&value);
|
||||
}
|
||||
@@ -164,6 +398,10 @@ impl Federation {
|
||||
pub async fn apply(self: &Arc<Self>, config: &AppConfig) {
|
||||
*lock(&self.database_url) = config.database_url.clone();
|
||||
*lock(&self.storage_dir) = config.agent_storage_dir.clone();
|
||||
self.save_on_listen.store(
|
||||
config.federation_save_on_listen,
|
||||
std::sync::atomic::Ordering::Relaxed,
|
||||
);
|
||||
let network = config.federation_network_id.trim().to_string();
|
||||
if config.federation_enabled && !network.is_empty() {
|
||||
if let Err(err) = self.start(network, config.agent_storage_dir.clone()).await {
|
||||
@@ -189,6 +427,10 @@ impl Federation {
|
||||
|
||||
let dht_storage = Arc::new(PostgresFederationStorage::new(pool.clone()).await?);
|
||||
let secret_key = dht_storage.load_or_create_secret_key().await?;
|
||||
self.transport_stats.reset();
|
||||
tokio::fs::create_dir_all(&self.data_dir)
|
||||
.await
|
||||
.with_context(|| format!("creating {}", self.data_dir.display()))?;
|
||||
|
||||
let config = MusicDhtConfig::builder()
|
||||
.data_dir(&self.data_dir)
|
||||
@@ -198,6 +440,9 @@ impl Federation {
|
||||
.stream_protocol(AUDIO_ALPN)
|
||||
.stream_protocol(CATALOG_ALPN)
|
||||
.stream_protocol(devices::SYNC_ALPN)
|
||||
.schema_independent_stream_protocol(SIMILARITY_ALPN)
|
||||
.schema_independent_stream_protocol(SIMILARITY_DHT_ALPN)
|
||||
.schema_independent_stream_protocol(CAPABILITIES_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
let (service, mut events) =
|
||||
@@ -211,6 +456,25 @@ impl Federation {
|
||||
"federation started"
|
||||
);
|
||||
|
||||
let similarity_dht = SimilarityDht::open(
|
||||
Arc::clone(&service),
|
||||
self.data_dir.join("similarity-routing.sqlite3"),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("failed to start the similarity DHT: {error}"))?;
|
||||
let similarity_dht_acceptor = service
|
||||
.stream_acceptor(SIMILARITY_DHT_ALPN)
|
||||
.map_err(|error| anyhow::anyhow!("failed to take similarity DHT acceptor: {error}"))?;
|
||||
let similarity_dht_serve_task =
|
||||
tokio::spawn(Arc::clone(&similarity_dht).serve(similarity_dht_acceptor));
|
||||
let similarity_dht_maintenance_task =
|
||||
tokio::spawn(Arc::clone(&similarity_dht).maintenance());
|
||||
let similarity_manager = crate::similarity::handle();
|
||||
let similarity_dht_sync_task = tokio::spawn(similarity_route_sync_loop(
|
||||
Arc::clone(&similarity_dht),
|
||||
Arc::clone(&similarity_manager),
|
||||
));
|
||||
|
||||
// Drain DHT events into the log; the channel is bounded.
|
||||
let event_task = tokio::spawn(async move {
|
||||
while let Some(event) = events.recv().await {
|
||||
@@ -236,6 +500,7 @@ impl Federation {
|
||||
pool.clone(),
|
||||
storage_dir.clone(),
|
||||
service.endpoint_id(),
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
let catalog_acceptor = service
|
||||
.stream_acceptor(CATALOG_ALPN)
|
||||
@@ -245,6 +510,7 @@ impl Federation {
|
||||
pool.clone(),
|
||||
storage_dir,
|
||||
service.endpoint_id(),
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
let device_acceptor = service
|
||||
.stream_acceptor(devices::SYNC_ALPN)
|
||||
@@ -255,12 +521,31 @@ impl Federation {
|
||||
pool.clone(),
|
||||
Arc::clone(&service),
|
||||
Arc::clone(&device_hub),
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
let device_sync_task = tokio::spawn(devices::sync_loop(
|
||||
pool,
|
||||
Arc::clone(&service),
|
||||
device_hub,
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
let capabilities_acceptor = service
|
||||
.stream_acceptor(CAPABILITIES_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the capabilities acceptor: {err}"))?;
|
||||
let capabilities_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
|
||||
let similarity_acceptor = service
|
||||
.stream_acceptor(SIMILARITY_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?;
|
||||
let similarity_task = tokio::spawn(similarity::serve_peers(
|
||||
similarity_acceptor,
|
||||
similarity_manager,
|
||||
service.endpoint_id(),
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
let device_sync_task =
|
||||
tokio::spawn(devices::sync_loop(pool, Arc::clone(&service), device_hub));
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
similarity_dht,
|
||||
network_name,
|
||||
tasks: vec![
|
||||
event_task,
|
||||
@@ -269,6 +554,11 @@ impl Federation {
|
||||
catalog_task,
|
||||
device_task,
|
||||
device_sync_task,
|
||||
capabilities_task,
|
||||
similarity_task,
|
||||
similarity_dht_serve_task,
|
||||
similarity_dht_maintenance_task,
|
||||
similarity_dht_sync_task,
|
||||
],
|
||||
});
|
||||
self.set_error(None);
|
||||
@@ -292,6 +582,20 @@ impl Federation {
|
||||
.context("federation is not running")
|
||||
}
|
||||
|
||||
async fn similarity_services(&self) -> Result<(Arc<MusicDhtService>, Arc<SimilarityDht>)> {
|
||||
self.running
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|running| {
|
||||
(
|
||||
Arc::clone(&running.service),
|
||||
Arc::clone(&running.similarity_dht),
|
||||
)
|
||||
})
|
||||
.context("federation is not running")
|
||||
}
|
||||
|
||||
async fn spawn_sync_soon(self: &Arc<Self>) {
|
||||
if let Ok(service) = self.service().await {
|
||||
let fed = Arc::clone(self);
|
||||
@@ -589,13 +893,19 @@ impl Federation {
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
let mut transport = self.transport_stats.snapshot();
|
||||
if let Ok(pool) = self.pool().await {
|
||||
enrich_transport_users(&pool, &mut transport).await;
|
||||
}
|
||||
json!({
|
||||
"running": true,
|
||||
"network": running.network_name,
|
||||
"endpoint_id": service.endpoint_id().to_string(),
|
||||
"connected_peers": peers,
|
||||
"known_contacts": service.known_peers().len(),
|
||||
"similarity_routing_peers": running.similarity_dht.known_peers(),
|
||||
"published_items": published,
|
||||
"transport": transport,
|
||||
})
|
||||
}
|
||||
None => json!({ "running": false }),
|
||||
@@ -629,6 +939,26 @@ impl Federation {
|
||||
Ok(peer.to_string())
|
||||
}
|
||||
|
||||
pub async fn search_similarity(
|
||||
&self,
|
||||
query: crate::similarity::QueryVector,
|
||||
limit: usize,
|
||||
) -> Result<similarity::SimilaritySearchOutcome> {
|
||||
anyhow::ensure!(
|
||||
crate::similarity::handle().enabled(),
|
||||
"similarity search is disabled"
|
||||
);
|
||||
let (service, similarity_dht) = self.similarity_services().await?;
|
||||
similarity::search(
|
||||
service,
|
||||
similarity_dht,
|
||||
query,
|
||||
limit,
|
||||
Arc::clone(&self.transport_stats),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fed_device_status(
|
||||
&self,
|
||||
user_id: i64,
|
||||
@@ -668,6 +998,7 @@ impl Federation {
|
||||
&pool,
|
||||
service,
|
||||
crate::player::PlayerDeviceHub::shared(),
|
||||
Arc::clone(&self.transport_stats),
|
||||
user_id,
|
||||
user_name,
|
||||
invite,
|
||||
@@ -698,6 +1029,7 @@ impl Federation {
|
||||
&pool,
|
||||
service,
|
||||
crate::player::PlayerDeviceHub::shared(),
|
||||
Arc::clone(&self.transport_stats),
|
||||
user_id,
|
||||
)
|
||||
.await
|
||||
@@ -740,6 +1072,75 @@ impl Federation {
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn fed_device_web_active_takeover(
|
||||
&self,
|
||||
user_id: i64,
|
||||
previous_device_id: &str,
|
||||
state: serde_json::Value,
|
||||
) -> Result<()> {
|
||||
let pool = self.pool().await?;
|
||||
devices::record_web_active_takeover(&pool, user_id, previous_device_id, state).await
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+145
-61
@@ -3,18 +3,26 @@
|
||||
//! with the furumi TUI client and any other furumi peer.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::Result;
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor};
|
||||
pub use music_dht::catalog::CATALOG_ALPN;
|
||||
use music_dht::catalog::{
|
||||
CatalogArtist, CatalogArtistPreview, CatalogImageHeader as ImageHeader, CatalogRelease,
|
||||
CatalogRequest, CatalogResponse, CatalogTrack,
|
||||
};
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor, normalize_name};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row as _;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
|
||||
|
||||
use super::{TransportStats, record_stream_transport};
|
||||
|
||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||
/// ALPN of the per-artist catalog protocol.
|
||||
pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/1";
|
||||
/// Version of the peer-to-peer audio streaming protocol.
|
||||
pub const AUDIO_PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
/// Maximum size of a JSON protocol line (request or response header).
|
||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||
@@ -63,58 +71,6 @@ struct TrackMetadata {
|
||||
disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CatalogRequest {
|
||||
artist: String,
|
||||
#[serde(default)]
|
||||
want: Option<String>,
|
||||
#[serde(default)]
|
||||
release: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct CatalogResponse {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
artist: Option<CatalogArtist>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct CatalogArtist {
|
||||
name: String,
|
||||
releases: Vec<CatalogRelease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct CatalogRelease {
|
||||
title: String,
|
||||
release_type: String,
|
||||
year: Option<i32>,
|
||||
tracks: Vec<CatalogTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct CatalogTrack {
|
||||
title: String,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
duration_seconds: Option<f64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
content_id: Option<String>,
|
||||
item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
struct ImageHeader {
|
||||
ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
mime_type: String,
|
||||
size: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Framing helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -274,6 +230,31 @@ async fn track_artist_image_file(pool: &PgPool, track_id: i64) -> Result<Option<
|
||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||
}
|
||||
|
||||
async fn track_catalog_artist_names(
|
||||
pool: &PgPool,
|
||||
track_id: i64,
|
||||
) -> Result<(Vec<String>, Vec<String>)> {
|
||||
let mut artists = Vec::new();
|
||||
let mut featured = Vec::new();
|
||||
let rows = sqlx::query(
|
||||
"SELECT a.name, ta.role FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = $1 ORDER BY ta.position",
|
||||
)
|
||||
.bind(track_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
for row in rows {
|
||||
let name: String = row.get(0);
|
||||
match row.get::<String, _>(1).as_str() {
|
||||
"featuring" => featured.push(name),
|
||||
"main" => artists.push(name),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok((artists, featured))
|
||||
}
|
||||
|
||||
async fn track_metadata(pool: &PgPool, track_id: i64) -> Result<Option<TrackMetadata>> {
|
||||
let Some(track) = sqlx::query(
|
||||
"SELECT t.title, t.track_number, t.disc_number, COALESCE(t.year, r.year),
|
||||
@@ -344,13 +325,16 @@ pub async fn serve_audio(
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let pool = pool.clone();
|
||||
let storage_dir = storage_dir.clone();
|
||||
let transport_stats = Arc::clone(&transport_stats);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own).await {
|
||||
if let Err(err) = serve_audio_one(stream, pool, storage_dir, own, transport_stats).await
|
||||
{
|
||||
tracing::warn!(peer = %peer, "federation audio stream failed: {err:#}");
|
||||
}
|
||||
});
|
||||
@@ -362,7 +346,9 @@ async fn serve_audio_one(
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
record_stream_transport(&transport_stats, "audio", "inbound", "open", &stream);
|
||||
let request: AudioRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
@@ -465,6 +451,7 @@ async fn serve_audio_one(
|
||||
// Wait until the peer read everything before dropping the stream,
|
||||
// otherwise the tail of the file is lost.
|
||||
let _ = stream.send.stopped().await;
|
||||
record_stream_transport(&transport_stats, "audio", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -493,13 +480,17 @@ pub async fn serve_catalog(
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let pool = pool.clone();
|
||||
let storage_dir = storage_dir.clone();
|
||||
let transport_stats = Arc::clone(&transport_stats);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(err) = serve_catalog_one(stream, pool, storage_dir, own).await {
|
||||
if let Err(err) =
|
||||
serve_catalog_one(stream, pool, storage_dir, own, transport_stats).await
|
||||
{
|
||||
tracing::warn!(peer = %peer, "federation catalog request failed: {err:#}");
|
||||
}
|
||||
});
|
||||
@@ -511,7 +502,9 @@ async fn serve_catalog_one(
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
own: EndpointId,
|
||||
transport_stats: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
record_stream_transport(&transport_stats, "catalog", "inbound", "open", &stream);
|
||||
let request: CatalogRequest = serde_json::from_slice(&read_line(&mut stream.recv).await?)?;
|
||||
tracing::info!(
|
||||
peer = %stream.peer_id,
|
||||
@@ -527,7 +520,23 @@ async fn serve_catalog_one(
|
||||
Err(err) => CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("catalog lookup failed: {err:#}")),
|
||||
artist: None,
|
||||
..CatalogResponse::default()
|
||||
},
|
||||
};
|
||||
stream
|
||||
.send
|
||||
.write_all(&serde_json::to_vec(&response)?)
|
||||
.await?;
|
||||
}
|
||||
Some("artists") => {
|
||||
let cursor = request.cursor.clone();
|
||||
let limit = request.limit.unwrap_or(64).clamp(1, 200);
|
||||
let response = match build_artist_slice(&pool, cursor, limit).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("artist slice lookup failed: {err:#}")),
|
||||
..CatalogResponse::default()
|
||||
},
|
||||
};
|
||||
stream
|
||||
@@ -569,7 +578,7 @@ async fn serve_catalog_one(
|
||||
let response = CatalogResponse {
|
||||
ok: false,
|
||||
error: Some(format!("unknown request kind '{other}'")),
|
||||
artist: None,
|
||||
..CatalogResponse::default()
|
||||
};
|
||||
stream
|
||||
.send
|
||||
@@ -579,6 +588,7 @@ async fn serve_catalog_one(
|
||||
}
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
record_stream_transport(&transport_stats, "catalog", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -595,7 +605,7 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
|
||||
return Ok(CatalogResponse {
|
||||
ok: false,
|
||||
error: Some("artist not found in the library".to_string()),
|
||||
artist: None,
|
||||
..CatalogResponse::default()
|
||||
});
|
||||
};
|
||||
let artist_id: i64 = artist_row.get(0);
|
||||
@@ -630,8 +640,11 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
|
||||
for row in track_rows {
|
||||
let track_id: i64 = row.get(0);
|
||||
let duration: f64 = row.get(4);
|
||||
let (artists, featured_artists) = track_catalog_artist_names(pool, track_id).await?;
|
||||
tracks.push(CatalogTrack {
|
||||
title: row.get(1),
|
||||
artists,
|
||||
featured_artists,
|
||||
track_number: row.get(2),
|
||||
disc_number: row.get(3),
|
||||
duration_seconds: (duration > 0.0).then_some(duration),
|
||||
@@ -649,11 +662,82 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
|
||||
|
||||
Ok(CatalogResponse {
|
||||
ok: true,
|
||||
error: None,
|
||||
artist: Some(CatalogArtist {
|
||||
name: artist_row.get(1),
|
||||
releases,
|
||||
appears_on: Vec::new(),
|
||||
}),
|
||||
..CatalogResponse::default()
|
||||
})
|
||||
}
|
||||
|
||||
async fn build_artist_slice(
|
||||
pool: &PgPool,
|
||||
cursor: Option<String>,
|
||||
limit: usize,
|
||||
) -> Result<CatalogResponse> {
|
||||
let offset = cursor
|
||||
.as_deref()
|
||||
.and_then(|value| value.parse::<i64>().ok())
|
||||
.unwrap_or(0)
|
||||
.max(0);
|
||||
let rows = sqlx::query(
|
||||
r#"SELECT a.name::text AS name,
|
||||
mf.file_path::text AS image_path,
|
||||
COALESCE(s.release_count, 0)::bigint AS release_count,
|
||||
COALESCE(s.track_count, 0)::bigint AS track_count
|
||||
FROM furumusic__artist a
|
||||
LEFT JOIN furumusic__media_file mf ON mf.id = a.image_file_id
|
||||
LEFT JOIN (
|
||||
SELECT appearance.artist_id,
|
||||
COUNT(DISTINCT appearance.release_id) FILTER (WHERE appearance.is_primary_release_artist) AS release_count,
|
||||
COUNT(DISTINCT appearance.track_id) AS track_count
|
||||
FROM (
|
||||
SELECT ta.artist_id,
|
||||
t.id AS track_id,
|
||||
r.id AS release_id,
|
||||
primary_release.artist_id IS NOT NULL AS is_primary_release_artist
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
|
||||
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
|
||||
LEFT JOIN furumusic__release_artist primary_release
|
||||
ON primary_release.release_id = r.id
|
||||
AND primary_release.artist_id = ta.artist_id
|
||||
AND primary_release.position = 0
|
||||
) appearance
|
||||
GROUP BY appearance.artist_id
|
||||
) s ON s.artist_id = a.id
|
||||
WHERE a.is_hidden = false
|
||||
AND COALESCE(s.track_count, 0) > 0
|
||||
ORDER BY (COALESCE(s.release_count, 0) > 0) DESC,
|
||||
COALESCE(s.release_count, 0) DESC,
|
||||
COALESCE(s.track_count, 0) DESC,
|
||||
a.name_sort
|
||||
LIMIT $1 OFFSET $2"#,
|
||||
)
|
||||
.bind(limit as i64 + 1)
|
||||
.bind(offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut artists = Vec::with_capacity(rows.len().min(limit));
|
||||
let has_more = rows.len() > limit;
|
||||
for row in rows.into_iter().take(limit) {
|
||||
let name: String = row.get(0);
|
||||
artists.push(CatalogArtistPreview {
|
||||
artist_key: normalize_name(&name),
|
||||
name,
|
||||
image_path: row.get(1),
|
||||
release_count: row.get(2),
|
||||
track_count: row.get(3),
|
||||
});
|
||||
}
|
||||
let next_cursor = has_more.then(|| (offset + artists.len() as i64).to_string());
|
||||
Ok(CatalogResponse {
|
||||
ok: true,
|
||||
artists,
|
||||
next_cursor,
|
||||
..CatalogResponse::default()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Furumusic policy and PostgreSQL adapter for the shared similarity protocol.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use futures_util::stream::{self, StreamExt as _};
|
||||
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||
use music_dht::similarity_dht::SimilarityDht;
|
||||
use music_dht::{
|
||||
ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, PeerTicket, StreamAcceptor,
|
||||
};
|
||||
|
||||
use crate::similarity::{Manager, QueryVector};
|
||||
|
||||
use super::TransportStats;
|
||||
|
||||
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||
|
||||
const INITIAL_QUERY_PEERS: usize = 16;
|
||||
const MAX_QUERY_PEERS: usize = 48;
|
||||
const QUERY_CONCURRENCY: usize = 8;
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const ROUTING_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_PER_ARTIST: usize = 3;
|
||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteSimilarityTrack {
|
||||
pub owner: String,
|
||||
pub item_id: String,
|
||||
pub title: String,
|
||||
pub artist_names: Vec<String>,
|
||||
pub featured_artist_names: Vec<String>,
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
pub content_id: Option<String>,
|
||||
pub release_title: Option<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub similarity_score: f32,
|
||||
}
|
||||
|
||||
pub struct SimilaritySearchOutcome {
|
||||
pub tracks: Vec<RemoteSimilarityTrack>,
|
||||
pub queried_peers: usize,
|
||||
}
|
||||
|
||||
pub async fn serve_peers(
|
||||
mut acceptor: StreamAcceptor,
|
||||
manager: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
transport: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let manager = Arc::clone(&manager);
|
||||
let transport = Arc::clone(&transport);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(error) = serve_one(stream, manager, own, transport).await {
|
||||
tracing::warn!(peer = %peer, "similarity request failed: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(
|
||||
mut stream: ByteStream,
|
||||
manager: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
|
||||
let request = wire::read_request(&mut stream).await?;
|
||||
let response = if !manager.enabled() {
|
||||
SimilarityResponse::refused("similarity search is disabled on this instance")?
|
||||
} else {
|
||||
let profile_id = request.profile_id;
|
||||
let vector = request.vector;
|
||||
let limit = request.limit;
|
||||
let rank_manager = Arc::clone(&manager);
|
||||
let ranked = tokio::task::spawn_blocking(move || {
|
||||
rank_manager.rank_vector(&profile_id, &vector, None, None, limit)
|
||||
})
|
||||
.await
|
||||
.context("local similarity task failed")
|
||||
.and_then(|result| result);
|
||||
match ranked {
|
||||
Ok(ranked) => {
|
||||
let ids = ranked
|
||||
.iter()
|
||||
.map(|track| track.track_id)
|
||||
.collect::<Vec<_>>();
|
||||
match manager.metadata_for_tracks(&ids).await {
|
||||
Ok(metadata) => {
|
||||
let by_id = ranked
|
||||
.into_iter()
|
||||
.map(|track| (track.track_id, track))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let hits = metadata
|
||||
.into_iter()
|
||||
.filter_map(|track| {
|
||||
let ranked = by_id.get(&track.track_id)?;
|
||||
let hit = SimilarityHit {
|
||||
score: ranked.score,
|
||||
item_id: hex(
|
||||
ItemId::derive(
|
||||
&own,
|
||||
ItemKind::Track,
|
||||
&format!("track:{}", track.track_id),
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
title: track.title,
|
||||
artist_names: track.artist_names,
|
||||
featured_artist_names: track.featured_artist_names,
|
||||
year: track.year,
|
||||
duration_seconds: Some(track.duration_seconds.round() as i64),
|
||||
content_id: track.content_id,
|
||||
release_title: Some(track.release_title),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
embedding_signature: Some(ranked.embedding_signature),
|
||||
};
|
||||
match hit.validate() {
|
||||
Ok(()) => Some(hit),
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "invalid local similarity metadata skipped");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SimilarityResponse::success(hits)?
|
||||
}
|
||||
Err(error) => SimilarityResponse::refused(format!(
|
||||
"similarity metadata is unavailable: {error:#}"
|
||||
))?,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
SimilarityResponse::refused(format!("similarity query is unavailable: {error:#}"))?
|
||||
}
|
||||
}
|
||||
};
|
||||
wire::write_response(&mut stream, &response).await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
service: Arc<MusicDhtService>,
|
||||
routing: Arc<SimilarityDht>,
|
||||
query: QueryVector,
|
||||
limit: usize,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<SimilaritySearchOutcome> {
|
||||
let own = service.endpoint_id();
|
||||
let routed = match tokio::time::timeout(
|
||||
ROUTING_TIMEOUT,
|
||||
routing.find_peers(&query.profile_id, &query.vector, MAX_QUERY_PEERS),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(peers)) => peers,
|
||||
Err(_) => {
|
||||
tracing::debug!("similarity DHT lookup timed out; using known peers");
|
||||
Vec::new()
|
||||
}
|
||||
Ok(Err(error)) => {
|
||||
tracing::debug!(%error, "similarity DHT lookup unavailable; using known peers");
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let mut seen = HashSet::new();
|
||||
let mut peers: Vec<QueryPeer> = routed
|
||||
.into_iter()
|
||||
.filter_map(|ticket| {
|
||||
let owner = ticket.endpoint_id();
|
||||
(owner != own && seen.insert(owner)).then_some(QueryPeer {
|
||||
owner,
|
||||
ticket: Some(ticket),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for peer in service
|
||||
.connected_peers()
|
||||
.into_iter()
|
||||
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||
{
|
||||
if peer != own && seen.insert(peer) {
|
||||
peers.push(QueryPeer {
|
||||
owner: peer,
|
||||
ticket: None,
|
||||
});
|
||||
}
|
||||
if peers.len() >= MAX_QUERY_PEERS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||
let request = Arc::new(SimilarityRequest::new(
|
||||
query.profile_id,
|
||||
query.vector,
|
||||
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||
)?);
|
||||
|
||||
let mut hits = Vec::new();
|
||||
let initial = peers.len().min(INITIAL_QUERY_PEERS);
|
||||
let mut queried_peers = initial;
|
||||
let responses = query_peers(
|
||||
Arc::clone(&service),
|
||||
&peers[..initial],
|
||||
Arc::clone(&request),
|
||||
Arc::clone(&transport),
|
||||
)
|
||||
.await;
|
||||
let mut successful = 0usize;
|
||||
for response in responses {
|
||||
match response {
|
||||
Ok(peer_hits) => {
|
||||
successful += 1;
|
||||
hits.extend(peer_hits);
|
||||
}
|
||||
Err(error) => tracing::debug!(%error, "similarity peer query skipped"),
|
||||
}
|
||||
}
|
||||
if initial < peers.len() && (hits.len() < limit || successful < initial.min(4)) {
|
||||
queried_peers += peers.len() - initial;
|
||||
for response in query_peers(
|
||||
Arc::clone(&service),
|
||||
&peers[initial..],
|
||||
Arc::clone(&request),
|
||||
Arc::clone(&transport),
|
||||
)
|
||||
.await
|
||||
{
|
||||
match response {
|
||||
Ok(peer_hits) => hits.extend(peer_hits),
|
||||
Err(error) => tracing::debug!(%error, "fallback similarity peer query skipped"),
|
||||
}
|
||||
}
|
||||
}
|
||||
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||
let mut dedup = HashSet::new();
|
||||
let mut signatures = vec![query_signature];
|
||||
let mut artist_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut tracks = Vec::new();
|
||||
for (track, _, signature) in hits {
|
||||
if query
|
||||
.source_content_id
|
||||
.as_deref()
|
||||
.is_some_and(|source| track.content_id.as_deref() == Some(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = track
|
||||
.content_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
|
||||
if !dedup.insert(key) {
|
||||
continue;
|
||||
}
|
||||
if signature.is_some_and(|candidate| {
|
||||
signatures.iter().any(|existing| {
|
||||
wire::signature_distance(&candidate, existing)
|
||||
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let artist = track
|
||||
.artist_names
|
||||
.first()
|
||||
.map(|name| music_dht::normalize_name(name))
|
||||
.unwrap_or_default();
|
||||
let count = artist_counts.entry(artist.clone()).or_default();
|
||||
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
|
||||
continue;
|
||||
}
|
||||
*count += 1;
|
||||
if let Some(signature) = signature {
|
||||
signatures.push(signature);
|
||||
}
|
||||
tracks.push(track);
|
||||
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(SimilaritySearchOutcome {
|
||||
tracks,
|
||||
queried_peers,
|
||||
})
|
||||
}
|
||||
|
||||
type PeerHits = Vec<(
|
||||
RemoteSimilarityTrack,
|
||||
f32,
|
||||
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
)>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct QueryPeer {
|
||||
owner: EndpointId,
|
||||
ticket: Option<PeerTicket>,
|
||||
}
|
||||
|
||||
async fn query_peers(
|
||||
service: Arc<MusicDhtService>,
|
||||
peers: &[QueryPeer],
|
||||
request: Arc<SimilarityRequest>,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Vec<Result<PeerHits>> {
|
||||
stream::iter(peers.iter().cloned().map(|peer| {
|
||||
let service = Arc::clone(&service);
|
||||
let request = Arc::clone(&request);
|
||||
let transport = Arc::clone(&transport);
|
||||
async move {
|
||||
tokio::time::timeout(
|
||||
QUERY_TIMEOUT,
|
||||
query_peer(service, peer, &request, transport),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(QUERY_CONCURRENCY)
|
||||
.collect()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn query_peer(
|
||||
service: Arc<MusicDhtService>,
|
||||
peer: QueryPeer,
|
||||
request: &SimilarityRequest,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<PeerHits> {
|
||||
let owner = peer.owner;
|
||||
let mut stream = match peer.ticket {
|
||||
Some(ticket) => service.open_stream_to(&ticket, SIMILARITY_ALPN).await,
|
||||
None => service.open_stream(owner, SIMILARITY_ALPN).await,
|
||||
}
|
||||
.map_err(|error| anyhow::anyhow!("cannot reach similarity peer: {error}"))?;
|
||||
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
||||
let response = wire::exchange(&mut stream, request).await?;
|
||||
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||
anyhow::ensure!(
|
||||
response.ok,
|
||||
"peer refused similarity query: {}",
|
||||
response.error.unwrap_or_default()
|
||||
);
|
||||
Ok(response
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| {
|
||||
let score = hit.score;
|
||||
let signature = hit.embedding_signature;
|
||||
(
|
||||
RemoteSimilarityTrack {
|
||||
owner: owner.to_string(),
|
||||
item_id: hit.item_id,
|
||||
title: hit.title,
|
||||
artist_names: hit.artist_names,
|
||||
featured_artist_names: hit.featured_artist_names,
|
||||
year: hit.year,
|
||||
duration_seconds: hit.duration_seconds,
|
||||
content_id: hit.content_id,
|
||||
release_title: hit.release_title,
|
||||
track_number: hit.track_number,
|
||||
disc_number: hit.disc_number,
|
||||
similarity_score: score,
|
||||
},
|
||||
score,
|
||||
signature,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hamming_threshold_keeps_exact_and_near_duplicates_out() {
|
||||
let query = [0u8; wire::SIMILARITY_SIGNATURE_BYTES];
|
||||
let mut near = query;
|
||||
near[0] = 0b0000_0111;
|
||||
assert!(wire::signature_distance(&query, &near) <= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE);
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -96,9 +96,9 @@ translations! {
|
||||
settings_swagger: "Swagger UI" , "Swagger UI";
|
||||
settings_swagger_help: "Serves interactive API docs at /swagger/ (requires restart)" , "Интерактивная документация API на /swagger/ (требуется перезапуск)";
|
||||
settings_lastfm_api_key: "Last.fm API key" , "API ключ Last.fm";
|
||||
settings_lastfm_api_key_help: "Used for Last.fm popularity and account connection" , "Используется для популярности Last.fm и подключения аккаунта";
|
||||
settings_lastfm_api_key_help: "Identifies this application to Last.fm and enables metadata, popularity data, and user account connection" , "Идентифицирует приложение в Last.fm и включает метаданные, данные о популярности и подключение аккаунтов";
|
||||
settings_lastfm_shared_secret: "Last.fm shared secret" , "Shared secret Last.fm";
|
||||
settings_lastfm_shared_secret_help: "Required for signed Last.fm scrobbling requests" , "Нужен для подписанных запросов скробблинга Last.fm";
|
||||
settings_lastfm_shared_secret_help: "Authenticates signed Last.fm requests, including scrobbling. Keep this value private" , "Подтверждает подписанные запросы Last.fm, включая скробблинг. Не раскрывайте это значение";
|
||||
|
||||
// OIDC login errors
|
||||
login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз.";
|
||||
@@ -298,6 +298,9 @@ translations! {
|
||||
player_likes_playlist: "Likes" , "Лайки";
|
||||
player_listened: "listened" , "прослушано";
|
||||
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
||||
player_search_similar_to: "Search similar to:" , "Поиск похожих на:";
|
||||
player_find_similar: "Find similar tracks" , "Найти похожие треки";
|
||||
player_similarity_failed: "Similarity search failed" , "Не удалось найти похожие треки";
|
||||
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
||||
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||
player_active_device: "Active device" , "Активный девайс";
|
||||
@@ -314,6 +317,9 @@ translations! {
|
||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||
player_rename: "Rename" , "Переименовать";
|
||||
player_close: "Close" , "Закрыть";
|
||||
player_interface_language: "Interface language" , "Язык интерфейса";
|
||||
player_language_description: "Choose the language used by the web player." , "Выберите язык интерфейса веб-плеера.";
|
||||
player_switch_language: "Русский" , "English";
|
||||
player_log_out: "Log out" , "Выйти";
|
||||
player_admin_panel: "Admin Panel" , "Админка";
|
||||
player_info: "Info" , "Информация";
|
||||
|
||||
+5
-5
@@ -447,17 +447,17 @@ async fn fetch_pending_scrobbles(
|
||||
o.duration_seconds,
|
||||
o.attempt_count,
|
||||
a.session_key::text AS session_key,
|
||||
t.title::text AS title,
|
||||
r.title::text AS album_title,
|
||||
COALESCE(o.track_title, t.title::text) AS title,
|
||||
COALESCE(o.album_title, r.title::text) AS album_title,
|
||||
t.track_number,
|
||||
(
|
||||
COALESCE(o.artist_name, (
|
||||
SELECT ar.name::text
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist ar ON ar.id = ta.artist_id
|
||||
WHERE ta.track_id = t.id AND ta.role <> 'featuring'
|
||||
ORDER BY ta.position
|
||||
LIMIT 1
|
||||
) AS artist_name,
|
||||
)) AS artist_name,
|
||||
(
|
||||
SELECT ar.name::text
|
||||
FROM furumusic__release_artist ra
|
||||
@@ -468,7 +468,7 @@ async fn fetch_pending_scrobbles(
|
||||
) AS album_artist_name
|
||||
FROM furumusic__lastfm_scrobble_outbox o
|
||||
JOIN furumusic__lastfm_account a ON a.user_id = o.user_id
|
||||
JOIN furumusic__track t ON t.id = o.track_id
|
||||
LEFT JOIN furumusic__track t ON t.id = o.track_id
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||
WHERE o.user_id = $1
|
||||
AND o.status IN ('pending', 'retry')
|
||||
|
||||
@@ -13,6 +13,7 @@ mod music;
|
||||
mod oidc;
|
||||
mod player;
|
||||
mod scheduler;
|
||||
mod similarity;
|
||||
mod torrents;
|
||||
mod user;
|
||||
|
||||
@@ -567,6 +568,13 @@ impl Project for FuruProject {
|
||||
federation::handle().boot(&fed_config).await;
|
||||
});
|
||||
|
||||
// Embedding calculation is an independent, server-wide background
|
||||
// service. It remains useful locally when federation is disabled.
|
||||
let similarity_config = Arc::clone(&self.app_config);
|
||||
tokio::spawn(async move {
|
||||
similarity::handle().boot(&similarity_config).await;
|
||||
});
|
||||
|
||||
apps.register(cot::session::db::SessionApp::new());
|
||||
apps.register_with_views(
|
||||
FuruApp {
|
||||
|
||||
@@ -2223,6 +2223,352 @@ pub mod db_migrations {
|
||||
&[Operation::custom(ensure_federation_content_id_cache).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_content_addressed_music_refs(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
// A track reference is durable user-facing identity. `local_track_id`
|
||||
// is availability, not identity: it may become non-NULL after a
|
||||
// federated track is materialized without changing likes/playlists.
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__track_ref (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
content_id TEXT NOT NULL UNIQUE,
|
||||
local_track_id BIGINT UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
release_title TEXT,
|
||||
year INTEGER,
|
||||
duration_seconds DOUBLE PRECISION,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
metadata_authority TEXT NOT NULL DEFAULT 'local',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_track_ref_local_track
|
||||
ON furumusic__track_ref (local_track_id)
|
||||
WHERE local_track_id IS NOT NULL",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_track_source (
|
||||
track_ref_id BIGINT NOT NULL REFERENCES furumusic__track_ref(id)
|
||||
ON DELETE CASCADE,
|
||||
owner_peer_id TEXT NOT NULL,
|
||||
item_id TEXT NOT NULL,
|
||||
last_seen_ms BIGINT NOT NULL,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
PRIMARY KEY (owner_peer_id, item_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_federation_track_source_ref
|
||||
ON furumusic__federation_track_source (track_ref_id, last_seen_ms DESC)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"ALTER TABLE furumusic__user_liked_track
|
||||
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
|
||||
REFERENCES furumusic__track_ref(id)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_user_liked_track_ref_uniq
|
||||
ON furumusic__user_liked_track (user_id, track_ref_id)
|
||||
WHERE track_ref_id IS NOT NULL",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"ALTER TABLE furumusic__playlist_track
|
||||
ADD COLUMN IF NOT EXISTS track_ref_id BIGINT
|
||||
REFERENCES furumusic__track_ref(id)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_playlist_track_ref
|
||||
ON furumusic__playlist_track (track_ref_id)
|
||||
WHERE track_ref_id IS NOT NULL",
|
||||
)
|
||||
.await?;
|
||||
// History deliberately remains local-track based. Only the web
|
||||
// player's existing playback report records history and triggers
|
||||
// Last.fm scrobbling.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0040CreateContentAddressedMusicRefs;
|
||||
|
||||
impl migrations::Migration for M0040CreateContentAddressedMusicRefs {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0040_create_content_addressed_music_refs";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0039_ensure_federation_content_id_cache",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_content_addressed_music_refs).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_synced_listen_history(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__listen_event (
|
||||
user_id BIGINT NOT NULL,
|
||||
listen_id TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
local_track_id BIGINT,
|
||||
origin_device_id TEXT NOT NULL,
|
||||
started_at_ms BIGINT NOT NULL,
|
||||
listened_ms BIGINT NOT NULL,
|
||||
track_duration_ms BIGINT,
|
||||
ended_reason TEXT NOT NULL,
|
||||
qualified BOOLEAN NOT NULL,
|
||||
metadata_json JSONB NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, listen_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_listen_event_user_time
|
||||
ON furumusic__listen_event (user_id, started_at_ms DESC, listen_id)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_listen_event_content
|
||||
ON furumusic__listen_event (user_id, content_id)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"INSERT INTO furumusic__listen_event
|
||||
(user_id, listen_id, content_id, local_track_id,
|
||||
origin_device_id, started_at_ms, listened_ms,
|
||||
track_duration_ms, ended_reason, qualified,
|
||||
metadata_json, created_at)
|
||||
SELECT ph.user_id,
|
||||
'legacy-web:' || ph.id::text,
|
||||
tr.content_id,
|
||||
ph.track_id,
|
||||
ident.device_id,
|
||||
(EXTRACT(EPOCH FROM ph.played_at::timestamptz) * 1000)::bigint,
|
||||
COALESCE(ph.duration_listened, 0)::bigint * 1000,
|
||||
(t.duration_seconds * 1000)::bigint,
|
||||
CASE WHEN ph.completed THEN '\"finished\"' ELSE '\"unknown\"' END,
|
||||
ph.completed,
|
||||
jsonb_build_object(
|
||||
'title', t.title::text,
|
||||
'artist_names', COALESCE((
|
||||
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = t.id
|
||||
AND ta.role <> 'featuring'
|
||||
), '[]'::jsonb),
|
||||
'featured_artist_names', COALESCE((
|
||||
SELECT jsonb_agg(a.name::text ORDER BY ta.position)
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE ta.track_id = t.id
|
||||
AND ta.role = 'featuring'
|
||||
), '[]'::jsonb),
|
||||
'release_title', r.title::text
|
||||
),
|
||||
ph.played_at::text
|
||||
FROM furumusic__play_history ph
|
||||
JOIN furumusic__track t ON t.id = ph.track_id
|
||||
JOIN furumusic__track_ref tr ON tr.local_track_id = ph.track_id
|
||||
JOIN furumusic__fed_device_identity ident
|
||||
ON ident.user_id = ph.user_id
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||
ON CONFLICT (user_id, listen_id) DO NOTHING",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"ALTER TABLE furumusic__lastfm_scrobble_outbox
|
||||
ALTER COLUMN track_id DROP NOT NULL",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"ALTER TABLE furumusic__lastfm_scrobble_outbox
|
||||
ADD COLUMN IF NOT EXISTS track_title TEXT,
|
||||
ADD COLUMN IF NOT EXISTS artist_name TEXT,
|
||||
ADD COLUMN IF NOT EXISTS album_title TEXT",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0041CreateSyncedListenHistory;
|
||||
|
||||
impl migrations::Migration for M0041CreateSyncedListenHistory {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0041_create_synced_listen_history";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0040_create_content_addressed_music_refs",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_synced_listen_history).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn repair_legacy_listen_qualification(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"UPDATE furumusic__listen_event le
|
||||
SET qualified = (
|
||||
ph.completed
|
||||
OR (
|
||||
COALESCE(ph.duration_listened, 0) >= 5
|
||||
AND COALESCE(t.duration_seconds, 0) > 0
|
||||
AND COALESCE(ph.duration_listened, 0) >= LEAST(
|
||||
COALESCE(t.duration_seconds, 0) / 2.0,
|
||||
240.0
|
||||
)
|
||||
)
|
||||
)
|
||||
FROM furumusic__play_history ph
|
||||
JOIN furumusic__track t ON t.id = ph.track_id
|
||||
WHERE le.user_id = ph.user_id
|
||||
AND le.listen_id = 'legacy-web:' || ph.id::text",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0042RepairLegacyListenQualification;
|
||||
|
||||
impl migrations::Migration for M0042RepairLegacyListenQualification {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0042_repair_legacy_listen_qualification";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0041_create_synced_listen_history",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(repair_legacy_listen_qualification).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_similarity_embeddings(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__similarity_profile (
|
||||
profile_id TEXT PRIMARY KEY,
|
||||
model_id TEXT NOT NULL,
|
||||
model_version TEXT NOT NULL,
|
||||
model_sha256 TEXT NOT NULL,
|
||||
preprocessing TEXT NOT NULL,
|
||||
dimensions INTEGER NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TEXT NOT NULL
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE UNIQUE INDEX IF NOT EXISTS idx_similarity_profile_active
|
||||
ON furumusic__similarity_profile (active)
|
||||
WHERE active = TRUE",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__track_embedding (
|
||||
track_id BIGINT NOT NULL REFERENCES furumusic__track(id)
|
||||
ON DELETE CASCADE,
|
||||
profile_id TEXT NOT NULL REFERENCES furumusic__similarity_profile(profile_id)
|
||||
ON DELETE CASCADE,
|
||||
dimensions INTEGER NOT NULL,
|
||||
vector BYTEA NOT NULL,
|
||||
source_sha256 TEXT NOT NULL,
|
||||
source_content_id TEXT,
|
||||
computed_at TEXT NOT NULL,
|
||||
PRIMARY KEY (track_id, profile_id)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_track_embedding_profile
|
||||
ON furumusic__track_embedding (profile_id, track_id)",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0043CreateSimilarityEmbeddings;
|
||||
|
||||
impl migrations::Migration for M0043CreateSimilarityEmbeddings {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0043_create_similarity_embeddings";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0042_repair_legacy_listen_qualification",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_similarity_embeddings).build()];
|
||||
}
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn add_similarity_routing_signature(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"ALTER TABLE furumusic__track_embedding
|
||||
ADD COLUMN IF NOT EXISTS routing_signature BYTEA",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0044AddSimilarityRoutingSignature;
|
||||
|
||||
impl migrations::Migration for M0044AddSimilarityRoutingSignature {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0044_add_similarity_routing_signature";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0043_create_similarity_embeddings",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(add_similarity_routing_signature).build()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0006CreateMediaFile,
|
||||
&M0007CreateArtist,
|
||||
@@ -2253,5 +2599,10 @@ pub mod db_migrations {
|
||||
&M0037CreatePlaylistShareLinks,
|
||||
&M0038CreateFedDeviceSync,
|
||||
&M0039EnsureFederationContentIdCache,
|
||||
&M0040CreateContentAddressedMusicRefs,
|
||||
&M0041CreateSyncedListenHistory,
|
||||
&M0042RepairLegacyListenQualification,
|
||||
&M0043CreateSimilarityEmbeddings,
|
||||
&M0044AddSimilarityRoutingSignature,
|
||||
];
|
||||
}
|
||||
|
||||
+48
-3
@@ -51,6 +51,7 @@ pub(super) struct ArtistRef {
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub(super) struct TrackItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) content_id: Option<String>,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
@@ -77,6 +78,8 @@ pub(super) struct TrackItem {
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlaylistTrackItem {
|
||||
pub(super) playlist_track_id: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub(super) sort_key: Option<i64>,
|
||||
#[serde(flatten)]
|
||||
pub(super) track: TrackItem,
|
||||
}
|
||||
@@ -538,14 +541,16 @@ pub(super) struct UserUploadReviewUpdateRequest {
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayHistoryItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) track_id: i64,
|
||||
pub(super) id: String,
|
||||
pub(super) track_id: Option<i64>,
|
||||
pub(super) track_title: String,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) track: TrackItem,
|
||||
pub(super) track: serde_json::Value,
|
||||
pub(super) played_at: String,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
pub(super) device_id: String,
|
||||
pub(super) device_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -561,6 +566,46 @@ pub(super) struct LikeStatus {
|
||||
pub(super) liked: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct ContentTrackMutation {
|
||||
pub(super) content_id: String,
|
||||
pub(super) liked: Option<bool>,
|
||||
pub(super) playlist_id: Option<i64>,
|
||||
pub(super) position: Option<i64>,
|
||||
pub(super) federation: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct PrepareFederatedTrackRequest {
|
||||
pub(super) content_id: String,
|
||||
pub(super) owner: Option<String>,
|
||||
pub(super) item_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FederationArtworkQuery {
|
||||
pub(super) owner: String,
|
||||
pub(super) item_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FederationArtistQuery {
|
||||
pub(super) name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FederationCatalogArtworkQuery {
|
||||
pub(super) owner: String,
|
||||
pub(super) artist: String,
|
||||
pub(super) release: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct FederationArtworkDiscoveryQuery {
|
||||
pub(super) artist: String,
|
||||
pub(super) release: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct LikedIds {
|
||||
pub(super) track_ids: Vec<i64>,
|
||||
|
||||
+1477
-220
File diff suppressed because it is too large
Load Diff
@@ -3,9 +3,11 @@ use serde::Deserialize;
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct HistoryEntry {
|
||||
pub(super) track_id: i64,
|
||||
pub(super) listen_id: Option<String>,
|
||||
pub(super) started_at: Option<i64>,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
pub(super) ended_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -254,34 +254,6 @@ pub(super) struct ReleaseUploaderRow {
|
||||
pub(super) track_count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlayHistoryTrackRow {
|
||||
pub(super) history_id: i64,
|
||||
pub(super) played_at: String,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) release_id: i64,
|
||||
pub(super) release_title: String,
|
||||
pub(super) release_year: Option<i32>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
pub(super) lastfm_listeners: Option<i64>,
|
||||
pub(super) lastfm_playcount: Option<i64>,
|
||||
pub(super) lastfm_rating: Option<f64>,
|
||||
pub(super) lastfm_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseInfoRow {
|
||||
pub(super) id: i64,
|
||||
|
||||
+1461
File diff suppressed because it is too large
Load Diff
+512
-58
@@ -806,35 +806,68 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
max-width: none;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(620px, 1fr) minmax(360px, 440px);
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
grid-template-areas:
|
||||
"access access access access oidc oidc oidc oidc oidc oidc oidc oidc"
|
||||
"agent agent agent agent agent agent agent agent agentstatus agentstatus agentstatus agentstatus"
|
||||
"similarity similarity similarity similarity similarity similarity similarity similarity similaritystatus similaritystatus similaritystatus similaritystatus"
|
||||
"federation federation federation federation federation federation federation federation federation federation federation federation"
|
||||
"lastfm lastfm lastfm lastfm lastfm lastfm lastfm lastfm developer developer developer developer"
|
||||
"actions actions actions actions actions actions actions actions actions actions actions actions";
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.settings-column {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: start;
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.settings-side .settings-grid {
|
||||
.settings-section {
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
}
|
||||
.settings-access { grid-area: access; }
|
||||
.settings-oidc { grid-area: oidc; }
|
||||
.settings-agent { grid-area: agent; }
|
||||
.settings-agent-status { grid-area: agentstatus; }
|
||||
.settings-similarity { grid-area: similarity; }
|
||||
.settings-similarity-status { grid-area: similaritystatus; }
|
||||
.settings-federation { grid-area: federation; }
|
||||
.settings-lastfm { grid-area: lastfm; }
|
||||
.settings-developer { grid-area: developer; }
|
||||
.settings-section-narrow { border-left: 2px solid rgba(29, 185, 84, 0.55); }
|
||||
|
||||
.settings-access .settings-grid,
|
||||
.settings-developer .settings-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-section-narrow .panel-head {
|
||||
background: rgba(29, 185, 84, 0.035);
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
grid-column: 1 / -1;
|
||||
grid-area: actions;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: rgba(35, 35, 35, 0.96);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
gap: 14px 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@@ -843,22 +876,26 @@ tbody tr:hover {
|
||||
|
||||
.setting-field {
|
||||
min-width: 0;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.setting-field.settings-short { max-width: 150px; }
|
||||
.settings-wide { max-width: 680px; }
|
||||
|
||||
.setting-field label,
|
||||
.setting-toggle label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 7px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.setting-field input {
|
||||
.setting-field input,
|
||||
.setting-field select {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
@@ -869,13 +906,27 @@ tbody tr:hover {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.setting-field input:focus {
|
||||
.setting-field textarea {
|
||||
width: 100%;
|
||||
min-height: 68px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.setting-field input:focus,
|
||||
.setting-field select:focus,
|
||||
.setting-field textarea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.setting-toggle {
|
||||
min-height: 74px;
|
||||
padding: 12px;
|
||||
min-height: 68px;
|
||||
padding: 11px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
@@ -894,9 +945,12 @@ tbody tr:hover {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.setting-toggle input {
|
||||
.setting-toggle input,
|
||||
.setting-toggle-row input[type="checkbox"] {
|
||||
flex: 0 0 auto;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
padding: 0;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
|
||||
@@ -904,7 +958,8 @@ tbody tr:hover {
|
||||
margin-top: 6px;
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
line-height: 1.45;
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.source-pill {
|
||||
@@ -929,6 +984,101 @@ tbody tr:hover {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.settings-federation-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(360px, 4fr) minmax(580px, 8fr);
|
||||
gap: 20px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.settings-federation-body > .settings-grid,
|
||||
.settings-federation-body > .probe-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.federation-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.transport-log {
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.transport-log-head {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin: 14px 0 7px;
|
||||
}
|
||||
|
||||
.transport-log-head strong { font-size: 12px; }
|
||||
.transport-log-head span { color: var(--text-subdued); font-size: 11px; }
|
||||
|
||||
.transport-log-scroll {
|
||||
max-height: 210px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.transport-row {
|
||||
display: grid;
|
||||
grid-template-columns: 68px minmax(100px, 1.2fr) 84px 72px 54px 64px 64px 66px 66px 66px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 850px;
|
||||
min-height: 30px;
|
||||
padding: 5px 9px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||
color: var(--text-secondary);
|
||||
font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.transport-row:last-child { border-bottom: 0; }
|
||||
.transport-row.transport-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
color: var(--text-subdued);
|
||||
background: var(--bg-elevated);
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.transport-row > span { overflow: hidden; text-overflow: ellipsis; }
|
||||
.transport-number { text-align: right; }
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.settings-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"access"
|
||||
"oidc"
|
||||
"agent"
|
||||
"agentstatus"
|
||||
"similarity"
|
||||
"similaritystatus"
|
||||
"federation"
|
||||
"lastfm"
|
||||
"developer"
|
||||
"actions";
|
||||
}
|
||||
.settings-federation-body { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.settings-grid,
|
||||
.federation-status-grid { grid-template-columns: 1fr; }
|
||||
.setting-field { max-width: none; }
|
||||
}
|
||||
|
||||
.settings-note {
|
||||
padding: 14px;
|
||||
color: var(--text-secondary);
|
||||
@@ -937,7 +1087,7 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
.probe-body {
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.probe-intro {
|
||||
@@ -955,9 +1105,43 @@ tbody tr:hover {
|
||||
}
|
||||
|
||||
.probe-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.probe-row strong {
|
||||
max-width: 210px;
|
||||
overflow: hidden;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.similarity-profile-details {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.similarity-profile-details > span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
color: var(--text-subdued);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.similarity-profile-details pre {
|
||||
margin: 0;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.library-row {
|
||||
@@ -1560,7 +1744,7 @@ tbody tr:hover {
|
||||
<p x-text="pageSubtitle()"></p>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button class="btn" @click="refreshAll()">
|
||||
<button class="btn" @click="refreshAll()" x-show="activeView !== 'settings'">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Refresh
|
||||
</button>
|
||||
@@ -2059,7 +2243,7 @@ tbody tr:hover {
|
||||
<div class="settings-page">
|
||||
<form class="settings-layout" @submit.prevent="saveSettings()">
|
||||
<div class="settings-column">
|
||||
<section class="panel">
|
||||
<section class="panel settings-section settings-section-wide settings-oidc">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>OIDC</strong>
|
||||
@@ -2070,6 +2254,7 @@ tbody tr:hover {
|
||||
<div class="setting-field settings-wide">
|
||||
<label>Callback URL</label>
|
||||
<input readonly :value="callbackUrl()" />
|
||||
<div class="setting-help">Register this exact redirect URL in your identity provider.</div>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>
|
||||
@@ -2105,6 +2290,7 @@ tbody tr:hover {
|
||||
<span class="source-pill" :class="sourceClass('oidc_admin_groups')" x-text="settingSource('oidc_admin_groups')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.oidc_admin_groups" placeholder="/admin,/furumusic-admins" />
|
||||
<div class="setting-help">Comma-separated identity-provider groups whose members receive administrator access.</div>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>
|
||||
@@ -2112,11 +2298,12 @@ tbody tr:hover {
|
||||
<span class="source-pill" :class="sourceClass('oidc_user_groups')" x-text="settingSource('oidc_user_groups')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.oidc_user_groups" />
|
||||
<div class="setting-help">Comma-separated groups allowed to sign in. Leave empty to allow any authenticated OIDC user.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel settings-section settings-section-wide settings-agent">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Agent</strong>
|
||||
@@ -2134,12 +2321,13 @@ tbody tr:hover {
|
||||
<input type="checkbox" x-model="settingsDraft.agent_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<div class="setting-field settings-short">
|
||||
<label>
|
||||
<span>Concurrency</span>
|
||||
<span class="source-pill" :class="sourceClass('agent_concurrency')" x-text="settingSource('agent_concurrency')"></span>
|
||||
</label>
|
||||
<input type="number" min="1" max="32" x-model="settingsDraft.agent_concurrency" />
|
||||
<div class="setting-help">Maximum number of inbox items processed at the same time. Higher values use more CPU and LLM capacity.</div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
@@ -2161,6 +2349,7 @@ tbody tr:hover {
|
||||
<span class="source-pill" :class="sourceClass('agent_llm_url')" x-text="settingSource('agent_llm_url')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.agent_llm_url" />
|
||||
<div class="setting-help">Base URL of an OpenAI-compatible service. The agent sends chat requests to its <code>/v1/chat/completions</code> endpoint.</div>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>
|
||||
@@ -2168,6 +2357,7 @@ tbody tr:hover {
|
||||
<span class="source-pill" :class="sourceClass('agent_llm_model')" x-text="settingSource('agent_llm_model')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.agent_llm_model" />
|
||||
<div class="setting-help">Model identifier sent to the configured LLM service, for example the name exposed by your local model server.</div>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>
|
||||
@@ -2175,6 +2365,7 @@ tbody tr:hover {
|
||||
<span class="source-pill" :class="sourceClass('agent_llm_auth')" x-text="settingSource('agent_llm_auth')"></span>
|
||||
</label>
|
||||
<input type="password" x-model="settingsDraft.agent_llm_auth" autocomplete="off" />
|
||||
<div class="setting-help">Complete HTTP Authorization value expected by the LLM endpoint, for example <code>Bearer …</code>.</div>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>
|
||||
@@ -2182,6 +2373,7 @@ tbody tr:hover {
|
||||
<span class="source-pill" :class="sourceClass('agent_confidence_threshold')" x-text="settingSource('agent_confidence_threshold')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.agent_confidence_threshold" />
|
||||
<div class="setting-help">Minimum confidence required to accept generated metadata automatically. Lower-confidence results are sent for review.</div>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>
|
||||
@@ -2189,13 +2381,71 @@ tbody tr:hover {
|
||||
<span class="source-pill" :class="sourceClass('agent_context_limit')" x-text="settingSource('agent_context_limit')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.agent_context_limit" />
|
||||
<div class="setting-help">Maximum model context budget in tokens. Reduce it for smaller models or increase it when processing large batches.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-section settings-section-wide settings-similarity">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Similarity Search</strong>
|
||||
<span>Local audio embeddings and federated nearest-track search</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-toggle settings-wide">
|
||||
<label>
|
||||
<span>Similarity search enabled</span>
|
||||
<span class="source-pill" :class="sourceClass('similarity_enabled')" x-text="settingSource('similarity_enabled')"></span>
|
||||
</label>
|
||||
<div class="setting-toggle-row">
|
||||
<span x-text="settingsDraft.similarity_enabled ? 'Enabled for this instance' : 'Disabled'"></span>
|
||||
<input type="checkbox" x-model="settingsDraft.similarity_enabled" />
|
||||
</div>
|
||||
<div class="setting-help">Builds an audio fingerprint index for finding musically similar tracks. When federation is enabled, compatible peers can also participate in searches.</div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
<span>Embedding model</span>
|
||||
<span class="source-pill" :class="sourceClass('similarity_model')" x-text="settingSource('similarity_model')"></span>
|
||||
</label>
|
||||
<select x-model="settingsDraft.similarity_model">
|
||||
<template x-for="model in similarityStatus.models || []" :key="model.id">
|
||||
<option :value="model.id" x-text="`${model.id} · ${model.dimensions}d`"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div class="setting-help">
|
||||
<span>The model converts audio into vectors used for comparison. Changing it rebuilds the search index.</span>
|
||||
<span x-show="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license" x-text="' License: ' + (similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
<span>Preprocessing profile</span>
|
||||
<span class="source-pill" :class="sourceClass('similarity_profile')" x-text="settingSource('similarity_profile')"></span>
|
||||
</label>
|
||||
<select x-model="settingsDraft.similarity_profile">
|
||||
<template x-for="profile in similarityStatus.profiles || []" :key="profile.id">
|
||||
<option :value="profile.id" x-text="profile.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div class="setting-help">Controls how audio is decoded and normalized before comparison. Changing it rebuilds the search index.</div>
|
||||
</div>
|
||||
<div class="setting-field settings-short">
|
||||
<label>
|
||||
<span>Background workers</span>
|
||||
<span class="source-pill" :class="sourceClass('similarity_workers')" x-text="settingSource('similarity_workers')"></span>
|
||||
</label>
|
||||
<input type="number" min="1" max="16" step="1" x-model="settingsDraft.similarity_workers" />
|
||||
<div class="setting-help">Number of tracks indexed in parallel. Higher values finish sooner but use more CPU and memory.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="settings-column settings-side">
|
||||
<section class="panel">
|
||||
<section class="panel settings-section settings-section-narrow settings-access">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Authentication</strong>
|
||||
@@ -2226,26 +2476,15 @@ tbody tr:hover {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel settings-section settings-lastfm">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>API</strong>
|
||||
<span>Developer and enrichment integrations</span>
|
||||
<strong>Last.fm Integration</strong>
|
||||
<span>Metadata enrichment and scrobbling credentials</span>
|
||||
</div>
|
||||
<span class="badge" :class="settings.lastfm_scrobbling_configured ? 'ok' : 'disabled'" x-text="settings.lastfm_scrobbling_configured ? 'Last.fm configured' : 'Last.fm missing'"></span>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-toggle">
|
||||
<label>
|
||||
<span>Swagger UI</span>
|
||||
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
|
||||
</label>
|
||||
<div class="setting-toggle-row">
|
||||
<span x-text="settingsDraft.swagger_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
|
||||
</div>
|
||||
<div class="setting-help">Interactive API docs at /swagger/ after restart.</div>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>
|
||||
<span>{{ t.settings_lastfm_api_key }}</span>
|
||||
@@ -2265,7 +2504,29 @@ tbody tr:hover {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel settings-section settings-developer">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Developer API</strong>
|
||||
<span>Interactive API documentation</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-toggle settings-wide">
|
||||
<label>
|
||||
<span>Swagger UI</span>
|
||||
<span class="source-pill" :class="sourceClass('swagger_enabled')" x-text="settingSource('swagger_enabled')"></span>
|
||||
</label>
|
||||
<div class="setting-toggle-row">
|
||||
<span x-text="settingsDraft.swagger_enabled ? 'Available at /swagger/' : 'Disabled' "></span>
|
||||
<input type="checkbox" x-model="settingsDraft.swagger_enabled" />
|
||||
</div>
|
||||
<div class="setting-help">Exposes interactive API documentation at <code>/swagger/</code> for developers and integrations.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-section settings-section-full settings-federation">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Federation</strong>
|
||||
@@ -2273,6 +2534,7 @@ tbody tr:hover {
|
||||
</div>
|
||||
<span class="badge" :class="federationStatus.node && federationStatus.node.running ? 'ok' : 'disabled'" x-text="federationStatus.node && federationStatus.node.running ? 'running' : 'stopped'"></span>
|
||||
</div>
|
||||
<div class="settings-federation-body">
|
||||
<div class="settings-grid">
|
||||
<div class="setting-toggle">
|
||||
<label>
|
||||
@@ -2283,7 +2545,7 @@ tbody tr:hover {
|
||||
<span x-text="settingsDraft.federation_enabled ? 'Enabled' : 'Disabled'"></span>
|
||||
<input type="checkbox" x-model="settingsDraft.federation_enabled" />
|
||||
</div>
|
||||
<div class="setting-help">Applies immediately on save — no restart needed. Peers can browse and stream every visible track.</div>
|
||||
<div class="setting-help">Lets other peers in this logical network discover the visible library and request audio streams from this instance.</div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
@@ -2291,24 +2553,71 @@ tbody tr:hover {
|
||||
<span class="source-pill" :class="sourceClass('federation_network_id')" x-text="settingSource('federation_network_id')"></span>
|
||||
</label>
|
||||
<input x-model="settingsDraft.federation_network_id" placeholder="my-crew-music-7f3a" autocomplete="off" />
|
||||
<div class="setting-help">Every peer using the same id finds the others automatically.</div>
|
||||
<div class="setting-help">Peers with the same Network ID form one isolated logical network and discover each other automatically. You may choose any private value; use the exact same value on every peer that should join this network.</div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
<span>Save federated tracks on play</span>
|
||||
<span class="source-pill" :class="sourceClass('federation_save_on_listen')" x-text="settingSource('federation_save_on_listen')"></span>
|
||||
</label>
|
||||
<div class="setting-toggle-row">
|
||||
<span x-text="settingsDraft.federation_save_on_listen ? 'Import into the shared library' : 'Use temporary cache'"></span>
|
||||
<input type="checkbox" x-model="settingsDraft.federation_save_on_listen" />
|
||||
</div>
|
||||
<div class="setting-help">When enabled, a federated track is permanently imported after playback and becomes available to every local user. Otherwise it remains only in the temporary cache. Imported peer metadata is trusted as provided and does not enter AI review.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="probe-body" x-show="federationStatus.node">
|
||||
<div class="federation-status-grid">
|
||||
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
|
||||
<div class="probe-row"><span>Endpoint</span><strong x-text="fedShort(federationStatus.node && federationStatus.node.endpoint_id)"></strong></div>
|
||||
<div class="probe-row"><span>Network</span><strong x-text="(federationStatus.node && federationStatus.node.network) || '-'"></strong></div>
|
||||
<div class="probe-row"><span>Connected peers</span><strong x-text="federationStatus.node && federationStatus.node.connected_peers ? federationStatus.node.connected_peers.length : 0"></strong></div>
|
||||
<div class="probe-row"><span>Known contacts</span><strong x-text="(federationStatus.node && federationStatus.node.known_contacts) ?? '-'"></strong></div>
|
||||
<div class="probe-row"><span>Similarity routing peers</span><strong x-text="(federationStatus.node && federationStatus.node.similarity_routing_peers) ?? '-'"></strong></div>
|
||||
<div class="probe-row"><span>Published items</span><strong x-text="(federationStatus.node && federationStatus.node.published_items) ?? '-'"></strong></div>
|
||||
<div class="probe-row"><span>Last sync</span><strong x-text="federationStatus.last_sync || 'not yet'"></strong></div>
|
||||
</div>
|
||||
<div class="probe-table" x-show="fedTransport().total_samples > 0">
|
||||
<div class="probe-row">
|
||||
<span>Transport path</span>
|
||||
<strong>
|
||||
<span class="badge" :class="fedPathBadge(fedTransport().last_path)" x-text="fedTransport().last_path || 'unknown'"></span>
|
||||
</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>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
||||
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
||||
</div>
|
||||
<div class="transport-log" x-show="fedTransport().last && fedTransport().last.length">
|
||||
<div class="transport-log-head">
|
||||
<strong>Recent transport operations</strong>
|
||||
<span>Newest first · updates automatically</span>
|
||||
</div>
|
||||
<div class="transport-log-scroll">
|
||||
<div class="transport-row transport-header">
|
||||
<span>Time</span><span>User / peer</span><span>Protocol</span><span>Direction</span><span>Phase</span><span>Path</span><span class="transport-number">RTT</span><span class="transport-number">TX</span><span class="transport-number">RX</span><span class="transport-number">Lost</span>
|
||||
</div>
|
||||
<template x-for="(sample, index) in fedTransport().last" :key="`${sample.at}-${sample.protocol}-${sample.direction}-${sample.phase}-${sample.peer_id}-${index}`">
|
||||
<div class="transport-row">
|
||||
<span :title="sample.at" x-text="formatTransportTime(sample.at)"></span>
|
||||
<span :title="sample.user_name || sample.peer_id" x-text="sample.user_name || fedShort(sample.peer_id)"></span>
|
||||
<span x-text="sample.protocol || '-'"></span>
|
||||
<span x-text="sample.direction || '-'"></span>
|
||||
<span x-text="sample.phase || '-'"></span>
|
||||
<span x-text="sample.selected_path || 'unknown'"></span>
|
||||
<span class="transport-number" x-text="fedRtt(sample.selected_rtt_ms)"></span>
|
||||
<span class="transport-number" x-text="formatBytes(sample.total_tx_bytes || 0)"></span>
|
||||
<span class="transport-number" x-text="formatBytes(sample.total_rx_bytes || 0)"></span>
|
||||
<span class="transport-number" x-text="formatBytes(sample.lost_bytes || 0)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="probe-intro muted" x-show="federationStatus.last_error" x-text="federationStatus.last_error"></p>
|
||||
<div class="toolbar" style="margin-top:14px; flex-wrap:wrap; gap:8px">
|
||||
<button class="btn" type="button" @click="loadFederation()" :disabled="federationLoading">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn" type="button" @click="fedSyncNow()" :disabled="federationLoading || !(federationStatus.node && federationStatus.node.running)">
|
||||
<i data-lucide="upload-cloud"></i>
|
||||
Publish now
|
||||
@@ -2330,9 +2639,48 @@ tbody tr:hover {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<section class="panel settings-section settings-section-narrow settings-similarity-status">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Search Index Statistics</strong>
|
||||
<span>Similarity model, indexing progress, and storage</span>
|
||||
</div>
|
||||
<span class="badge" :class="similarityBadge()" x-text="similarityStatus.status?.phase || 'disabled'"></span>
|
||||
</div>
|
||||
<div class="probe-body">
|
||||
<div class="probe-table">
|
||||
<div class="probe-row"><span>Model</span><strong x-text="similarityStatus.status?.model || settingsDraft.similarity_model || '-' "></strong></div>
|
||||
<div class="probe-row"><span>Active profile</span><strong :title="similarityStatus.status?.active_profile || ''" x-text="similarityStatus.status?.active_profile ? similarityStatus.status.active_profile.slice(0, 22) + '…' : 'not ready'"></strong></div>
|
||||
<div class="probe-row" x-show="similarityStatus.status?.target_profile && similarityStatus.status?.target_profile !== similarityStatus.status?.active_profile">
|
||||
<span>Processing profile</span><strong :title="similarityStatus.status?.target_profile || ''" x-text="similarityStatus.status?.target_profile?.slice(0, 22) + '…'"></strong>
|
||||
</div>
|
||||
<div class="probe-row"><span>Visible tracks</span><strong x-text="similarityStatus.status?.total_tracks ?? 0"></strong></div>
|
||||
<div class="probe-row"><span>Processed</span><strong x-text="`${similarityStatus.status?.completed_tracks || 0} · ${similarityProgress()}%`"></strong></div>
|
||||
<div class="probe-row"><span>Failed</span><strong x-text="similarityStatus.status?.failed_tracks || 0"></strong></div>
|
||||
<div class="probe-row"><span>Stored</span><strong x-text="`${similarityStatus.status?.stored_vectors || 0} vectors · ${formatBytes(similarityStatus.status?.stored_bytes || 0)}`"></strong></div>
|
||||
<div class="probe-row" x-show="similarityStatus.status?.current_track"><span>Current track</span><strong x-text="similarityStatus.status?.current_track"></strong></div>
|
||||
</div>
|
||||
<div class="similarity-profile-details">
|
||||
<span>Selected preprocessing profile</span>
|
||||
<pre x-text="selectedSimilarityProfile()?.details || 'Profile information is not available.'"></pre>
|
||||
</div>
|
||||
<div style="height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-top:12px" x-show="similarityStatus.status?.phase === 'processing'">
|
||||
<div style="height:100%;background:var(--accent);transition:width .25s" :style="`width:${similarityProgress()}%`"></div>
|
||||
</div>
|
||||
<p class="probe-intro muted" x-show="similarityStatus.status?.last_error" x-text="similarityStatus.status?.last_error"></p>
|
||||
<div class="toolbar" style="margin-top:14px;flex-wrap:wrap;gap:8px">
|
||||
<button class="btn danger" type="button" @click="clearSimilarityEmbeddings()" :disabled="similarityLoading || !(similarityStatus.status?.stored_vectors > 0)">
|
||||
<i data-lucide="trash-2"></i>
|
||||
Clear all embeddings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-section settings-section-narrow settings-agent-status">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Agent Status</strong>
|
||||
@@ -2363,10 +2711,6 @@ tbody tr:hover {
|
||||
<div class="action-strip settings-actions">
|
||||
<span class="selection-summary">Settings are stored as database overrides unless an environment variable wins.</span>
|
||||
<div class="toolbar">
|
||||
<button class="btn" type="button" @click="loadSettings()">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Reload
|
||||
</button>
|
||||
<button class="btn primary" type="submit" :disabled="settingsSaving">
|
||||
<i :data-lucide="settingsSaving ? 'loader-circle' : 'save'"></i>
|
||||
<span x-text="settingsSaving ? 'Saving...' : 'Save settings'"></span>
|
||||
@@ -2438,7 +2782,7 @@ tbody tr:hover {
|
||||
<div class="user-activity-row">
|
||||
<div class="user-activity-cover">
|
||||
<template x-if="play.cover_url">
|
||||
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy">
|
||||
<img :src="play.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!play.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
@@ -2901,7 +3245,12 @@ function adminV2() {
|
||||
agent_context_limit: '',
|
||||
agent_concurrency: '',
|
||||
federation_enabled: false,
|
||||
federation_network_id: ''
|
||||
federation_network_id: '',
|
||||
federation_save_on_listen: false,
|
||||
similarity_enabled: false,
|
||||
similarity_model: 'discogs-effnet-bsdynamic-1',
|
||||
similarity_profile: 'furumi-full-track-v1',
|
||||
similarity_workers: '1'
|
||||
},
|
||||
settingsProbe: { status: 'idle', ok: false },
|
||||
settingsProbeLoading: false,
|
||||
@@ -2909,6 +3258,8 @@ function adminV2() {
|
||||
federationLoading: false,
|
||||
federationTicket: '',
|
||||
fedConnectTicket: '',
|
||||
similarityStatus: { status: { phase: 'disabled' }, models: [], profiles: [] },
|
||||
similarityLoading: false,
|
||||
settingsSaving: false,
|
||||
routeReady: false,
|
||||
poller: null,
|
||||
@@ -2972,6 +3323,11 @@ function adminV2() {
|
||||
]);
|
||||
} else if (this.activeView === 'users') {
|
||||
await Promise.allSettled([this.loadUsers(false)]);
|
||||
} else if (this.activeView === 'settings') {
|
||||
await Promise.allSettled([
|
||||
this.loadFederation(false),
|
||||
this.loadSimilarity(false)
|
||||
]);
|
||||
} else {
|
||||
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
||||
}
|
||||
@@ -3034,6 +3390,10 @@ function adminV2() {
|
||||
} else if (this.activeView === 'settings') {
|
||||
if (updateRoute) this.setRoute('#settings');
|
||||
await this.loadSettings();
|
||||
await Promise.allSettled([
|
||||
this.loadFederation(false),
|
||||
this.loadSimilarity(false)
|
||||
]);
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
@@ -3181,6 +3541,21 @@ function adminV2() {
|
||||
|
||||
async saveSettings() {
|
||||
if (this.settingsSaving) return;
|
||||
const networkSimilarityWasEnabled = Boolean(
|
||||
this.settings.values?.similarity_enabled
|
||||
&& this.settings.values?.federation_enabled
|
||||
);
|
||||
const networkSimilarityWillBeEnabled = Boolean(
|
||||
this.settingsDraft.similarity_enabled
|
||||
&& this.settingsDraft.federation_enabled
|
||||
);
|
||||
if (!networkSimilarityWasEnabled && networkSimilarityWillBeEnabled) {
|
||||
const accepted = window.confirm(
|
||||
'Similarity searches send an anonymized embedding of the selected track to federation peers. '
|
||||
+ 'Peers do not receive a user identity, but the query can technically reveal what is being searched. Enable this for the instance?'
|
||||
);
|
||||
if (!accepted) return;
|
||||
}
|
||||
this.settingsSaving = true;
|
||||
try {
|
||||
await this.request(`${this.apiBase}/settings`, {
|
||||
@@ -3189,6 +3564,7 @@ function adminV2() {
|
||||
});
|
||||
await this.loadSettings(false);
|
||||
await this.loadFederation(false);
|
||||
await this.loadSimilarity(false);
|
||||
this.showToast('Settings saved');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
@@ -3203,6 +3579,7 @@ function adminV2() {
|
||||
this.setRoute('#settings');
|
||||
await this.loadSettings();
|
||||
await this.loadFederation(false);
|
||||
await this.loadSimilarity(false);
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
@@ -3220,6 +3597,57 @@ function adminV2() {
|
||||
}
|
||||
},
|
||||
|
||||
async loadSimilarity(showErrors = true) {
|
||||
this.similarityLoading = true;
|
||||
try {
|
||||
this.similarityStatus = await this.request(`${this.apiBase}/similarity`);
|
||||
} catch (error) {
|
||||
if (showErrors) this.showToast(error.message);
|
||||
} finally {
|
||||
this.similarityLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async clearSimilarityEmbeddings() {
|
||||
if (!window.confirm('Delete embeddings for every model and preprocessing profile? Audio files are not affected.')) return;
|
||||
this.similarityLoading = true;
|
||||
try {
|
||||
await this.request(`${this.apiBase}/similarity/clear`, {
|
||||
method: 'POST',
|
||||
body: '{}'
|
||||
});
|
||||
await this.loadSimilarity(false);
|
||||
this.showToast('All embeddings cleared');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.similarityLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
selectedSimilarityProfile() {
|
||||
return (this.similarityStatus.profiles || []).find(
|
||||
profile => profile.id === this.settingsDraft.similarity_profile
|
||||
) || null;
|
||||
},
|
||||
|
||||
similarityBadge() {
|
||||
const phase = this.similarityStatus.status?.phase || 'disabled';
|
||||
if (phase === 'ready') return 'ok';
|
||||
if (phase === 'error') return 'failed';
|
||||
if (['downloading', 'loading', 'processing'].includes(phase)) return 'running';
|
||||
return 'disabled';
|
||||
},
|
||||
|
||||
similarityProgress() {
|
||||
const status = this.similarityStatus.status || {};
|
||||
const total = Number(status.total_tracks || 0);
|
||||
const completed = Number(status.completed_tracks || 0);
|
||||
return total > 0 ? Math.min(100, Math.round(completed * 100 / total)) : 0;
|
||||
},
|
||||
|
||||
async fedSyncNow() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
@@ -3268,6 +3696,32 @@ function adminV2() {
|
||||
return id ? `${id.slice(0, 12)}…` : '-';
|
||||
},
|
||||
|
||||
fedTransport() {
|
||||
return (this.federationStatus.node && this.federationStatus.node.transport) || {};
|
||||
},
|
||||
|
||||
fedPathBadge(path) {
|
||||
if (path === 'direct') return 'ok';
|
||||
if (path === 'relay') return 'pending';
|
||||
if (path === 'custom') return 'running';
|
||||
return 'disabled';
|
||||
},
|
||||
|
||||
fedRtt(ms) {
|
||||
return ms != null ? `${Math.round(Number(ms))} ms` : '-';
|
||||
},
|
||||
|
||||
formatTransportTime(value) {
|
||||
const date = new Date(value);
|
||||
if (!value || Number.isNaN(date.getTime())) return '-';
|
||||
return date.toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
});
|
||||
},
|
||||
|
||||
async loadSettingsProbe(showErrors = true) {
|
||||
this.settingsProbeLoading = true;
|
||||
try {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23111827'/%3E%3Cpath d='M27 15v31.5a9 9 0 1 1-5-8.1V22l27-6v24.5a9 9 0 1 1-5-8.1V15.9L27 20.5' fill='%2367e8f9'/%3E%3C/svg%3E">
|
||||
<title>{% block title %}{{ t.site_name }}{% endblock title %}</title>
|
||||
{% block head_extra %}{% endblock head_extra %}
|
||||
</head>
|
||||
|
||||
@@ -636,6 +636,155 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- User Settings Modal -->
|
||||
<template x-if="$store.user.settingsOpen">
|
||||
<div class="modal-overlay" @click.self="$store.user.closeSettings()">
|
||||
<div class="modal-box user-settings-modal">
|
||||
<div class="user-settings-head">
|
||||
<div>
|
||||
<h3>User settings</h3>
|
||||
<p>Personal services, listening history and trusted devices.</p>
|
||||
</div>
|
||||
<button class="mobile-list-action" @click="$store.user.closeSettings()" title="{{ t.player_close }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<section class="user-settings-section">
|
||||
<div class="user-settings-section-head">
|
||||
<div>
|
||||
<h4>{{ t.player_interface_language }}</h4>
|
||||
<p>{{ t.player_language_description }}</p>
|
||||
</div>
|
||||
<button class="settings-secondary-btn"
|
||||
onclick="location.href='/set-lang?lang={% if t.lang.code() == "en" %}ru{% else %}en{% endif %}&next='+encodeURIComponent(location.pathname+location.search+location.hash)">{{ t.player_switch_language }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="user-settings-section">
|
||||
<div class="user-settings-section-head">
|
||||
<div>
|
||||
<h4>Listening history</h4>
|
||||
<p>Review plays recorded by this web player.</p>
|
||||
</div>
|
||||
<button class="settings-secondary-btn" @click="$store.user.openHistoryFromSettings()">Open history</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="user-settings-section">
|
||||
<div class="user-settings-section-head">
|
||||
<div>
|
||||
<h4>Last.fm</h4>
|
||||
<p x-text="$store.user.lastfmStatusLabel()"></p>
|
||||
</div>
|
||||
<button class="settings-secondary-btn"
|
||||
:class="$store.user.lastfmClass()"
|
||||
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||||
@click="$store.user.handleLastfm()"
|
||||
x-text="$store.user.lastfm?.connected && !$store.user.lastfm?.reauth_required ? 'Disconnect' : 'Connect'"></button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="user-settings-section user-settings-devices">
|
||||
<div class="user-settings-section-head">
|
||||
<div>
|
||||
<h4>Connected devices</h4>
|
||||
<p x-text="$store.devices.fedSummary()"></p>
|
||||
</div>
|
||||
<button class="settings-secondary-btn"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.syncFedDevices()">Sync now</button>
|
||||
</div>
|
||||
<template x-if="$store.devices.fedError">
|
||||
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
|
||||
</template>
|
||||
|
||||
<div class="settings-device-group">
|
||||
<div class="settings-device-label">Web player sessions</div>
|
||||
<template x-for="device in $store.devices.webDevices()" :key="'settings-web-' + device.id">
|
||||
<div class="settings-device-row">
|
||||
<span class="device-row-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2"/>
|
||||
<path d="M8 20h8M12 16v4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="fed-device-main">
|
||||
<span class="fed-device-name" x-text="device.name"></span>
|
||||
<span class="fed-device-meta"
|
||||
x-text="device.is_current ? 'This browser session' : (device.is_active ? 'Active web session' : 'Web session')"></span>
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="settings-device-group">
|
||||
<div class="settings-device-label">Trusted federation devices</div>
|
||||
<template x-for="request in $store.devices.fedPending()" :key="request.request_id">
|
||||
<div class="fed-pairing-card">
|
||||
<div class="fed-pairing-title" x-text="request.name || request.device_id"></div>
|
||||
<div class="fed-pairing-meta"
|
||||
x-text="request.requester_group_id ? 'Already belongs to another sync group' : (request.client_version || 'Waiting for approval')"></div>
|
||||
<div class="fed-device-actions">
|
||||
<button class="fed-action-btn primary"
|
||||
@click="$store.devices.answerFedPairing(request, true, !!request.requester_group_id)"
|
||||
x-text="request.requester_group_id ? 'Use existing group' : 'Approve'"></button>
|
||||
<button class="fed-action-btn"
|
||||
@click="$store.devices.answerFedPairing(request, false, false)">Reject</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="device in $store.devices.fedDevices()" :key="'settings-fed-' + device.device_id">
|
||||
<div class="settings-device-row federation">
|
||||
<span class="device-row-icon federation-device-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M5.6 8.5a7.5 7.5 0 000 7M18.4 8.5a7.5 7.5 0 010 7"/>
|
||||
<path d="M2.8 5.8a11 11 0 000 12.4M21.2 5.8a11 11 0 010 12.4"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="fed-device-main">
|
||||
<span class="fed-device-name" x-text="device.name || device.device_id"></span>
|
||||
<span class="fed-device-meta"
|
||||
x-text="device.is_self ? 'This web player' : (device.client_version || 'Trusted device')"></span>
|
||||
</span>
|
||||
<button class="fed-revoke-btn"
|
||||
x-show="!device.is_self"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="settings-pairing-actions">
|
||||
<button class="settings-primary-btn"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.generateFedInvite()">Invite a device</button>
|
||||
<template x-if="$store.devices.fedInvite">
|
||||
<input class="fed-device-input"
|
||||
readonly
|
||||
:value="$store.devices.fedInvite"
|
||||
@focus="$event.target.select()">
|
||||
</template>
|
||||
<div class="fed-connect-row">
|
||||
<input class="fed-device-input"
|
||||
type="text"
|
||||
placeholder="Paste frid:// invite"
|
||||
x-model="$store.devices.fedInviteInput"
|
||||
@keydown.enter.prevent="$store.devices.connectFedInvite()">
|
||||
<button class="settings-secondary-btn"
|
||||
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
|
||||
@click="$store.devices.connectFedInvite()">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Play History Modal -->
|
||||
<template x-if="$store.history.modal">
|
||||
<div class="modal-overlay" @click.self="$store.history.close()">
|
||||
@@ -676,14 +825,19 @@
|
||||
@click.stop="$store.history.playFrom(idx)"
|
||||
:title="item.track?.title || item.track_title">
|
||||
<template x-if="item.track && item.track.cover_url">
|
||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||
<img :src="item.track.cover_url" alt="" aria-hidden="true" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!item.track || !item.track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
</template>
|
||||
</button>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="item.track?.title || item.track_title"></div>
|
||||
<div class="track-title">
|
||||
<span x-text="item.track?.title || item.track_title"></span>
|
||||
<span class="history-device-badge"
|
||||
:title="item.device_id"
|
||||
x-text="item.device_name"></span>
|
||||
</div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
|
||||
+1432
-24
File diff suppressed because it is too large
Load Diff
+572
-158
File diff suppressed because it is too large
Load Diff
+454
-24
@@ -67,11 +67,17 @@ body {
|
||||
|
||||
.user-widget-main {
|
||||
display: grid;
|
||||
grid-template-columns: 36px minmax(0, 1fr) 32px;
|
||||
grid-template-columns: 36px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-widget-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
@@ -444,22 +450,6 @@ button.user-stat:hover {
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sidebar-bottom {
|
||||
padding: 12px 16px;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sidebar-bottom a {
|
||||
color: var(--text-subdued);
|
||||
text-decoration: none;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sidebar-bottom a:hover { color: var(--text-secondary); }
|
||||
|
||||
/* Center Content */
|
||||
.center-content {
|
||||
flex: 1;
|
||||
@@ -670,6 +660,42 @@ button.user-stat:hover {
|
||||
margin-bottom: 14px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.artist-release-group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.artist-release-group-heading .artist-release-group-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.artist-top-tracks-toggle {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-subdued);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 5px;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.artist-top-tracks-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.artist-top-tracks-toggle svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.artist-top-tracks-toggle svg.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Release detail header */
|
||||
.release-header {
|
||||
@@ -1357,20 +1383,50 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.queue-track-cover {
|
||||
position: relative;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-elevated);
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.queue-track-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.queue-track-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.queue-track-cover svg { width: 20px; height: 20px; color: var(--text-subdued); }
|
||||
|
||||
.queue-track-cover .queue-federation-status {
|
||||
position: absolute;
|
||||
right: 2px;
|
||||
bottom: 2px;
|
||||
z-index: 3;
|
||||
width: 18px;
|
||||
min-width: 18px;
|
||||
max-width: 18px;
|
||||
height: 18px;
|
||||
min-height: 18px;
|
||||
max-height: 18px;
|
||||
box-sizing: border-box;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: 0 0 0 1px var(--bg-secondary);
|
||||
}
|
||||
.queue-track-cover .queue-federation-status > svg {
|
||||
width: 13px;
|
||||
height: 13px;
|
||||
}
|
||||
.queue-track-cover .queue-federation-status .federation-download-progress,
|
||||
.queue-track-cover .queue-federation-status .federation-download-progress svg {
|
||||
width: 18px !important;
|
||||
min-width: 18px;
|
||||
height: 18px !important;
|
||||
min-height: 18px;
|
||||
}
|
||||
|
||||
.queue-track-info { overflow: hidden; flex: 1; }
|
||||
.queue-track-title {
|
||||
font-size: 13px;
|
||||
@@ -1955,6 +2011,33 @@ button.user-stat:hover {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.device-group-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 7px 8px 3px;
|
||||
color: #b8d6ff;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.device-group-divider::before,
|
||||
.device-group-divider::after {
|
||||
content: "";
|
||||
height: 1px;
|
||||
flex: 1;
|
||||
background: var(--border-color);
|
||||
}
|
||||
.device-group-divider span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.federation-device-row {
|
||||
color: #c9dcff;
|
||||
}
|
||||
.federation-device-icon {
|
||||
color: #78a9ff;
|
||||
}
|
||||
|
||||
.fed-section-label {
|
||||
margin-top: 4px;
|
||||
color: #b8d6ff;
|
||||
@@ -2374,7 +2457,16 @@ button.user-stat:hover {
|
||||
line-height: 1;
|
||||
font-weight: 500;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
text-decoration: none;
|
||||
pointer-events: auto;
|
||||
transition: opacity 120ms ease;
|
||||
}
|
||||
|
||||
.player-version-chip:hover,
|
||||
.player-version-chip:focus-visible {
|
||||
color: var(--text-subdued);
|
||||
opacity: 0.9;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mobile-account-chip {
|
||||
@@ -2436,11 +2528,18 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.mobile-account-logout {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mobile-account-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.torrent-import-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2632,12 +2731,225 @@ button.user-stat:hover {
|
||||
|
||||
/* Search results */
|
||||
.search-section { margin-bottom: 24px; }
|
||||
.federation-search-section {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 18px;
|
||||
}
|
||||
.federation-search-section.similarity-federation-merged {
|
||||
margin-top: -24px;
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.similarity-unified-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.similarity-unified-results > .search-section {
|
||||
display: contents;
|
||||
}
|
||||
.similarity-unified-results .search-section-title { order: -1000002; }
|
||||
.similarity-unified-results .track-list-header { order: -1000001; }
|
||||
.similarity-unified-results .federation-search-status { order: 1000001; }
|
||||
.similarity-search-inline-progress {
|
||||
order: 1000000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 9px;
|
||||
min-height: 42px;
|
||||
margin-top: 4px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.similarity-search-inline-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, .16);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 999px;
|
||||
animation: federation-progress-spin .8s linear infinite;
|
||||
}
|
||||
.federation-live-badge {
|
||||
margin-left: 8px;
|
||||
color: var(--accent);
|
||||
font-size: 10px;
|
||||
letter-spacing: .12em;
|
||||
}
|
||||
.federation-search-status {
|
||||
color: var(--text-muted);
|
||||
padding: 12px 0;
|
||||
}
|
||||
.federation-search-status.error { color: var(--danger, #e66); }
|
||||
.federation-track-status {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.federation-track-status > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.federation-error-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 1px solid rgba(235, 105, 105, .7);
|
||||
border-radius: 999px;
|
||||
background: rgba(190, 55, 55, .18);
|
||||
color: #ffb0b0;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
line-height: 1;
|
||||
cursor: help;
|
||||
}
|
||||
.queue-federation-status .federation-error-badge {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.federation-download-progress {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.federation-download-progress svg {
|
||||
width: 22px !important;
|
||||
height: 22px !important;
|
||||
transform: rotate(-90deg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.federation-download-progress circle {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
}
|
||||
.federation-download-progress .progress-track {
|
||||
stroke: color-mix(in srgb, var(--text-muted) 28%, transparent);
|
||||
}
|
||||
.federation-download-progress .progress-value {
|
||||
stroke: var(--accent);
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 50.27;
|
||||
transition: stroke-dashoffset 160ms linear;
|
||||
}
|
||||
.federation-download-progress.indeterminate .progress-value {
|
||||
stroke-dasharray: 12.57 37.70;
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: federation-progress-spin .8s linear infinite;
|
||||
}
|
||||
.federation-download-progress .progress-percent {
|
||||
position: absolute;
|
||||
color: var(--text);
|
||||
font-size: 7px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
.federation-download-tooltip {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
left: calc(100% + 10px);
|
||||
top: 50%;
|
||||
width: max-content;
|
||||
max-width: 290px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-elevated);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, .28);
|
||||
color: var(--text);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translate(3px, -50%);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
.federation-download-progress:hover .federation-download-tooltip {
|
||||
opacity: 1;
|
||||
transform: translate(0, -50%);
|
||||
}
|
||||
@keyframes federation-progress-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.federation-source-count {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.search-section-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.search-similarity-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
margin: 0 0 20px;
|
||||
}
|
||||
.search-similarity-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.search-similarity-title strong {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
}
|
||||
.search-similarity-progress {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 30px;
|
||||
padding: 5px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-similarity-progress.loading { border-color: rgba(29, 185, 84, .38); }
|
||||
.search-progress-live {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--accent);
|
||||
}
|
||||
.search-progress-live i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: currentColor;
|
||||
animation: similarity-search-pulse 1.1s ease-in-out infinite;
|
||||
}
|
||||
@keyframes similarity-search-pulse {
|
||||
50% { opacity: .3; transform: scale(.75); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.search-similarity-heading { align-items: flex-start; flex-direction: column; }
|
||||
.search-similarity-progress { max-width: 100%; flex-wrap: wrap; white-space: normal; }
|
||||
}
|
||||
|
||||
.search-artists-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -2675,7 +2987,7 @@ button.user-stat:hover {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-artist-img img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.search-artist-img img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.search-artist-img svg { width: 32px; height: 32px; color: var(--text-subdued); }
|
||||
|
||||
.search-artist-name {
|
||||
@@ -2710,6 +3022,7 @@ button.user-stat:hover {
|
||||
.search-release-card:hover { background: var(--bg-elevated); }
|
||||
|
||||
.search-release-cover {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 6px;
|
||||
@@ -2721,9 +3034,12 @@ button.user-stat:hover {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-release-cover img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.search-release-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||
.search-release-cover svg { width: 40px; height: 40px; color: var(--text-subdued); }
|
||||
|
||||
.artwork-image { z-index: 1; opacity: 0; }
|
||||
.artwork-image.artwork-loaded { opacity: 1; }
|
||||
|
||||
/* Like button */
|
||||
.like-btn {
|
||||
background: none;
|
||||
@@ -3371,6 +3687,105 @@ button.user-stat:hover {
|
||||
max-width: 980px;
|
||||
}
|
||||
|
||||
.user-settings-modal {
|
||||
width: min(760px, calc(100vw - 32px));
|
||||
max-width: 760px;
|
||||
max-height: min(86dvh, 820px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
.user-settings-head,
|
||||
.user-settings-section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.user-settings-head {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.user-settings-head h3,
|
||||
.user-settings-section h4 {
|
||||
margin: 0;
|
||||
}
|
||||
.user-settings-head p,
|
||||
.user-settings-section p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--text-subdued);
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.user-settings-section {
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
.user-settings-devices {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.settings-device-group {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
.settings-device-label {
|
||||
padding: 8px 11px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-subdued);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.settings-device-row {
|
||||
min-height: 48px;
|
||||
padding: 8px 11px;
|
||||
display: grid;
|
||||
grid-template-columns: 28px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
.settings-device-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.settings-device-row.federation {
|
||||
background: rgba(82, 145, 255, 0.035);
|
||||
}
|
||||
.settings-primary-btn,
|
||||
.settings-secondary-btn {
|
||||
min-height: 34px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.settings-primary-btn {
|
||||
border-color: rgba(82, 145, 255, 0.4);
|
||||
background: rgba(82, 145, 255, 0.18);
|
||||
color: #d5e4ff;
|
||||
}
|
||||
.settings-primary-btn:hover,
|
||||
.settings-secondary-btn:hover {
|
||||
filter: brightness(1.12);
|
||||
}
|
||||
.settings-primary-btn:disabled,
|
||||
.settings-secondary-btn:disabled {
|
||||
cursor: default;
|
||||
opacity: .55;
|
||||
}
|
||||
.settings-pairing-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.settings-pairing-actions > .settings-primary-btn {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.history-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -3413,6 +3828,18 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.history-row:last-child { border-bottom: 0; }
|
||||
.history-device-badge {
|
||||
display: inline-block;
|
||||
margin-left: .45rem;
|
||||
padding: .08rem .38rem;
|
||||
border: 1px solid color-mix(in srgb, currentColor 28%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
font-size: .68rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
vertical-align: .12rem;
|
||||
}
|
||||
|
||||
.history-cover {
|
||||
width: 40px;
|
||||
@@ -5225,6 +5652,7 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.mobile-expanded-queue-cover {
|
||||
position: relative;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 5px;
|
||||
@@ -5236,6 +5664,8 @@ button.user-stat:hover {
|
||||
}
|
||||
|
||||
.mobile-expanded-queue-cover img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
|
||||
Reference in New Issue
Block a user