Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77cbb62d84 | ||
|
|
05d68724f3 | ||
|
|
d71845509d | ||
|
|
4efcfdc539 | ||
|
|
d61d7a6bac | ||
|
|
cad8b2280f | ||
|
|
3a75c7d848 | ||
|
|
dc1fac1a94 | ||
|
|
122214a896 | ||
|
|
134f89fc9d | ||
|
|
232c171aac | ||
|
|
845df4e031 | ||
|
|
b0d8929b4c | ||
|
|
291265be7d | ||
|
|
624e75839d | ||
|
|
fd766cda24 |
@@ -3,3 +3,5 @@
|
||||
/.claude
|
||||
/media
|
||||
/federation
|
||||
/similarity-models
|
||||
/federation-cache
|
||||
|
||||
Generated
+631
-27
@@ -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"
|
||||
@@ -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#085a4752da25a8d3fe7eac673af081a4a73c08bd"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15a8707baeccb46b5935138f9cb3df3c988c0730b807a2634d901f26b39250d6"
|
||||
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.2"
|
||||
version = "0.9.8"
|
||||
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#085a4752da25a8d3fe7eac673af081a4a73c08bd"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91592b40de9f3c2158a39da105c821e9bf17f461fe142a56a8607fb0faf56a9c"
|
||||
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.3"
|
||||
version = "0.10.0"
|
||||
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.3.1"
|
||||
|
||||
Generated
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1786106723,
|
||||
"narHash": "sha256-zDSUbpoeo/9ZmD2+wXnzxoo1+uhL8vxc0b8yuYMKYq0=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "f13ff45afd1bb73e640eaa08a7066dbed07e3238",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
description = "Furumusic development environment";
|
||||
|
||||
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
|
||||
outputs = { nixpkgs, ... }:
|
||||
let
|
||||
supportedSystems = [
|
||||
"x86_64-linux"
|
||||
"aarch64-linux"
|
||||
"x86_64-darwin"
|
||||
"aarch64-darwin"
|
||||
];
|
||||
forEachSystem = function:
|
||||
nixpkgs.lib.genAttrs supportedSystems (system:
|
||||
function (import nixpkgs { inherit system; }));
|
||||
in
|
||||
{
|
||||
devShells = forEachSystem (pkgs: {
|
||||
default = pkgs.mkShell {
|
||||
nativeBuildInputs = with pkgs; [
|
||||
cargo
|
||||
clippy
|
||||
pkg-config
|
||||
rustc
|
||||
rustfmt
|
||||
];
|
||||
|
||||
buildInputs = with pkgs; [
|
||||
cacert
|
||||
openssl
|
||||
] ++ lib.optionals stdenv.isDarwin [ libiconv ];
|
||||
|
||||
RUST_SRC_PATH = "${pkgs.rustPlatform.rustLibSrc}";
|
||||
};
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -422,6 +422,20 @@ impl App for AdminApp {
|
||||
}),
|
||||
"admin_v2_federation_status",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/similarity",
|
||||
get(move |session: Session, db: Database| async move {
|
||||
v2::similarity_status(session, db).await
|
||||
}),
|
||||
"admin_v2_similarity_status",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/similarity/clear",
|
||||
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||
v2::similarity_clear(session, db).await
|
||||
}),
|
||||
"admin_v2_similarity_clear",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/federation/sync",
|
||||
cot::router::method::post(move |session: Session, db: Database| async move {
|
||||
|
||||
+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,74 @@
|
||||
//! 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)
|
||||
);
|
||||
manifest.validate().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
#[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 }],
|
||||
},
|
||||
};
|
||||
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 }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
+359
-10
@@ -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;
|
||||
@@ -20,10 +21,10 @@ use crate::player::PlayerDeviceHub;
|
||||
|
||||
use super::{TransportStats, record_stream_transport};
|
||||
|
||||
pub const SYNC_ALPN: &[u8] = b"furumi/sync/1";
|
||||
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);
|
||||
@@ -31,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)]
|
||||
@@ -83,6 +87,7 @@ struct Identity {
|
||||
#[derive(Debug, Clone)]
|
||||
struct StoredDevice {
|
||||
device_id: String,
|
||||
endpoint_id: String,
|
||||
endpoint_ticket: String,
|
||||
}
|
||||
|
||||
@@ -269,6 +274,9 @@ enum SyncOpPayload {
|
||||
target_device_id: String,
|
||||
command: PlaybackCommand,
|
||||
},
|
||||
ListenRecorded {
|
||||
event: ListenEvent,
|
||||
},
|
||||
}
|
||||
|
||||
impl SyncOpPayload {
|
||||
@@ -771,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,
|
||||
@@ -1391,8 +1577,7 @@ async fn sync_device(
|
||||
user_id: i64,
|
||||
device: &StoredDevice,
|
||||
) -> Result<()> {
|
||||
let ticket: PeerTicket = device.endpoint_ticket.parse()?;
|
||||
let peer = service.connect(ticket).await?;
|
||||
let peer = resolve_device_peer(&service, device).await?;
|
||||
let own_ticket = service.ticket().await?.to_string();
|
||||
let identity = ensure_identity(pool, user_id, "").await?;
|
||||
let profile = own_profile(pool, user_id, "", &own_ticket).await?;
|
||||
@@ -1569,7 +1754,7 @@ async fn local_seed_needs_metadata_backfill(pool: &sqlx::PgPool, user_id: i64) -
|
||||
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
|
||||
@@ -1586,6 +1771,7 @@ 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)
|
||||
@@ -1600,7 +1786,7 @@ async fn seed_local_user_state(pool: &sqlx::PgPool, user_id: i64) -> Result<()>
|
||||
)
|
||||
.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)
|
||||
@@ -2015,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,
|
||||
@@ -2664,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,
|
||||
@@ -3113,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,
|
||||
})
|
||||
@@ -3811,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
|
||||
@@ -3827,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
|
||||
@@ -3895,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?;
|
||||
@@ -4253,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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4368,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!(
|
||||
|
||||
+81
-5
@@ -5,15 +5,21 @@
|
||||
//! releases, tracks — names and small metadata, never files) into the
|
||||
//! shared DHT and serves audio, track metadata, cover art and per-artist
|
||||
//! catalogs to other peers (TUI clients) over the same wire protocols the
|
||||
//! clients speak among themselves. Serve-only: the server does not search
|
||||
//! or download from other peers.
|
||||
//! clients speak among themselves. The web player also searches known peers
|
||||
//! for catalog metadata and, when enabled, compatible similarity embeddings;
|
||||
//! local playback and the local library remain independent of the network.
|
||||
//!
|
||||
//! Settings are the regular admin config entries (`federation_enabled`,
|
||||
//! `federation_network_id`) and apply on the fly — saving the settings
|
||||
//! starts, stops or re-joins the node without a server restart.
|
||||
//! `federation_network_id`, `federation_save_on_listen`) and apply on the fly —
|
||||
//! saving the settings starts, stops or re-joins the node without a server
|
||||
//! restart.
|
||||
|
||||
mod capabilities;
|
||||
pub mod client;
|
||||
pub mod devices;
|
||||
mod receive;
|
||||
mod serve;
|
||||
mod similarity;
|
||||
mod storage;
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
@@ -22,6 +28,7 @@ use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use music_dht::capabilities::CAPABILITIES_ALPN;
|
||||
use music_dht::{
|
||||
ByteStream, ByteStreamConnectionStats, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService,
|
||||
NetworkId, PeerTicket, PublishStats, RendezvousConfig, SyncStats,
|
||||
@@ -34,6 +41,7 @@ use crate::config::AppConfig;
|
||||
use storage::PostgresFederationStorage;
|
||||
|
||||
pub use serve::{AUDIO_ALPN, CATALOG_ALPN};
|
||||
pub use similarity::SIMILARITY_ALPN;
|
||||
|
||||
/// How often the published library is re-synchronized with the database.
|
||||
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
|
||||
@@ -51,6 +59,13 @@ 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,
|
||||
@@ -113,6 +128,7 @@ struct TransportStatsState {
|
||||
audio_samples: u64,
|
||||
catalog_samples: u64,
|
||||
sync_samples: u64,
|
||||
similarity_samples: u64,
|
||||
last: VecDeque<TransportSample>,
|
||||
}
|
||||
|
||||
@@ -146,6 +162,7 @@ impl TransportStats {
|
||||
"audio" => state.audio_samples += 1,
|
||||
"catalog" => state.catalog_samples += 1,
|
||||
"device-sync" => state.sync_samples += 1,
|
||||
"similarity" => state.similarity_samples += 1,
|
||||
_ => {}
|
||||
}
|
||||
state.last.push_front(sample);
|
||||
@@ -166,6 +183,7 @@ impl TransportStats {
|
||||
"audio_samples": state.audio_samples,
|
||||
"catalog_samples": state.catalog_samples,
|
||||
"sync_samples": state.sync_samples,
|
||||
"similarity_samples": state.similarity_samples,
|
||||
"last_path": latest.map(|sample| sample.selected_path.clone()),
|
||||
"last_rtt_ms": latest.and_then(|sample| sample.selected_rtt_ms),
|
||||
"last_peer": latest.map(|sample| sample.peer_id.clone()),
|
||||
@@ -207,8 +225,12 @@ pub struct Federation {
|
||||
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>>,
|
||||
@@ -234,8 +256,12 @@ 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),
|
||||
@@ -285,7 +311,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
|
||||
@@ -304,6 +331,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);
|
||||
}
|
||||
@@ -318,6 +350,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 {
|
||||
@@ -353,6 +389,8 @@ impl Federation {
|
||||
.stream_protocol(AUDIO_ALPN)
|
||||
.stream_protocol(CATALOG_ALPN)
|
||||
.stream_protocol(devices::SYNC_ALPN)
|
||||
.stream_protocol(SIMILARITY_ALPN)
|
||||
.schema_independent_stream_protocol(CAPABILITIES_ALPN)
|
||||
.build()
|
||||
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
|
||||
let (service, mut events) =
|
||||
@@ -420,6 +458,19 @@ impl Federation {
|
||||
device_hub,
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
let capabilities_acceptor = service
|
||||
.stream_acceptor(CAPABILITIES_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the capabilities acceptor: {err}"))?;
|
||||
let capabilities_task = tokio::spawn(capabilities::serve(capabilities_acceptor));
|
||||
let similarity_acceptor = service
|
||||
.stream_acceptor(SIMILARITY_ALPN)
|
||||
.map_err(|err| anyhow::anyhow!("failed to take the similarity acceptor: {err}"))?;
|
||||
let similarity_task = tokio::spawn(similarity::serve_peers(
|
||||
similarity_acceptor,
|
||||
crate::similarity::handle(),
|
||||
service.endpoint_id(),
|
||||
Arc::clone(&self.transport_stats),
|
||||
));
|
||||
|
||||
*guard = Some(Running {
|
||||
service,
|
||||
@@ -431,6 +482,8 @@ impl Federation {
|
||||
catalog_task,
|
||||
device_task,
|
||||
device_sync_task,
|
||||
capabilities_task,
|
||||
similarity_task,
|
||||
],
|
||||
});
|
||||
self.set_error(None);
|
||||
@@ -792,6 +845,19 @@ impl Federation {
|
||||
Ok(peer.to_string())
|
||||
}
|
||||
|
||||
pub async fn search_similarity(
|
||||
&self,
|
||||
query: crate::similarity::QueryVector,
|
||||
limit: usize,
|
||||
) -> Result<Vec<similarity::RemoteSimilarityTrack>> {
|
||||
anyhow::ensure!(
|
||||
crate::similarity::handle().enabled(),
|
||||
"similarity search is disabled"
|
||||
);
|
||||
let service = self.service().await?;
|
||||
similarity::search(service, query, limit, Arc::clone(&self.transport_stats)).await
|
||||
}
|
||||
|
||||
pub async fn fed_device_status(
|
||||
&self,
|
||||
user_id: i64,
|
||||
@@ -905,6 +971,16 @@ 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 persist_content_id(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+127
-59
@@ -6,7 +6,12 @@ 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 _;
|
||||
@@ -16,8 +21,8 @@ use super::{TransportStats, record_stream_transport};
|
||||
|
||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
||||
/// 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;
|
||||
@@ -66,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -277,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),
|
||||
@@ -542,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
|
||||
@@ -584,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
|
||||
@@ -611,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);
|
||||
@@ -646,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),
|
||||
@@ -665,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,310 @@
|
||||
//! Furumusic policy and PostgreSQL adapter for the shared similarity protocol.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use futures_util::stream::{self, StreamExt as _};
|
||||
use music_dht::similarity::{self as wire, SimilarityHit, SimilarityRequest, SimilarityResponse};
|
||||
use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, MusicDhtService, StreamAcceptor};
|
||||
|
||||
use crate::similarity::{Manager, QueryVector};
|
||||
|
||||
use super::TransportStats;
|
||||
|
||||
pub use music_dht::similarity::SIMILARITY_ALPN;
|
||||
|
||||
const MAX_QUERY_PEERS: usize = 16;
|
||||
const QUERY_CONCURRENCY: usize = 6;
|
||||
const QUERY_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const MAX_PER_ARTIST: usize = 3;
|
||||
const MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE: u32 = 8;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RemoteSimilarityTrack {
|
||||
pub owner: String,
|
||||
pub item_id: String,
|
||||
pub title: String,
|
||||
pub artist_names: Vec<String>,
|
||||
pub featured_artist_names: Vec<String>,
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: Option<i64>,
|
||||
pub content_id: Option<String>,
|
||||
pub release_title: Option<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
}
|
||||
|
||||
pub async fn serve_peers(
|
||||
mut acceptor: StreamAcceptor,
|
||||
manager: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
transport: Arc<TransportStats>,
|
||||
) {
|
||||
while let Some(stream) = acceptor.accept().await {
|
||||
let manager = Arc::clone(&manager);
|
||||
let transport = Arc::clone(&transport);
|
||||
tokio::spawn(async move {
|
||||
let peer = stream.peer_id;
|
||||
if let Err(error) = serve_one(stream, manager, own, transport).await {
|
||||
tracing::warn!(peer = %peer, "similarity request failed: {error:#}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_one(
|
||||
mut stream: ByteStream,
|
||||
manager: Arc<Manager>,
|
||||
own: EndpointId,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<()> {
|
||||
super::record_stream_transport(&transport, "similarity", "inbound", "open", &stream);
|
||||
let request = wire::read_request(&mut stream).await?;
|
||||
let response = if !manager.enabled() {
|
||||
SimilarityResponse::refused("similarity search is disabled on this instance")?
|
||||
} else {
|
||||
let profile_id = request.profile_id;
|
||||
let vector = request.vector;
|
||||
let limit = request.limit;
|
||||
let rank_manager = Arc::clone(&manager);
|
||||
let ranked = tokio::task::spawn_blocking(move || {
|
||||
rank_manager.rank_vector(&profile_id, &vector, None, None, limit)
|
||||
})
|
||||
.await
|
||||
.context("local similarity task failed")
|
||||
.and_then(|result| result);
|
||||
match ranked {
|
||||
Ok(ranked) => {
|
||||
let ids = ranked
|
||||
.iter()
|
||||
.map(|track| track.track_id)
|
||||
.collect::<Vec<_>>();
|
||||
match manager.metadata_for_tracks(&ids).await {
|
||||
Ok(metadata) => {
|
||||
let by_id = ranked
|
||||
.into_iter()
|
||||
.map(|track| (track.track_id, track))
|
||||
.collect::<HashMap<_, _>>();
|
||||
let hits = metadata
|
||||
.into_iter()
|
||||
.filter_map(|track| {
|
||||
let ranked = by_id.get(&track.track_id)?;
|
||||
let hit = SimilarityHit {
|
||||
score: ranked.score,
|
||||
item_id: hex(
|
||||
ItemId::derive(
|
||||
&own,
|
||||
ItemKind::Track,
|
||||
&format!("track:{}", track.track_id),
|
||||
)
|
||||
.as_bytes(),
|
||||
),
|
||||
title: track.title,
|
||||
artist_names: track.artist_names,
|
||||
featured_artist_names: track.featured_artist_names,
|
||||
year: track.year,
|
||||
duration_seconds: Some(track.duration_seconds.round() as i64),
|
||||
content_id: track.content_id,
|
||||
release_title: Some(track.release_title),
|
||||
track_number: track.track_number,
|
||||
disc_number: track.disc_number,
|
||||
embedding_signature: Some(ranked.embedding_signature),
|
||||
};
|
||||
match hit.validate() {
|
||||
Ok(()) => Some(hit),
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "invalid local similarity metadata skipped");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
SimilarityResponse::success(hits)?
|
||||
}
|
||||
Err(error) => SimilarityResponse::refused(format!(
|
||||
"similarity metadata is unavailable: {error:#}"
|
||||
))?,
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
SimilarityResponse::refused(format!("similarity query is unavailable: {error:#}"))?
|
||||
}
|
||||
}
|
||||
};
|
||||
wire::write_response(&mut stream, &response).await?;
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
super::record_stream_transport(&transport, "similarity", "inbound", "done", &stream);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn search(
|
||||
service: Arc<MusicDhtService>,
|
||||
query: QueryVector,
|
||||
limit: usize,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<Vec<RemoteSimilarityTrack>> {
|
||||
let own = service.endpoint_id();
|
||||
let mut peers = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
for peer in service
|
||||
.connected_peers()
|
||||
.into_iter()
|
||||
.chain(service.known_peers().into_iter().map(|peer| peer.peer_id))
|
||||
{
|
||||
if peer != own && seen.insert(peer) {
|
||||
peers.push(peer);
|
||||
}
|
||||
if peers.len() >= MAX_QUERY_PEERS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let query_signature = wire::embedding_signature(&query.vector)?;
|
||||
let request = Arc::new(SimilarityRequest::new(
|
||||
query.profile_id,
|
||||
query.vector,
|
||||
limit.clamp(1, wire::MAX_SIMILARITY_RESULTS),
|
||||
)?);
|
||||
|
||||
let responses = stream::iter(peers.into_iter().map(|peer| {
|
||||
let service = Arc::clone(&service);
|
||||
let request = Arc::clone(&request);
|
||||
let transport = Arc::clone(&transport);
|
||||
async move {
|
||||
tokio::time::timeout(
|
||||
QUERY_TIMEOUT,
|
||||
query_peer(service, peer, &request, transport),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("similarity peer timed out"))?
|
||||
}
|
||||
}))
|
||||
.buffer_unordered(QUERY_CONCURRENCY)
|
||||
.collect::<Vec<_>>()
|
||||
.await;
|
||||
|
||||
let mut hits = Vec::new();
|
||||
for response in responses {
|
||||
match response {
|
||||
Ok(peer_hits) => hits.extend(peer_hits),
|
||||
Err(error) => tracing::debug!(%error, "similarity peer query skipped"),
|
||||
}
|
||||
}
|
||||
hits.sort_by(|left, right| right.1.total_cmp(&left.1));
|
||||
let mut dedup = HashSet::new();
|
||||
let mut signatures = vec![query_signature];
|
||||
let mut artist_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut tracks = Vec::new();
|
||||
for (track, _, signature) in hits {
|
||||
if query
|
||||
.source_content_id
|
||||
.as_deref()
|
||||
.is_some_and(|source| track.content_id.as_deref() == Some(source))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let key = track
|
||||
.content_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}:{}", track.owner, track.item_id));
|
||||
if !dedup.insert(key) {
|
||||
continue;
|
||||
}
|
||||
if signature.is_some_and(|candidate| {
|
||||
signatures.iter().any(|existing| {
|
||||
wire::signature_distance(&candidate, existing)
|
||||
<= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE
|
||||
})
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
let artist = track
|
||||
.artist_names
|
||||
.first()
|
||||
.map(|name| music_dht::normalize_name(name))
|
||||
.unwrap_or_default();
|
||||
let count = artist_counts.entry(artist.clone()).or_default();
|
||||
if !artist.is_empty() && *count >= MAX_PER_ARTIST {
|
||||
continue;
|
||||
}
|
||||
*count += 1;
|
||||
if let Some(signature) = signature {
|
||||
signatures.push(signature);
|
||||
}
|
||||
tracks.push(track);
|
||||
if tracks.len() >= limit.min(wire::MAX_SIMILARITY_RESULTS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(tracks)
|
||||
}
|
||||
|
||||
async fn query_peer(
|
||||
service: Arc<MusicDhtService>,
|
||||
owner: EndpointId,
|
||||
request: &SimilarityRequest,
|
||||
transport: Arc<TransportStats>,
|
||||
) -> Result<
|
||||
Vec<(
|
||||
RemoteSimilarityTrack,
|
||||
f32,
|
||||
Option<[u8; wire::SIMILARITY_SIGNATURE_BYTES]>,
|
||||
)>,
|
||||
> {
|
||||
let mut stream = service
|
||||
.open_stream(owner, SIMILARITY_ALPN)
|
||||
.await
|
||||
.map_err(|error| anyhow::anyhow!("cannot reach similarity peer: {error}"))?;
|
||||
super::record_stream_transport(&transport, "similarity", "outbound", "open", &stream);
|
||||
let response = wire::exchange(&mut stream, request).await?;
|
||||
super::record_stream_transport(&transport, "similarity", "outbound", "done", &stream);
|
||||
anyhow::ensure!(
|
||||
response.ok,
|
||||
"peer refused similarity query: {}",
|
||||
response.error.unwrap_or_default()
|
||||
);
|
||||
Ok(response
|
||||
.hits
|
||||
.into_iter()
|
||||
.map(|hit| {
|
||||
let score = hit.score;
|
||||
let signature = hit.embedding_signature;
|
||||
(
|
||||
RemoteSimilarityTrack {
|
||||
owner: owner.to_string(),
|
||||
item_id: hit.item_id,
|
||||
title: hit.title,
|
||||
artist_names: hit.artist_names,
|
||||
featured_artist_names: hit.featured_artist_names,
|
||||
year: hit.year,
|
||||
duration_seconds: hit.duration_seconds,
|
||||
content_id: hit.content_id,
|
||||
release_title: hit.release_title,
|
||||
track_number: hit.track_number,
|
||||
disc_number: hit.disc_number,
|
||||
},
|
||||
score,
|
||||
signature,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
bytes.iter().map(|byte| format!("{byte:02x}")).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hamming_threshold_keeps_exact_and_near_duplicates_out() {
|
||||
let query = [0u8; wire::SIMILARITY_SIGNATURE_BYTES];
|
||||
let mut near = query;
|
||||
near[0] = 0b0000_0111;
|
||||
assert!(wire::signature_distance(&query, &near) <= MAX_NEAR_DUPLICATE_SIGNATURE_DISTANCE);
|
||||
}
|
||||
}
|
||||
@@ -298,6 +298,9 @@ translations! {
|
||||
player_likes_playlist: "Likes" , "Лайки";
|
||||
player_listened: "listened" , "прослушано";
|
||||
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
||||
player_search_similar_to: "Search similar to:" , "Поиск похожих на:";
|
||||
player_find_similar: "Find similar tracks" , "Найти похожие треки";
|
||||
player_similarity_failed: "Similarity search failed" , "Не удалось найти похожие треки";
|
||||
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
||||
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||
player_active_device: "Active device" , "Активный девайс";
|
||||
@@ -314,6 +317,9 @@ translations! {
|
||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||
player_rename: "Rename" , "Переименовать";
|
||||
player_close: "Close" , "Закрыть";
|
||||
player_interface_language: "Interface language" , "Язык интерфейса";
|
||||
player_language_description: "Choose the language used by the web player." , "Выберите язык интерфейса веб-плеера.";
|
||||
player_switch_language: "Русский" , "English";
|
||||
player_log_out: "Log out" , "Выйти";
|
||||
player_admin_panel: "Admin Panel" , "Админка";
|
||||
player_info: "Info" , "Информация";
|
||||
|
||||
+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,324 @@ 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()];
|
||||
}
|
||||
|
||||
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||
&M0006CreateMediaFile,
|
||||
&M0007CreateArtist,
|
||||
@@ -2253,5 +2571,9 @@ pub mod db_migrations {
|
||||
&M0037CreatePlaylistShareLinks,
|
||||
&M0038CreateFedDeviceSync,
|
||||
&M0039EnsureFederationContentIdCache,
|
||||
&M0040CreateContentAddressedMusicRefs,
|
||||
&M0041CreateSyncedListenHistory,
|
||||
&M0042RepairLegacyListenQualification,
|
||||
&M0043CreateSimilarityEmbeddings,
|
||||
];
|
||||
}
|
||||
|
||||
+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>,
|
||||
|
||||
+1435
-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,
|
||||
|
||||
+1323
File diff suppressed because it is too large
Load Diff
+192
-2
@@ -2192,6 +2192,63 @@ tbody tr:hover {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Similarity Search</strong>
|
||||
<span>Local audio embeddings and federated nearest-track search</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-grid">
|
||||
<div class="setting-toggle settings-wide">
|
||||
<label>
|
||||
<span>Similarity search enabled</span>
|
||||
<span class="source-pill" :class="sourceClass('similarity_enabled')" x-text="settingSource('similarity_enabled')"></span>
|
||||
</label>
|
||||
<div class="setting-toggle-row">
|
||||
<span x-text="settingsDraft.similarity_enabled ? 'Enabled for this instance' : 'Disabled'"></span>
|
||||
<input type="checkbox" x-model="settingsDraft.similarity_enabled" />
|
||||
</div>
|
||||
<div class="setting-help">Downloads the selected model and processes every visible local track. When federation is also enabled, this instance sends anonymized query embeddings to peers and answers their searches.</div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
<span>Embedding model</span>
|
||||
<span class="source-pill" :class="sourceClass('similarity_model')" x-text="settingSource('similarity_model')"></span>
|
||||
</label>
|
||||
<select x-model="settingsDraft.similarity_model">
|
||||
<template x-for="model in similarityStatus.models || []" :key="model.id">
|
||||
<option :value="model.id" x-text="`${model.id} · ${model.dimensions}d`"></option>
|
||||
</template>
|
||||
</select>
|
||||
<div class="setting-help" x-text="(similarityStatus.models || []).find(model => model.id === settingsDraft.similarity_model)?.license || ''"></div>
|
||||
</div>
|
||||
<div class="setting-field settings-wide">
|
||||
<label>
|
||||
<span>Preprocessing profile</span>
|
||||
<span class="source-pill" :class="sourceClass('similarity_profile')" x-text="settingSource('similarity_profile')"></span>
|
||||
</label>
|
||||
<select x-model="settingsDraft.similarity_profile">
|
||||
<template x-for="profile in similarityStatus.profiles || []" :key="profile.id">
|
||||
<option :value="profile.id" x-text="profile.title"></option>
|
||||
</template>
|
||||
</select>
|
||||
<details class="setting-help" style="margin-top:8px">
|
||||
<summary style="cursor:pointer">Show profile details</summary>
|
||||
<pre style="white-space:pre-wrap;font:inherit;margin:8px 0 0" x-text="selectedSimilarityProfile()?.details || 'Profile details are loading…'"></pre>
|
||||
</details>
|
||||
</div>
|
||||
<div class="setting-field">
|
||||
<label>
|
||||
<span>Background workers</span>
|
||||
<span class="source-pill" :class="sourceClass('similarity_workers')" x-text="settingSource('similarity_workers')"></span>
|
||||
</label>
|
||||
<input type="number" min="1" max="16" step="1" x-model="settingsDraft.similarity_workers" />
|
||||
<div class="setting-help">Applied immediately after saving.</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="settings-column settings-side">
|
||||
@@ -2293,6 +2350,17 @@ tbody tr:hover {
|
||||
<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>
|
||||
<div class="setting-field">
|
||||
<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">Server-wide policy. Imported tracks become available to every user and are published by this peer. Federation metadata is trusted and bypasses the AI agent.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="probe-body" x-show="federationStatus.node">
|
||||
<div class="probe-table" x-show="federationStatus.node && federationStatus.node.running">
|
||||
@@ -2312,7 +2380,7 @@ tbody tr:hover {
|
||||
</div>
|
||||
<div class="probe-row"><span>RTT</span><strong x-text="fedRtt(fedTransport().last_rtt_ms)"></strong></div>
|
||||
<div class="probe-row"><span>Path samples</span><strong x-text="`${fedTransport().direct_samples || 0} direct · ${fedTransport().relay_samples || 0} relay · ${fedTransport().custom_samples || 0} custom · ${fedTransport().unknown_samples || 0} unknown`"></strong></div>
|
||||
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
||||
<div class="probe-row"><span>Protocols</span><strong x-text="`${fedTransport().audio_samples || 0} audio · ${fedTransport().catalog_samples || 0} catalog · ${fedTransport().similarity_samples || 0} similarity · ${fedTransport().sync_samples || 0} sync`"></strong></div>
|
||||
<div class="probe-row"><span>Last peer</span><strong x-text="fedShort(fedTransport().last_peer)"></strong></div>
|
||||
</div>
|
||||
<div class="probe-table" x-show="fedTransport().last && fedTransport().last.length" style="margin-top:10px">
|
||||
@@ -2355,6 +2423,44 @@ tbody tr:hover {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
<strong>Similarity Status</strong>
|
||||
<span>Model download, indexing, and active profile</span>
|
||||
</div>
|
||||
<span class="badge" :class="similarityBadge()" x-text="similarityStatus.status?.phase || 'disabled'"></span>
|
||||
</div>
|
||||
<div class="probe-body">
|
||||
<div class="probe-table">
|
||||
<div class="probe-row"><span>Model</span><strong x-text="similarityStatus.status?.model || settingsDraft.similarity_model || '-' "></strong></div>
|
||||
<div class="probe-row"><span>Active profile</span><strong :title="similarityStatus.status?.active_profile || ''" x-text="similarityStatus.status?.active_profile ? similarityStatus.status.active_profile.slice(0, 22) + '…' : 'not ready'"></strong></div>
|
||||
<div class="probe-row" x-show="similarityStatus.status?.target_profile && similarityStatus.status?.target_profile !== similarityStatus.status?.active_profile">
|
||||
<span>Processing profile</span><strong :title="similarityStatus.status?.target_profile || ''" x-text="similarityStatus.status?.target_profile?.slice(0, 22) + '…'"></strong>
|
||||
</div>
|
||||
<div class="probe-row"><span>Visible tracks</span><strong x-text="similarityStatus.status?.total_tracks ?? 0"></strong></div>
|
||||
<div class="probe-row"><span>Processed</span><strong x-text="`${similarityStatus.status?.completed_tracks || 0} · ${similarityProgress()}%`"></strong></div>
|
||||
<div class="probe-row"><span>Failed</span><strong x-text="similarityStatus.status?.failed_tracks || 0"></strong></div>
|
||||
<div class="probe-row"><span>Stored</span><strong x-text="`${similarityStatus.status?.stored_vectors || 0} vectors · ${formatBytes(similarityStatus.status?.stored_bytes || 0)}`"></strong></div>
|
||||
<div class="probe-row" x-show="similarityStatus.status?.current_track"><span>Current track</span><strong x-text="similarityStatus.status?.current_track"></strong></div>
|
||||
</div>
|
||||
<div style="height:6px;background:rgba(255,255,255,.08);border-radius:999px;overflow:hidden;margin-top:12px" x-show="similarityStatus.status?.phase === 'processing'">
|
||||
<div style="height:100%;background:var(--accent);transition:width .25s" :style="`width:${similarityProgress()}%`"></div>
|
||||
</div>
|
||||
<p class="probe-intro muted" x-show="similarityStatus.status?.last_error" x-text="similarityStatus.status?.last_error"></p>
|
||||
<div class="toolbar" style="margin-top:14px;flex-wrap:wrap;gap:8px">
|
||||
<button class="btn" type="button" @click="loadSimilarity()" :disabled="similarityLoading">
|
||||
<i data-lucide="refresh-cw"></i>
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn danger" type="button" @click="clearSimilarityEmbeddings()" :disabled="similarityLoading || !(similarityStatus.status?.stored_vectors > 0)">
|
||||
<i data-lucide="trash-2"></i>
|
||||
Clear all embeddings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-head">
|
||||
<div class="panel-title">
|
||||
@@ -2924,7 +3030,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,
|
||||
@@ -2932,6 +3043,8 @@ function adminV2() {
|
||||
federationLoading: false,
|
||||
federationTicket: '',
|
||||
fedConnectTicket: '',
|
||||
similarityStatus: { status: { phase: 'disabled' }, models: [], profiles: [] },
|
||||
similarityLoading: false,
|
||||
settingsSaving: false,
|
||||
routeReady: false,
|
||||
poller: null,
|
||||
@@ -2995,6 +3108,11 @@ function adminV2() {
|
||||
]);
|
||||
} else if (this.activeView === 'users') {
|
||||
await Promise.allSettled([this.loadUsers(false)]);
|
||||
} else if (this.activeView === 'settings') {
|
||||
await Promise.allSettled([
|
||||
this.loadFederation(false),
|
||||
this.loadSimilarity(false)
|
||||
]);
|
||||
} else {
|
||||
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
||||
}
|
||||
@@ -3057,6 +3175,10 @@ function adminV2() {
|
||||
} else if (this.activeView === 'settings') {
|
||||
if (updateRoute) this.setRoute('#settings');
|
||||
await this.loadSettings();
|
||||
await Promise.allSettled([
|
||||
this.loadFederation(false),
|
||||
this.loadSimilarity(false)
|
||||
]);
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
@@ -3204,6 +3326,21 @@ function adminV2() {
|
||||
|
||||
async saveSettings() {
|
||||
if (this.settingsSaving) return;
|
||||
const networkSimilarityWasEnabled = Boolean(
|
||||
this.settings.values?.similarity_enabled
|
||||
&& this.settings.values?.federation_enabled
|
||||
);
|
||||
const networkSimilarityWillBeEnabled = Boolean(
|
||||
this.settingsDraft.similarity_enabled
|
||||
&& this.settingsDraft.federation_enabled
|
||||
);
|
||||
if (!networkSimilarityWasEnabled && networkSimilarityWillBeEnabled) {
|
||||
const accepted = window.confirm(
|
||||
'Similarity searches send an anonymized embedding of the selected track to federation peers. '
|
||||
+ 'Peers do not receive a user identity, but the query can technically reveal what is being searched. Enable this for the instance?'
|
||||
);
|
||||
if (!accepted) return;
|
||||
}
|
||||
this.settingsSaving = true;
|
||||
try {
|
||||
await this.request(`${this.apiBase}/settings`, {
|
||||
@@ -3212,6 +3349,7 @@ function adminV2() {
|
||||
});
|
||||
await this.loadSettings(false);
|
||||
await this.loadFederation(false);
|
||||
await this.loadSimilarity(false);
|
||||
this.showToast('Settings saved');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
@@ -3226,6 +3364,7 @@ function adminV2() {
|
||||
this.setRoute('#settings');
|
||||
await this.loadSettings();
|
||||
await this.loadFederation(false);
|
||||
await this.loadSimilarity(false);
|
||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||
await this.loadSettingsProbe(false);
|
||||
}
|
||||
@@ -3243,6 +3382,57 @@ function adminV2() {
|
||||
}
|
||||
},
|
||||
|
||||
async loadSimilarity(showErrors = true) {
|
||||
this.similarityLoading = true;
|
||||
try {
|
||||
this.similarityStatus = await this.request(`${this.apiBase}/similarity`);
|
||||
} catch (error) {
|
||||
if (showErrors) this.showToast(error.message);
|
||||
} finally {
|
||||
this.similarityLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
async clearSimilarityEmbeddings() {
|
||||
if (!window.confirm('Delete embeddings for every model and preprocessing profile? Audio files are not affected.')) return;
|
||||
this.similarityLoading = true;
|
||||
try {
|
||||
await this.request(`${this.apiBase}/similarity/clear`, {
|
||||
method: 'POST',
|
||||
body: '{}'
|
||||
});
|
||||
await this.loadSimilarity(false);
|
||||
this.showToast('All embeddings cleared');
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
this.similarityLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
},
|
||||
|
||||
selectedSimilarityProfile() {
|
||||
return (this.similarityStatus.profiles || []).find(
|
||||
profile => profile.id === this.settingsDraft.similarity_profile
|
||||
) || null;
|
||||
},
|
||||
|
||||
similarityBadge() {
|
||||
const phase = this.similarityStatus.status?.phase || 'disabled';
|
||||
if (phase === 'ready') return 'ok';
|
||||
if (phase === 'error') return 'failed';
|
||||
if (['downloading', 'loading', 'processing'].includes(phase)) return 'running';
|
||||
return 'disabled';
|
||||
},
|
||||
|
||||
similarityProgress() {
|
||||
const status = this.similarityStatus.status || {};
|
||||
const total = Number(status.total_tracks || 0);
|
||||
const completed = Number(status.completed_tracks || 0);
|
||||
return total > 0 ? Math.min(100, Math.round(completed * 100 / total)) : 0;
|
||||
},
|
||||
|
||||
async fedSyncNow() {
|
||||
this.federationLoading = true;
|
||||
try {
|
||||
|
||||
@@ -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()">
|
||||
@@ -683,7 +832,12 @@
|
||||
</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>
|
||||
|
||||
+1194
-18
File diff suppressed because it is too large
Load Diff
+507
-130
@@ -16,13 +16,21 @@
|
||||
<div class="user-name" x-text="$store.user.profile?.name || ''"></div>
|
||||
<div class="user-role" x-text="$store.user.profile?.role || ''"></div>
|
||||
</div>
|
||||
<button class="user-logout-btn" @click="$store.user.logout()" title="{{ t.player_log_out }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="user-widget-actions">
|
||||
<button class="user-logout-btn" @click="$store.user.openSettings()" title="User settings" aria-label="User settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.7 1.7 0 00.34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0015 19.4a1.7 1.7 0 00-1 .6 1.7 1.7 0 00-.4 1.1V21h-4v-.1A1.7 1.7 0 009 19.4a1.7 1.7 0 00-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 004.6 15a1.7 1.7 0 00-.6-1 1.7 1.7 0 00-1.1-.4H3v-4h.1A1.7 1.7 0 004.6 9a1.7 1.7 0 00-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 009 4.6a1.7 1.7 0 001-.6 1.7 1.7 0 00.4-1.1V3h4v.1A1.7 1.7 0 0015 4.6a1.7 1.7 0 001.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0019.4 9a1.7 1.7 0 00.6 1 1.7 1.7 0 001.1.4h.1v4h-.1a1.7 1.7 0 00-1.7.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="user-logout-btn" @click="$store.user.logout()" title="{{ t.player_log_out }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" y1="12" x2="9" y2="12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-stats">
|
||||
<button class="user-stat" @click="$store.history.open()">
|
||||
@@ -38,19 +46,6 @@
|
||||
<span class="user-stat-label">{{ t.player_listened }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="lastfm-profile-action"
|
||||
:class="$store.user.lastfmClass()"
|
||||
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||||
@click="$store.user.handleLastfm()"
|
||||
:title="$store.user.lastfmLabel()"
|
||||
:aria-label="$store.user.lastfmLabel()">
|
||||
<span class="lastfm-dot"></span>
|
||||
<span class="lastfm-profile-text">
|
||||
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
|
||||
<span class="lastfm-profile-separator">·</span>
|
||||
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="sidebar-header">
|
||||
<h2>{{ t.player_library }}</h2>
|
||||
@@ -363,35 +358,44 @@
|
||||
<span class="user-stat-label">{{ t.player_listened }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button class="lastfm-profile-action"
|
||||
:class="$store.user.lastfmClass()"
|
||||
:disabled="$store.user.lastfmBusy || !$store.user.lastfm?.configured"
|
||||
@click="$store.user.handleLastfm()"
|
||||
:title="$store.user.lastfmLabel()"
|
||||
:aria-label="$store.user.lastfmLabel()">
|
||||
<span class="lastfm-dot"></span>
|
||||
<span class="lastfm-profile-text">
|
||||
<span class="lastfm-profile-brand">{{ t.player_lastfm_profile }}</span>
|
||||
<span class="lastfm-profile-separator">·</span>
|
||||
<span class="lastfm-profile-status" x-text="$store.user.lastfmStatusLabel()"></span>
|
||||
</span>
|
||||
</button>
|
||||
<button class="modal-btn modal-btn-primary mobile-account-logout"
|
||||
@click="$store.user.logout()">
|
||||
{{ t.player_log_out }}
|
||||
</button>
|
||||
<div class="mobile-account-actions">
|
||||
<button class="user-logout-btn"
|
||||
@click="$store.user.menuOpen = false; $store.user.openSettings()"
|
||||
title="User settings"
|
||||
aria-label="User settings">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.7 1.7 0 00.34 1.88l.06.06-2.83 2.83-.06-.06A1.7 1.7 0 0015 19.4a1.7 1.7 0 00-1 .6 1.7 1.7 0 00-.4 1.1V21h-4v-.1A1.7 1.7 0 009 19.4a1.7 1.7 0 00-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 004.6 15a1.7 1.7 0 00-.6-1 1.7 1.7 0 00-1.1-.4H3v-4h.1A1.7 1.7 0 004.6 9a1.7 1.7 0 00-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 009 4.6a1.7 1.7 0 001-.6 1.7 1.7 0 00.4-1.1V3h4v.1A1.7 1.7 0 0015 4.6a1.7 1.7 0 001.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0019.4 9a1.7 1.7 0 00.6 1 1.7 1.7 0 001.1.4h.1v4h-.1a1.7 1.7 0 00-1.7.6z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="modal-btn modal-btn-primary mobile-account-logout"
|
||||
@click="$store.user.logout()">
|
||||
{{ t.player_log_out }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Results -->
|
||||
<template x-if="$store.library.view === 'search'">
|
||||
<div>
|
||||
<h2 class="search-similarity-title"
|
||||
x-show="$store.library.similaritySearchLabel"
|
||||
x-cloak>
|
||||
<span>{{ t.player_search_similar_to }}</span>
|
||||
<strong x-text="$store.library.similaritySearchLabel"></strong>
|
||||
</h2>
|
||||
<template x-if="$store.library.searchLoading">
|
||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||
</template>
|
||||
<template x-if="!$store.library.searchLoading && $store.library.similaritySearchError">
|
||||
<div class="empty-state">
|
||||
<p x-text="$store.library.similaritySearchError"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.library.searchLoading && $store.library.searchResults">
|
||||
<div>
|
||||
<template x-if="$store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
||||
<template x-if="!$store.library.similaritySearchError && $store.library.searchResults.artists.length === 0 && $store.library.searchResults.releases.length === 0 && $store.library.searchResults.tracks.length === 0">
|
||||
<div class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
||||
<p>{{ t.player_no_results }}</p>
|
||||
@@ -505,6 +509,143 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="search-section federation-search-section">
|
||||
<h2 class="search-section-title">
|
||||
Federation
|
||||
<span class="federation-live-badge"
|
||||
x-show="$store.library.federationSearch.loading"
|
||||
x-cloak>LIVE</span>
|
||||
</h2>
|
||||
<template x-if="$store.library.federationSearch.error">
|
||||
<div class="federation-search-status error"
|
||||
x-text="$store.library.federationSearch.error"></div>
|
||||
</template>
|
||||
<template x-if="$store.library.federationSearch.loading && $store.library.federationSearch.tracks.length === 0">
|
||||
<div class="federation-search-status">Searching peers…</div>
|
||||
</template>
|
||||
<div class="search-artists-row"
|
||||
x-show="$store.library.federationSearch.artists.length > 0"
|
||||
x-cloak>
|
||||
<template x-for="artist in $store.library.federationSearch.artists"
|
||||
:key="artist.key.normalized_name">
|
||||
<div class="search-artist-card federation-entity-card"
|
||||
@click="$store.library.openFederatedArtist(artist)">
|
||||
<div class="search-artist-img">
|
||||
<img x-show="$store.library.federationArtistImage(artist)"
|
||||
:src="$store.library.federationArtistImage(artist)"
|
||||
:alt="artist.name"
|
||||
loading="lazy"
|
||||
@error="$event.currentTarget.style.display = 'none'">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="search-artist-name" x-text="artist.name"></div>
|
||||
<div class="federation-source-count"
|
||||
x-text="`${artist.peers.length} peer${artist.peers.length === 1 ? '' : 's'}`"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="search-releases-row"
|
||||
x-show="$store.library.federationSearch.releases.length > 0"
|
||||
x-cloak>
|
||||
<template x-for="release in $store.library.federationSearch.releases"
|
||||
:key="JSON.stringify(release.key)">
|
||||
<div class="search-release-card federation-entity-card">
|
||||
<div class="search-release-cover">
|
||||
<img x-show="release.cover_url"
|
||||
:src="release.cover_url"
|
||||
:alt="release.title"
|
||||
loading="lazy">
|
||||
<svg x-show="!release.cover_url"
|
||||
viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-title" x-text="release.title"></div>
|
||||
<div class="card-subtitle"
|
||||
x-text="[release.year, ...(release.artists || [])].filter(Boolean).join(' · ')"></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.federationSearch.tracks"
|
||||
:key="track.key.content_id">
|
||||
<div class="track-row federation-track-row"
|
||||
@dblclick="$store.library.playFederatedTrack(track)">
|
||||
<span class="track-num federation-track-status">
|
||||
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<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>
|
||||
</template>
|
||||
<template x-if="$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||
<span class="federation-error-badge"
|
||||
:title="$store.library.federationFailure(track.key.content_id).message">!</span>
|
||||
</template>
|
||||
<template x-if="$store.library.federationDownload(track.key.content_id)">
|
||||
<span class="federation-download-progress"
|
||||
:class="{ indeterminate: !$store.library.federationDownload(track.key.content_id).total }">
|
||||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||||
<circle class="progress-track" cx="10" cy="10" r="8"/>
|
||||
<circle class="progress-value" cx="10" cy="10" r="8"
|
||||
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(track.key.content_id).percent / 100)}`"/>
|
||||
</svg>
|
||||
<span class="progress-percent"
|
||||
x-show="$store.library.federationDownload(track.key.content_id).total"
|
||||
x-text="Math.round($store.library.federationDownload(track.key.content_id).percent)"></span>
|
||||
<span class="federation-download-tooltip"
|
||||
x-text="$store.library.federationDownloadTooltip(track.key.content_id)"></span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="track.metadata.title"></div>
|
||||
<div class="track-artists-inline"
|
||||
x-text="$store.library.federationArtistLine(track)"></div>
|
||||
</div>
|
||||
<span class="federation-source-count"
|
||||
x-text="`${track.availability.federation.length} peer${track.availability.federation.length === 1 ? '' : 's'}`"></span>
|
||||
<span class="track-actions">
|
||||
<button class="track-action-btn info-btn"
|
||||
@click.stop="$store.library.openFederationTrackInfo(track)"
|
||||
title="{{ t.player_track_info }}">
|
||||
<span class="info-letter">i</span>
|
||||
</button>
|
||||
<button class="like-btn"
|
||||
:class="{ liked: $store.library.federationIsLiked(track) }"
|
||||
@click.stop="$store.library.toggleFederationLike(track)"
|
||||
title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24"
|
||||
:fill="$store.library.federationIsLiked(track) ? 'currentColor' : 'none'"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn"
|
||||
@click.stop="$store.library.enqueueFederatedTrack(track, true)"
|
||||
title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn"
|
||||
@click.stop="$store.library.enqueueFederatedTrack(track, false)"
|
||||
title="{{ t.player_add_to_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn"
|
||||
@click.stop="$store.playlists.showFederationPicker(track)"
|
||||
title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span class="track-duration"
|
||||
x-text="formatTime(track.metadata.duration_seconds)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -580,7 +721,9 @@
|
||||
<div class="artist-header">
|
||||
<div class="artist-img">
|
||||
<template x-if="$store.library.currentArtist.image_url">
|
||||
<img :src="$store.library.currentArtist.image_url" :alt="$store.library.currentArtist.name">
|
||||
<img :src="$store.library.currentArtist.image_url"
|
||||
:alt="$store.library.currentArtist.name"
|
||||
@error="$store.library.currentArtist.image_url = null">
|
||||
</template>
|
||||
<template x-if="!$store.library.currentArtist.image_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
@@ -589,15 +732,18 @@
|
||||
<div>
|
||||
<div class="artist-name" x-text="$store.library.currentArtist.name"></div>
|
||||
<div class="artist-stats">
|
||||
<span x-text="$store.library.currentArtist.releases.length + ' {{ t.player_releases_count }}'"></span>
|
||||
<span x-text="($store.library.currentArtist.releases.length + $store.library.artistFederation.releases.length) + ' {{ t.player_releases_count }}'"></span>
|
||||
<span>•</span>
|
||||
<span x-text="$store.library.currentArtist.total_track_count + ' {{ t.player_tracks_count }}'"></span>
|
||||
<span x-text="($store.library.currentArtist.total_track_count + $store.library.artistFederation.tracks.length) + ' {{ t.player_tracks_count }}'"></span>
|
||||
<span>•</span>
|
||||
<span x-text="$store.library.currentArtist.total_play_count + ' {{ t.player_plays_count }}'"></span>
|
||||
<span class="federation-live-badge"
|
||||
x-show="$store.library.artistFederation.loading"
|
||||
x-cloak>FEDERATION LIVE</span>
|
||||
</div>
|
||||
<div class="release-actions artist-actions">
|
||||
<button class="release-action-btn primary artist-listen-action"
|
||||
:disabled="!($store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length)"
|
||||
:disabled="!($store.library.currentArtist.top_tracks?.length || $store.library.artistFederation.tracks.length)"
|
||||
@click="$store.library.playArtistTopTracks()"
|
||||
title="{{ t.player_listen_artist }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
@@ -606,6 +752,7 @@
|
||||
<span>{{ t.player_listen }}</span>
|
||||
</button>
|
||||
<button class="release-action-btn secondary artist-follow-action"
|
||||
x-show="$store.library.currentArtist.id"
|
||||
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
|
||||
@click="$store.follows.toggle($store.library.currentArtist.id)"
|
||||
:title="$store.follows.has($store.library.currentArtist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||||
@@ -621,8 +768,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
||||
<section class="artist-release-group" x-data="{ expanded: false }">
|
||||
<div class="artist-release-group-heading">
|
||||
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
||||
<button class="artist-top-tracks-toggle"
|
||||
type="button"
|
||||
x-show="$store.library.currentArtist.top_tracks.length > 5"
|
||||
@click="expanded = !expanded"
|
||||
:aria-expanded="expanded"
|
||||
x-cloak>
|
||||
<span x-text="expanded ? '{{ t.player_collapse }}' : '{{ t.player_expand_all }}'"></span>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
:class="{ expanded: expanded }">
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="track-list-header">
|
||||
<span>#</span>
|
||||
<span>{{ t.player_title }}</span>
|
||||
@@ -630,7 +791,9 @@
|
||||
<span></span>
|
||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
|
||||
<template x-for="(track, idx) in (expanded
|
||||
? $store.library.currentArtist.top_tracks
|
||||
: $store.library.currentArtist.top_tracks.slice(0, 5))" :key="track.id">
|
||||
<div class="track-row artist-appearance-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
|
||||
@@ -705,7 +868,8 @@
|
||||
<div class="card" @click="$store.library.openRelease(release.id)">
|
||||
<div class="card-img">
|
||||
<template x-if="release.cover_url">
|
||||
<img :src="release.cover_url" :alt="release.title" loading="lazy">
|
||||
<img :src="release.cover_url" :alt="release.title"
|
||||
loading="lazy" @error="release.cover_url = null">
|
||||
</template>
|
||||
<template x-if="!release.cover_url">
|
||||
<span class="placeholder-icon"><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></span>
|
||||
@@ -730,6 +894,124 @@
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template x-if="$store.library.artistFederation.releases.length > 0">
|
||||
<section class="artist-release-group federation-artist-section">
|
||||
<h2 class="artist-release-group-title">Federation</h2>
|
||||
<div class="card-grid">
|
||||
<template x-for="release in $store.library.artistFederation.releases"
|
||||
:key="release.key">
|
||||
<div class="card federation-release-card"
|
||||
@click="$store.library.openFederatedRelease(release)">
|
||||
<div class="card-img">
|
||||
<template x-if="release.cover_url">
|
||||
<img :src="release.cover_url" :alt="release.title"
|
||||
loading="lazy" @error="release.cover_url = null">
|
||||
</template>
|
||||
<template x-if="!release.cover_url">
|
||||
<span class="placeholder-icon"><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></span>
|
||||
</template>
|
||||
<button class="card-play-btn"
|
||||
:disabled="!release.tracks.length"
|
||||
@click.stop="$store.library.playFederatedRelease(release)">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-title" x-text="release.title"></div>
|
||||
<div class="card-subtitle">
|
||||
<span x-text="release.year || ''"></span>
|
||||
<span x-text="release.tracks.length + ' {{ t.player_tracks_count }}'"></span>
|
||||
<span class="federation-source-count"
|
||||
x-text="release.owners.length + ' peer' + (release.owners.length === 1 ? '' : 's')"></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
<template x-if="$store.library.artistFederation.tracks.length > 0">
|
||||
<section class="artist-release-group federation-artist-section">
|
||||
<h2 class="artist-release-group-title">Federation tracks</h2>
|
||||
<template x-for="(track, idx) in $store.library.artistFederation.tracks"
|
||||
:key="track.key.content_id">
|
||||
<div class="track-row federation-track-row"
|
||||
@dblclick="$store.library.playFederatedTrack(track)">
|
||||
<span class="track-num federation-track-status">
|
||||
<template x-if="!$store.library.federationDownload(track.key.content_id) && !$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||
<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"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="$store.library.federationFailure(track.key.content_id) && !$store.library.isTrackLocal(track)">
|
||||
<span class="federation-error-badge"
|
||||
:title="$store.library.federationFailure(track.key.content_id).message">!</span>
|
||||
</template>
|
||||
<template x-if="$store.library.federationDownload(track.key.content_id)">
|
||||
<span class="federation-download-progress"
|
||||
:class="{ indeterminate: !$store.library.federationDownload(track.key.content_id).total }">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<circle class="progress-track" cx="10" cy="10" r="8"/>
|
||||
<circle class="progress-value" cx="10" cy="10" r="8"
|
||||
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(track.key.content_id).percent / 100)}`"/>
|
||||
</svg>
|
||||
<span class="progress-percent"
|
||||
x-show="$store.library.federationDownload(track.key.content_id).total"
|
||||
x-text="Math.round($store.library.federationDownload(track.key.content_id).percent)"></span>
|
||||
<span class="federation-download-tooltip"
|
||||
x-text="$store.library.federationDownloadTooltip(track.key.content_id)"></span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="track.metadata.title"></div>
|
||||
<div class="track-artists-inline"
|
||||
x-text="[track.metadata.release?.title, $store.library.federationArtistLine(track)].filter(Boolean).join(' · ')"></div>
|
||||
</div>
|
||||
<span></span>
|
||||
<span class="track-actions">
|
||||
<button class="track-action-btn info-btn"
|
||||
@click.stop="$store.library.openFederationTrackInfo(track)"
|
||||
title="{{ t.player_track_info }}">
|
||||
<span class="info-letter">i</span>
|
||||
</button>
|
||||
<button class="like-btn"
|
||||
:class="{ liked: $store.library.federationIsLiked(track) }"
|
||||
@click.stop="$store.library.toggleFederationLike(track)"
|
||||
title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24"
|
||||
:fill="$store.library.federationIsLiked(track) ? 'currentColor' : 'none'"
|
||||
stroke="currentColor" stroke-width="2">
|
||||
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn"
|
||||
@click.stop="$store.library.enqueueFederatedTrack(track, true)"
|
||||
title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn"
|
||||
@click.stop="$store.library.enqueueFederatedTrack(track, false)"
|
||||
title="{{ t.player_add_to_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn"
|
||||
@click.stop="$store.playlists.showFederationPicker(track)"
|
||||
title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/>
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span class="track-duration"
|
||||
x-text="formatTime(track.metadata.duration_seconds)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
<template x-if="$store.library.artistFederation.error">
|
||||
<div class="federation-search-status error"
|
||||
x-text="$store.library.artistFederation.error"></div>
|
||||
</template>
|
||||
<template x-if="$store.library.currentArtist.featured_tracks && $store.library.currentArtist.featured_tracks.length > 0">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title">{{ t.player_appears_on }}</h2>
|
||||
@@ -817,7 +1099,8 @@
|
||||
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
|
||||
<span>/</span>
|
||||
<template x-if="$store.library.currentRelease.artists.length > 0">
|
||||
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
||||
<a @click="$store.library.currentRelease.artists[0].id ? $store.library.openArtist($store.library.currentRelease.artists[0].id) : $store.library.openFederatedArtist($store.library.currentRelease.artists[0].name)"
|
||||
x-text="$store.library.currentRelease.artists[0].name"></a>
|
||||
</template>
|
||||
<span>/</span>
|
||||
<span x-text="$store.library.currentRelease.title"></span>
|
||||
@@ -832,21 +1115,28 @@
|
||||
</template>
|
||||
</div>
|
||||
<div class="release-meta">
|
||||
<div class="release-type" x-text="$store.library.currentRelease.release_type"></div>
|
||||
<div class="release-type">
|
||||
<span x-text="$store.library.currentRelease.release_type"></span>
|
||||
<span class="federation-live-badge"
|
||||
x-show="$store.library.releaseFederationLoading"
|
||||
x-cloak>FEDERATION LIVE</span>
|
||||
</div>
|
||||
<div class="release-title-row">
|
||||
<div class="release-title" x-text="$store.library.currentRelease.title"></div>
|
||||
<button class="like-btn like-btn-lg release-title-like"
|
||||
:class="{ liked: $store.likes.isReleaseLiked($store.library.currentRelease) }"
|
||||
@click.stop="$store.likes.toggleRelease($store.library.currentRelease.id)"
|
||||
:class="{ liked: $store.library.isMixedReleaseLiked($store.library.currentRelease) }"
|
||||
@click.stop="$store.library.toggleMixedReleaseLike($store.library.currentRelease)"
|
||||
title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.isReleaseLiked($store.library.currentRelease) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<svg viewBox="0 0 24 24" :fill="$store.library.isMixedReleaseLiked($store.library.currentRelease) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="release-artists">
|
||||
<template x-for="(artist, artistIdx) in $store.library.currentRelease.artists" :key="artist.id">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click="$store.library.openArtist(artist.id)" x-text="artist.name"></a>
|
||||
<a class="artist-link"
|
||||
@click="artist.id ? $store.library.openArtist(artist.id) : $store.library.openFederatedArtist(artist.name)"
|
||||
x-text="artist.name"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -861,6 +1151,7 @@
|
||||
{{ t.player_info }}
|
||||
</button>
|
||||
<button class="release-action-btn secondary release-share-btn"
|
||||
x-show="!$store.library.currentRelease.federated"
|
||||
@click.stop="$store.sharing.copyRelease($store.library.currentRelease, $event.currentTarget)"
|
||||
title="{{ t.player_share }}"
|
||||
aria-label="{{ t.player_share }}">
|
||||
@@ -897,15 +1188,50 @@
|
||||
<div class="track-row"
|
||||
:data-shared-track-id="track.id"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, 'shared-target': $store.sharing.isSharedTrack(track) }"
|
||||
@dblclick="$store.queue.playRelease([track], 0)">
|
||||
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
||||
@dblclick="$store.queue.playRelease($store.library.currentRelease.tracks, idx)">
|
||||
<span class="track-num federation-track-status">
|
||||
<template x-if="!track.federation_pending">
|
||||
<span x-text="track.track_number || (idx + 1)"></span>
|
||||
</template>
|
||||
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && !$store.library.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
||||
<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>
|
||||
</template>
|
||||
<template x-if="track.federation_pending && $store.library.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
||||
<span class="federation-error-badge"
|
||||
:title="$store.library.federationFailure(track.content_id).message">!</span>
|
||||
</template>
|
||||
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && $store.library.isTrackLocal(track._federationTrack)">
|
||||
<span x-text="track.track_number || (idx + 1)"></span>
|
||||
</template>
|
||||
<template x-if="track.federation_pending && $store.library.federationDownload(track.content_id)">
|
||||
<span class="federation-download-progress"
|
||||
:class="{ indeterminate: !$store.library.federationDownload(track.content_id).total }">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<circle class="progress-track" cx="10" cy="10" r="8"/>
|
||||
<circle class="progress-value" cx="10" cy="10" r="8"
|
||||
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(track.content_id).percent / 100)}`"/>
|
||||
</svg>
|
||||
<span class="progress-percent"
|
||||
x-show="$store.library.federationDownload(track.content_id).total"
|
||||
x-text="Math.round($store.library.federationDownload(track.content_id).percent)"></span>
|
||||
<span class="federation-download-tooltip"
|
||||
x-text="$store.library.federationDownloadTooltip(track.content_id)"></span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="track.title"></div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
<a class="artist-link"
|
||||
@click.stop="artist.id ? $store.library.openArtist(artist.id) : $store.library.openFederatedArtist(artist.label.replace(/^ft\\.\\s*/, ''))"
|
||||
x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -915,14 +1241,19 @@
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||||
:style="$store.library.popularityStyle(track)"
|
||||
@click.stop="$store.library.openTrackInfo(track)"
|
||||
@click.stop="track.federation_pending ? $store.library.openFederationTrackInfo(track._federationTrack) : $store.library.openTrackInfo(track)"
|
||||
:title="$store.library.trackInfoTitle(track)"
|
||||
aria-label="{{ t.player_track_info }}">
|
||||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<button class="like-btn"
|
||||
:class="{ liked: track.federation_pending ? $store.library.federationIsLiked(track._federationTrack) : $store.likes.has(track.id) }"
|
||||
@click.stop="track.federation_pending ? $store.library.toggleFederationLike(track._federationTrack) : $store.likes.toggle(track.id)"
|
||||
title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24"
|
||||
:fill="track.federation_pending ? ($store.library.federationIsLiked(track._federationTrack) ? 'currentColor' : 'none') : ($store.likes.has(track.id) ? 'currentColor' : 'none')"
|
||||
stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
@@ -930,10 +1261,14 @@
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||
<button class="track-action-btn track-share-btn"
|
||||
x-show="!track.federation_pending"
|
||||
@click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<button class="track-action-btn playlist-add-btn"
|
||||
@click.stop="track.federation_pending ? $store.playlists.showFederationPicker(track._federationTrack) : $store.playlists.showPicker([track.id])"
|
||||
title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1012,7 +1347,37 @@
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<span class="track-num federation-track-status">
|
||||
<template x-if="!track.federation_pending || $store.library.isTrackLocal(track._federationTrack)">
|
||||
<span x-text="idx + 1"></span>
|
||||
</template>
|
||||
<template x-if="track.federation_pending && !$store.library.federationDownload(track.content_id) && !$store.library.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
||||
<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>
|
||||
</template>
|
||||
<template x-if="track.federation_pending && $store.library.federationFailure(track.content_id) && !$store.library.isTrackLocal(track._federationTrack)">
|
||||
<span class="federation-error-badge"
|
||||
:title="$store.library.federationFailure(track.content_id).message">!</span>
|
||||
</template>
|
||||
<template x-if="track.federation_pending && $store.library.federationDownload(track.content_id)">
|
||||
<span class="federation-download-progress"
|
||||
:class="{ indeterminate: !$store.library.federationDownload(track.content_id).total }">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<circle class="progress-track" cx="10" cy="10" r="8"/>
|
||||
<circle class="progress-value" cx="10" cy="10" r="8"
|
||||
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(track.content_id).percent / 100)}`"/>
|
||||
</svg>
|
||||
<span class="progress-percent"
|
||||
x-show="$store.library.federationDownload(track.content_id).total"
|
||||
x-text="Math.round($store.library.federationDownload(track.content_id).percent)"></span>
|
||||
<span class="federation-download-tooltip"
|
||||
x-text="$store.library.federationDownloadTooltip(track.content_id)"></span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
<div class="track-info playlist-track-info">
|
||||
<button class="playlist-drag-handle"
|
||||
x-show="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||
@@ -1030,7 +1395,9 @@
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
<a class="artist-link"
|
||||
@click.stop="artist.id ? $store.library.openArtist(artist.id) : $store.library.openFederatedArtist(artist.label.replace(/^ft\\.\\s*/, ''))"
|
||||
x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1041,14 +1408,19 @@
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
|
||||
:style="$store.library.popularityStyle(track)"
|
||||
@click.stop="$store.library.openTrackInfo(track)"
|
||||
@click.stop="track.federation_pending ? $store.library.openFederationTrackInfo(track._federationTrack) : $store.library.openTrackInfo(track)"
|
||||
:title="$store.library.trackInfoTitle(track)"
|
||||
aria-label="{{ t.player_track_info }}">
|
||||
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
|
||||
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
<button class="like-btn"
|
||||
:class="{ liked: track.federation_pending ? $store.library.federationIsLiked(track._federationTrack) : $store.likes.has(track.id) }"
|
||||
@click.stop="track.federation_pending ? $store.library.toggleFederationLike(track._federationTrack) : $store.likes.toggle(track.id)"
|
||||
title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24"
|
||||
:fill="track.federation_pending ? ($store.library.federationIsLiked(track._federationTrack) ? 'currentColor' : 'none') : ($store.likes.has(track.id) ? 'currentColor' : 'none')"
|
||||
stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
@@ -1059,7 +1431,9 @@
|
||||
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<button class="track-action-btn playlist-add-btn"
|
||||
@click.stop="track.federation_pending ? $store.playlists.showFederationPicker(track._federationTrack) : $store.playlists.showPicker([track.id])"
|
||||
title="{{ t.player_add_to_playlist }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
@@ -1118,6 +1492,35 @@
|
||||
<template x-if="!item.track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
</template>
|
||||
<span class="queue-federation-status federation-track-status"
|
||||
x-show="item.track.federation_pending"
|
||||
x-cloak>
|
||||
<template x-if="!$store.library.federationDownload(item.track.content_id) && !$store.library.federationFailure(item.track.content_id)">
|
||||
<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"/>
|
||||
</svg>
|
||||
</template>
|
||||
<template x-if="$store.library.federationFailure(item.track.content_id)">
|
||||
<span class="federation-error-badge"
|
||||
:title="$store.library.federationFailure(item.track.content_id).message">!</span>
|
||||
</template>
|
||||
<template x-if="$store.library.federationDownload(item.track.content_id)">
|
||||
<span class="federation-download-progress"
|
||||
:class="{ indeterminate: !$store.library.federationDownload(item.track.content_id).total }">
|
||||
<svg viewBox="0 0 20 20">
|
||||
<circle class="progress-track" cx="10" cy="10" r="8"/>
|
||||
<circle class="progress-value" cx="10" cy="10" r="8"
|
||||
:style="`stroke-dashoffset: ${50.27 * (1 - $store.library.federationDownload(item.track.content_id).percent / 100)}`"/>
|
||||
</svg>
|
||||
<span class="progress-percent"
|
||||
x-show="$store.library.federationDownload(item.track.content_id).total"
|
||||
x-text="Math.round($store.library.federationDownload(item.track.content_id).percent)"></span>
|
||||
<span class="federation-download-tooltip"
|
||||
x-text="$store.library.federationDownloadTooltip(item.track.content_id)"></span>
|
||||
</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="queue-track-info">
|
||||
<div class="queue-track-title" x-text="item.track.title"></div>
|
||||
@@ -1125,7 +1528,9 @@
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||
<span>
|
||||
<template x-if="artistIdx > 0"><span>, </span></template>
|
||||
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
<a class="artist-link"
|
||||
@click.stop="artist.id ? $store.library.openArtist(artist.id) : $store.library.openFederatedArtist(artist.label.replace(/^ft\\.\\s*/, ''))"
|
||||
x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -1134,7 +1539,7 @@
|
||||
<button class="queue-track-remove info-btn popularity-info-btn"
|
||||
:class="{ 'has-popularity': $store.library.hasPopularity(item.track), 'no-popularity': !$store.library.hasPopularity(item.track) }"
|
||||
:style="$store.library.popularityStyle(item.track)"
|
||||
@click.stop="$store.library.openTrackInfo(item.track)"
|
||||
@click.stop="item.track.federation_pending ? $store.library.openFederationTrackInfo(item.track._federationTrack) : $store.library.openTrackInfo(item.track)"
|
||||
:title="$store.library.trackInfoTitle(item.track)"
|
||||
aria-label="{{ t.player_track_info }}">
|
||||
<span x-show="$store.library.hasPopularity(item.track)" x-text="$store.library.popularityLabel(item.track)"></span>
|
||||
@@ -1248,7 +1653,11 @@
|
||||
x-text="'-' + formatTime(Math.max(0, $store.player.duration - $store.player.currentTime)) + ' / ' + formatTime($store.player.duration)"></div>
|
||||
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
|
||||
</div>
|
||||
<div class="player-version-chip">v{{ t.app_version() }}</div>
|
||||
<a class="player-version-chip"
|
||||
href="https://github.com/house-of-vanity/furumusic"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label="Furumusic on GitHub">v{{ t.app_version() }}</a>
|
||||
</div>
|
||||
|
||||
<div class="player-right">
|
||||
@@ -1305,7 +1714,8 @@
|
||||
</span>
|
||||
</button>
|
||||
<div class="device-popover" x-show="$store.devices.open" x-transition x-cloak>
|
||||
<template x-for="device in $store.devices.devices" :key="device.id">
|
||||
<div class="device-section-label">Web players</div>
|
||||
<template x-for="device in $store.devices.webDevices()" :key="device.id">
|
||||
<button class="device-row"
|
||||
:class="{ active: device.is_active, 'current-device': device.is_current }"
|
||||
@click="$store.devices.select(device.id)">
|
||||
@@ -1335,67 +1745,34 @@
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="device-section-label fed-section-label">Fed Clients</div>
|
||||
<div class="fed-device-panel">
|
||||
<div class="fed-device-status" x-text="$store.devices.fedSummary()"></div>
|
||||
<template x-if="$store.devices.fedError">
|
||||
<div class="fed-device-error" x-text="$store.devices.fedError"></div>
|
||||
</template>
|
||||
<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 in another sync group' : (request.client_version || 'waiting for approval')"></div>
|
||||
<template x-if="request.requester_group_id">
|
||||
<div class="fed-pairing-note">Recommended keeps the existing group intact.</div>
|
||||
</template>
|
||||
<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 ? 'Recommended' : 'Approve'"></button>
|
||||
<button class="fed-action-btn"
|
||||
@click="$store.devices.answerFedPairing(request, false, false)">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="device in $store.devices.fedDevices()" :key="device.device_id">
|
||||
<div class="fed-device-row" :class="{ self: device.is_self }">
|
||||
<span class="fed-device-dot" :class="{ self: device.is_self }"></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 ? 'WEB' : (device.client_version || 'unknown'))"></span>
|
||||
</span>
|
||||
<button class="fed-revoke-btn"
|
||||
x-show="!device.is_self"
|
||||
@click="$store.devices.revokeFedDevice(device)">Revoke</button>
|
||||
</div>
|
||||
</template>
|
||||
<div class="fed-device-actions">
|
||||
<button class="fed-action-btn primary"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.generateFedInvite()">Invite</button>
|
||||
<button class="fed-action-btn"
|
||||
:disabled="$store.devices.fedBusy"
|
||||
@click="$store.devices.syncFedDevices()">Sync</button>
|
||||
<template x-if="$store.devices.playbackFedDevices().length > 0">
|
||||
<div class="device-group-divider">
|
||||
<span>Trusted federation devices</span>
|
||||
</div>
|
||||
<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="fed-action-btn"
|
||||
:disabled="$store.devices.fedBusy || !$store.devices.fedInviteInput.trim()"
|
||||
@click="$store.devices.connectFedInvite()">Connect</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="device in $store.devices.playbackFedDevices()" :key="device.id">
|
||||
<button class="device-row federation-device-row"
|
||||
:class="{ active: device.is_active }"
|
||||
@click="$store.devices.select(device.id)">
|
||||
<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="device-row-main">
|
||||
<span class="device-row-name" x-text="device.name"></span>
|
||||
<span class="device-row-current"
|
||||
x-text="device.is_active ? 'Active federation device' : 'Available for playback transfer'"></span>
|
||||
</span>
|
||||
<span class="device-row-check" x-show="device.is_active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="$store.devices.jams.length > 0">
|
||||
<div class="device-section-label jam-section-label">Jams</div>
|
||||
</template>
|
||||
|
||||
@@ -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;
|
||||
@@ -670,6 +676,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 +1399,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 { 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 +2027,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 +2473,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 +2544,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 +2747,146 @@ button.user-stat:hover {
|
||||
|
||||
/* Search results */
|
||||
.search-section { margin-bottom: 24px; }
|
||||
.federation-search-section {
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 18px;
|
||||
}
|
||||
.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-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
margin: 0 0 20px;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.search-similarity-title strong {
|
||||
color: var(--text);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.search-artists-row {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@@ -2675,7 +2924,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 {
|
||||
@@ -3371,6 +3620,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 +3761,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;
|
||||
|
||||
Reference in New Issue
Block a user