Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1370c6a28 | ||
|
|
2fc5fd7960 | ||
|
|
63506e3af2 | ||
|
|
5b339aa921 | ||
|
|
42c772f735 |
Generated
+73
-117
@@ -23,8 +23,8 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array 0.14.7",
|
||||
"crypto-common 0.1.6",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -290,7 +290,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -544,7 +544,7 @@ version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -684,15 +684,15 @@ version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.3"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fb99565819980999fb7b4a1796046a5c949e6d4ff132cf5fadf5a641e20d776"
|
||||
checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
@@ -712,14 +712,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "clap_derive"
|
||||
version = "4.6.3"
|
||||
version = "4.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32f2392eae7f16557a3d727ef3a12e57b2b2ca6f98566a5f4fb41ffe305df077"
|
||||
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1108,7 +1108,7 @@ version = "0.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -1116,11 +1116,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
|
||||
checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"typenum",
|
||||
]
|
||||
|
||||
@@ -1131,6 +1131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1181,9 +1182,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "curve25519-dalek"
|
||||
version = "5.0.0-rc.0"
|
||||
version = "5.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f359e08ca85e7bd759e1fd933ff2bccd81864c60a8fba0e259c7f822b0924bf"
|
||||
checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -1417,7 +1418,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -1558,11 +1559,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ed25519-dalek"
|
||||
version = "3.0.0-rc.0"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b011170fe4f04665565b4110afef66774fe9ffff278f3eb5b81cc73d26e27d60"
|
||||
checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
|
||||
dependencies = [
|
||||
"curve25519-dalek 5.0.0-rc.0",
|
||||
"curve25519-dalek 5.0.0",
|
||||
"ed25519 3.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"serde",
|
||||
@@ -1591,7 +1592,7 @@ dependencies = [
|
||||
"crypto-bigint",
|
||||
"digest 0.10.7",
|
||||
"ff",
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"group",
|
||||
"hkdf",
|
||||
"pem-rfc7468 0.7.0",
|
||||
@@ -1717,7 +1718,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "federation-net"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#8ee1db9cf89ea604c6b8a8c3fe089a714c1e321f"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#512a818a6a52ec713678e9a4e1cf0f50bb1e34ab"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
@@ -1844,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.6.3-fd"
|
||||
version = "0.7.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -2033,9 +2034,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "generic-array"
|
||||
version = "0.14.7"
|
||||
version = "0.14.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||
checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
"version_check",
|
||||
@@ -2775,7 +2776,7 @@ checksum = "bee2c455ca60511a054699102d40ce7153621cb2429558f9eaa600e4499b5984"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2784,7 +2785,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2829,9 +2830,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iroh"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5fca9b4b462c343ff88fc0af4096c186f939b602a0bc08723536ef2c31c93971"
|
||||
checksum = "460de6bc52163b41b1646931f2897e5ab986f0966ade444467fec25024751a72"
|
||||
dependencies = [
|
||||
"backon",
|
||||
"blake3",
|
||||
@@ -2840,7 +2841,7 @@ dependencies = [
|
||||
"ctutils",
|
||||
"data-encoding",
|
||||
"derive_more",
|
||||
"ed25519-dalek 3.0.0-rc.0",
|
||||
"ed25519-dalek 3.0.0",
|
||||
"futures-util",
|
||||
"getrandom 0.4.3",
|
||||
"hickory-resolver",
|
||||
@@ -2880,15 +2881,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iroh-base"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "830a582cd54410dc1aa71d4786a82c3297d7b0165accd8b6dbbb3b240b48140d"
|
||||
checksum = "6be73e16ee21c923aca9b3121aaa0db936f7c7ecc156ff47b8dac944c68d59a8"
|
||||
dependencies = [
|
||||
"curve25519-dalek 5.0.0-rc.0",
|
||||
"curve25519-dalek 5.0.0",
|
||||
"data-encoding",
|
||||
"data-encoding-macro",
|
||||
"derive_more",
|
||||
"ed25519-dalek 3.0.0-rc.0",
|
||||
"ed25519-dalek 3.0.0",
|
||||
"getrandom 0.4.3",
|
||||
"n0-error",
|
||||
"rand 0.10.2",
|
||||
@@ -2899,9 +2900,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iroh-dns"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "516e4eedc38e33ab69a6bd325520332dc3d67b25454e2d590ebb84a25240dd9a"
|
||||
checksum = "46f6a9b39d18e6345f5c151afd299f2488e2cb5c520fe41b107b6bd3dc4c3349"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"cfg_aliases",
|
||||
@@ -2950,9 +2951,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "iroh-relay"
|
||||
version = "1.0.2"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8149bb6a57126225a07d6928846d82dcedfd24ea0f863ef7b2eb475e1d726354"
|
||||
checksum = "24bd586cf927f7b700f56ec3639b53cb5fa901ce284784051ff71092bfbf8193"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"bytes",
|
||||
@@ -2989,7 +2990,6 @@ dependencies = [
|
||||
"tokio-websockets",
|
||||
"tracing",
|
||||
"url",
|
||||
"vergen-gitcl",
|
||||
"webpki-roots 1.0.9",
|
||||
"ws_stream_wasm",
|
||||
]
|
||||
@@ -3154,9 +3154,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
version = "0.2.189"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
@@ -3485,7 +3485,7 @@ dependencies = [
|
||||
"crc",
|
||||
"document-features",
|
||||
"dyn-clone",
|
||||
"ed25519-dalek 3.0.0-rc.0",
|
||||
"ed25519-dalek 3.0.0",
|
||||
"flume 0.12.0",
|
||||
"futures-lite",
|
||||
"getrandom 0.4.3",
|
||||
@@ -3622,7 +3622,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "music-dht"
|
||||
version = "0.1.0"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#8ee1db9cf89ea604c6b8a8c3fe089a714c1e321f"
|
||||
source = "git+https://gt.hexor.cy/ab/frid.git#512a818a6a52ec713678e9a4e1cf0f50bb1e34ab"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
@@ -3847,9 +3847,9 @@ checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21"
|
||||
|
||||
[[package]]
|
||||
name = "noq"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bf95190af1bd4a00a10e8255ca0c8ddd9e9a9f5e79151d7a7eb6d56aff5dc89"
|
||||
checksum = "e11803df44ac03a30988d61585ea50885d5428e42da944fe1e498799da7886a2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
@@ -3869,9 +3869,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "noq-proto"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3"
|
||||
checksum = "334c3c9833f7b2c573cceb9896ddc7aaeb58c8807cbb63211b24d1fe88bf866e"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"bytes",
|
||||
@@ -3896,9 +3896,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "noq-udp"
|
||||
version = "1.0.1"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3137a52df66c20090a889828d1c655f21f52294cba64e5c4fbb04fc83eee7c8e"
|
||||
checksum = "bde7a5d5102f1cff03d482240f0ed20551661f63663620f4b26112ed751165e9"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
@@ -4034,15 +4034,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num_threads"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauth2"
|
||||
version = "5.0.0"
|
||||
@@ -4960,7 +4951,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5209,9 +5200,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.0"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
@@ -5364,7 +5355,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||
dependencies = [
|
||||
"base16ct 0.2.0",
|
||||
"der 0.7.10",
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"pkcs8 0.10.2",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
@@ -5472,7 +5463,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5672,6 +5663,9 @@ name = "signature"
|
||||
version = "3.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
@@ -5914,7 +5908,7 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"generic-array 0.14.7",
|
||||
"generic-array 0.14.9",
|
||||
"hex 0.4.3",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
@@ -6277,9 +6271,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.2"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -6389,7 +6383,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.2",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6409,9 +6403,7 @@ checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"num-conv",
|
||||
"num_threads",
|
||||
"powerfmt",
|
||||
"serde_core",
|
||||
"time-core",
|
||||
@@ -6461,9 +6453,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.0"
|
||||
version = "1.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -6536,9 +6528,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.18"
|
||||
version = "0.1.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
|
||||
checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
@@ -6548,14 +6540,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-util"
|
||||
version = "0.7.18"
|
||||
version = "0.7.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
|
||||
checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
]
|
||||
@@ -6868,7 +6861,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common 0.1.7",
|
||||
"crypto-common 0.1.6",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -6938,43 +6931,6 @@ version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vergen"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b849a1f6d8639e8de261e81ee0fc881e3e3620db1af9f2e0da015d4382ceaf75"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
"vergen-lib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vergen-gitcl"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ff3b5300a085d6bcd8fc96a507f706a28ae3814693236c9b409db71a1d15b9"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
"time",
|
||||
"vergen",
|
||||
"vergen-lib",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vergen-lib"
|
||||
version = "9.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b34a29ba7e9c59e62f229ae1932fb1b8fb8a6fdcc99215a641913f5f5a59a569"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"derive_builder",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
@@ -7647,18 +7603,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.54"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19"
|
||||
checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.54"
|
||||
version = "0.8.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5"
|
||||
checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.6.4-fd"
|
||||
version = "0.7.1"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
|
||||
+104
-21
@@ -15,6 +15,7 @@
|
||||
mod serve;
|
||||
mod storage;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
@@ -42,12 +43,19 @@ struct Running {
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
struct ContentHashJob {
|
||||
media_file_id: i64,
|
||||
sha256_hash: String,
|
||||
file_path: String,
|
||||
}
|
||||
|
||||
pub struct Federation {
|
||||
/// Transport data directory; server-side DHT state and identity live in PostgreSQL.
|
||||
data_dir: PathBuf,
|
||||
database_url: std::sync::Mutex<String>,
|
||||
storage_dir: std::sync::Mutex<String>,
|
||||
content_cache: std::sync::Mutex<std::collections::HashMap<i64, (String, String)>>,
|
||||
content_cache: std::sync::Mutex<HashMap<i64, (String, String)>>,
|
||||
content_pending: std::sync::Mutex<HashSet<i64>>,
|
||||
pool: tokio::sync::OnceCell<PgPool>,
|
||||
running: tokio::sync::Mutex<Option<Running>>,
|
||||
last_sync: std::sync::Mutex<Option<String>>,
|
||||
@@ -73,6 +81,7 @@ pub fn handle() -> Arc<Federation> {
|
||||
database_url: std::sync::Mutex::new(String::new()),
|
||||
storage_dir: std::sync::Mutex::new(String::new()),
|
||||
content_cache: std::sync::Mutex::new(Default::default()),
|
||||
content_pending: std::sync::Mutex::new(Default::default()),
|
||||
pool: tokio::sync::OnceCell::new(),
|
||||
running: tokio::sync::Mutex::new(None),
|
||||
last_sync: std::sync::Mutex::new(None),
|
||||
@@ -286,7 +295,7 @@ impl Federation {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn sync_once(&self, service: &MusicDhtService) -> Result<SyncStats> {
|
||||
async fn sync_once(self: &Arc<Self>, service: &MusicDhtService) -> Result<SyncStats> {
|
||||
let specs = match self.collect_specs().await {
|
||||
Ok(specs) => specs,
|
||||
Err(err) => {
|
||||
@@ -346,7 +355,7 @@ impl Federation {
|
||||
|
||||
/// Everything the regular player shows, as DHT item specs: non-hidden
|
||||
/// artists, releases and tracks (a track also hides with its release).
|
||||
async fn collect_specs(&self) -> Result<Vec<ItemSpec>> {
|
||||
async fn collect_specs(self: &Arc<Self>) -> Result<Vec<ItemSpec>> {
|
||||
let pool = self.pool().await?;
|
||||
let mut specs = Vec::new();
|
||||
|
||||
@@ -434,24 +443,37 @@ impl Federation {
|
||||
let tracks = sqlx::query(
|
||||
"SELECT t.id, t.title, COALESCE(t.year, r.year), t.duration_seconds,
|
||||
r.title, r.release_type, t.track_number, t.disc_number,
|
||||
t.audio_file_id, m.file_path, m.sha256_hash
|
||||
t.audio_file_id, m.file_path, m.sha256_hash, c.content_id
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
LEFT JOIN furumusic__federation_content_id_cache c
|
||||
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||
WHERE t.is_hidden = false AND r.is_hidden = false",
|
||||
)
|
||||
.fetch_all(&pool)
|
||||
.await?;
|
||||
let storage_dir = lock(&self.storage_dir).clone();
|
||||
let mut content_hash_jobs = Vec::new();
|
||||
for row in &tracks {
|
||||
let id: i64 = row.get(0);
|
||||
let duration: f64 = row.get(3);
|
||||
let media_file_id: i64 = row.get(8);
|
||||
let file_path: String = row.get(9);
|
||||
let sha256_hash: String = row.get(10);
|
||||
let content_id = self
|
||||
.content_id_for_media(media_file_id, sha256_hash, file_path, &storage_dir)
|
||||
.await;
|
||||
let cached_content_id: Option<String> = row.get(11);
|
||||
let content_id = cached_content_id
|
||||
.or_else(|| self.cached_content_id_for_media(media_file_id, &sha256_hash));
|
||||
if content_id.is_none()
|
||||
&& !storage_dir.trim().is_empty()
|
||||
&& self.mark_content_hash_pending(media_file_id)
|
||||
{
|
||||
content_hash_jobs.push(ContentHashJob {
|
||||
media_file_id,
|
||||
sha256_hash,
|
||||
file_path,
|
||||
});
|
||||
}
|
||||
specs.push(ItemSpec {
|
||||
local_key: format!("track:{id}"),
|
||||
kind: ItemKind::Track,
|
||||
@@ -467,30 +489,67 @@ impl Federation {
|
||||
content_id,
|
||||
});
|
||||
}
|
||||
self.spawn_content_warmer(pool.clone(), storage_dir, content_hash_jobs);
|
||||
|
||||
Ok(specs)
|
||||
}
|
||||
|
||||
async fn content_id_for_media(
|
||||
&self,
|
||||
media_file_id: i64,
|
||||
sha256_hash: String,
|
||||
file_path: String,
|
||||
storage_dir: &str,
|
||||
) -> Option<String> {
|
||||
fn cached_content_id_for_media(&self, media_file_id: i64, sha256_hash: &str) -> Option<String> {
|
||||
if let Some((cached_hash, content_id)) = lock(&self.content_cache).get(&media_file_id)
|
||||
&& cached_hash == &sha256_hash
|
||||
&& cached_hash == sha256_hash
|
||||
{
|
||||
return Some(content_id.clone());
|
||||
}
|
||||
let storage_dir = storage_dir.to_string();
|
||||
let content_id =
|
||||
tokio::task::spawn_blocking(move || audio_content_id(&storage_dir, &file_path))
|
||||
None
|
||||
}
|
||||
|
||||
fn mark_content_hash_pending(&self, media_file_id: i64) -> bool {
|
||||
lock(&self.content_pending).insert(media_file_id)
|
||||
}
|
||||
|
||||
fn spawn_content_warmer(
|
||||
self: &Arc<Self>,
|
||||
pool: PgPool,
|
||||
storage_dir: String,
|
||||
jobs: Vec<ContentHashJob>,
|
||||
) {
|
||||
if jobs.is_empty() {
|
||||
return;
|
||||
}
|
||||
let fed = Arc::clone(self);
|
||||
tokio::spawn(async move {
|
||||
let total = jobs.len();
|
||||
let mut stored = 0usize;
|
||||
for job in jobs {
|
||||
let job_storage_dir = storage_dir.clone();
|
||||
let job_file_path = job.file_path.clone();
|
||||
let content_id = tokio::task::spawn_blocking(move || {
|
||||
audio_content_id(&job_storage_dir, &job_file_path)
|
||||
})
|
||||
.await
|
||||
.ok()
|
||||
.flatten()?;
|
||||
lock(&self.content_cache).insert(media_file_id, (sha256_hash, content_id.clone()));
|
||||
Some(content_id)
|
||||
.flatten();
|
||||
lock(&fed.content_pending).remove(&job.media_file_id);
|
||||
if let Some(content_id) = content_id {
|
||||
lock(&fed.content_cache).insert(
|
||||
job.media_file_id,
|
||||
(job.sha256_hash.clone(), content_id.clone()),
|
||||
);
|
||||
if let Err(err) =
|
||||
persist_content_id(&pool, job.media_file_id, &job.sha256_hash, &content_id)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
media_file_id = job.media_file_id,
|
||||
"federation content-id cache write failed: {err:#}"
|
||||
);
|
||||
} else {
|
||||
stored += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
tracing::info!(total, stored, "federation content-id cache warm finished");
|
||||
});
|
||||
}
|
||||
|
||||
/// Live status for the admin page.
|
||||
@@ -550,6 +609,30 @@ impl Federation {
|
||||
}
|
||||
}
|
||||
|
||||
async fn persist_content_id(
|
||||
pool: &PgPool,
|
||||
media_file_id: i64,
|
||||
sha256_hash: &str,
|
||||
content_id: &str,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_content_id_cache
|
||||
(media_file_id, sha256_hash, content_id, updated_at)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
ON CONFLICT (media_file_id) DO UPDATE SET
|
||||
sha256_hash = EXCLUDED.sha256_hash,
|
||||
content_id = EXCLUDED.content_id,
|
||||
updated_at = EXCLUDED.updated_at",
|
||||
)
|
||||
.bind(media_file_id)
|
||||
.bind(sha256_hash)
|
||||
.bind(content_id)
|
||||
.bind(now_iso())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn audio_content_id(storage_dir: &str, file_path: &str) -> Option<String> {
|
||||
if storage_dir.trim().is_empty() {
|
||||
return None;
|
||||
|
||||
+6
-19
@@ -522,7 +522,7 @@ async fn serve_catalog_one(
|
||||
|
||||
match request.want.as_deref() {
|
||||
None | Some("catalog") => {
|
||||
let response = match build_catalog(&pool, &own, &storage_dir, &request.artist).await {
|
||||
let response = match build_catalog(&pool, &own, &request.artist).await {
|
||||
Ok(response) => response,
|
||||
Err(err) => CatalogResponse {
|
||||
ok: false,
|
||||
@@ -582,12 +582,7 @@ async fn serve_catalog_one(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn build_catalog(
|
||||
pool: &PgPool,
|
||||
own: &EndpointId,
|
||||
storage_dir: &str,
|
||||
artist: &str,
|
||||
) -> Result<CatalogResponse> {
|
||||
async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<CatalogResponse> {
|
||||
let Some(artist_row) = sqlx::query(
|
||||
"SELECT id, name FROM furumusic__artist
|
||||
WHERE LOWER(name) = LOWER($1) AND is_hidden = false
|
||||
@@ -620,9 +615,11 @@ async fn build_catalog(
|
||||
let release_id: i64 = release_row.get(0);
|
||||
let track_rows = sqlx::query(
|
||||
"SELECT t.id, t.title, t.track_number, t.disc_number, t.duration_seconds,
|
||||
m.file_path
|
||||
c.content_id
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__media_file m ON m.id = t.audio_file_id
|
||||
LEFT JOIN furumusic__federation_content_id_cache c
|
||||
ON c.media_file_id = m.id AND c.sha256_hash = m.sha256_hash
|
||||
WHERE t.release_id = $1 AND t.is_hidden = false
|
||||
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title",
|
||||
)
|
||||
@@ -633,14 +630,12 @@ async fn build_catalog(
|
||||
for row in track_rows {
|
||||
let track_id: i64 = row.get(0);
|
||||
let duration: f64 = row.get(4);
|
||||
let file_path: String = row.get(5);
|
||||
let content_id = catalog_content_id(storage_dir, file_path).await;
|
||||
tracks.push(CatalogTrack {
|
||||
title: row.get(1),
|
||||
track_number: row.get(2),
|
||||
disc_number: row.get(3),
|
||||
duration_seconds: (duration > 0.0).then_some(duration),
|
||||
content_id,
|
||||
content_id: row.get(5),
|
||||
item_id: item_id_of(own, track_id),
|
||||
});
|
||||
}
|
||||
@@ -662,14 +657,6 @@ async fn build_catalog(
|
||||
})
|
||||
}
|
||||
|
||||
async fn catalog_content_id(storage_dir: &str, file_path: String) -> Option<String> {
|
||||
let storage_dir = storage_dir.to_string();
|
||||
tokio::task::spawn_blocking(move || super::audio_content_id(&storage_dir, &file_path))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
async fn artist_image_by_name(pool: &PgPool, artist: &str) -> Result<Option<(String, String)>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT m.file_path, m.mime_type FROM furumusic__artist a
|
||||
|
||||
+77
-44
@@ -44,6 +44,14 @@ const SCHEMA: &[&str] = &[
|
||||
ticket TEXT NOT NULL,
|
||||
last_seen_ms BIGINT NOT NULL
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__federation_content_id_cache (
|
||||
media_file_id BIGINT PRIMARY KEY,
|
||||
sha256_hash TEXT NOT NULL,
|
||||
content_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
|
||||
ON furumusic__federation_content_id_cache(content_id)",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -157,52 +165,21 @@ impl MusicDhtStorage for PostgresFederationStorage {
|
||||
}
|
||||
|
||||
async fn store_dht_record(&self, key: DhtKey, record: StoredRecord) -> music_dht::Result<bool> {
|
||||
let existing = sqlx::query(
|
||||
"SELECT revision, deleted, expires_at_ms
|
||||
FROM furumusic__federation_dht_record
|
||||
WHERE dht_key = $1 AND item_id = $2 AND owner_peer_id = $3",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(record.item.id.as_bytes().as_slice())
|
||||
.bind(record.item.owner.to_string())
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.map(|row| {
|
||||
(
|
||||
row.get::<i64, _>(0) as u64,
|
||||
row.get::<bool, _>(1),
|
||||
row.get::<i64, _>(2) as u64,
|
||||
)
|
||||
});
|
||||
let mut conn = self.pool.acquire().await.map_err(db_error)?;
|
||||
store_record_in_conn(&mut conn, &key, &record).await
|
||||
}
|
||||
|
||||
match decide_store(existing, &record) {
|
||||
StoreDecision::Ignore => return Ok(false),
|
||||
StoreDecision::Write | StoreDecision::RefreshExpiry(_) => {}
|
||||
async fn store_dht_records(
|
||||
&self,
|
||||
entries: Vec<(DhtKey, StoredRecord)>,
|
||||
) -> music_dht::Result<Vec<bool>> {
|
||||
let mut tx = self.pool.begin().await.map_err(db_error)?;
|
||||
let mut stored = Vec::with_capacity(entries.len());
|
||||
for (key, record) in &entries {
|
||||
stored.push(store_record_in_conn(&mut tx, key, record).await?);
|
||||
}
|
||||
|
||||
let payload = postcard::to_stdvec(&record).map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_dht_record
|
||||
(dht_key, item_id, owner_peer_id, payload, revision, deleted, expires_at_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (dht_key, item_id, owner_peer_id) DO UPDATE SET
|
||||
payload = EXCLUDED.payload,
|
||||
revision = EXCLUDED.revision,
|
||||
deleted = EXCLUDED.deleted,
|
||||
expires_at_ms = EXCLUDED.expires_at_ms",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(record.item.id.as_bytes().as_slice())
|
||||
.bind(record.item.owner.to_string())
|
||||
.bind(payload)
|
||||
.bind(record.item.revision as i64)
|
||||
.bind(record.item.deleted)
|
||||
.bind(record.expires_at_ms as i64)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(true)
|
||||
tx.commit().await.map_err(db_error)?;
|
||||
Ok(stored)
|
||||
}
|
||||
|
||||
async fn dht_records_by_key(
|
||||
@@ -311,6 +288,62 @@ fn secret_from_bytes(bytes: Vec<u8>) -> music_dht::Result<SecretKey> {
|
||||
Ok(SecretKey::from_bytes(&bytes))
|
||||
}
|
||||
|
||||
/// Applies one validated record following the revision/tombstone rules.
|
||||
/// Returns `true` if the record was written or refreshed. Runs against a
|
||||
/// pooled connection or an open transaction.
|
||||
async fn store_record_in_conn(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
key: &DhtKey,
|
||||
record: &StoredRecord,
|
||||
) -> music_dht::Result<bool> {
|
||||
let existing = sqlx::query(
|
||||
"SELECT revision, deleted, expires_at_ms
|
||||
FROM furumusic__federation_dht_record
|
||||
WHERE dht_key = $1 AND item_id = $2 AND owner_peer_id = $3",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(record.item.id.as_bytes().as_slice())
|
||||
.bind(record.item.owner.to_string())
|
||||
.fetch_optional(&mut *conn)
|
||||
.await
|
||||
.map_err(db_error)?
|
||||
.map(|row| {
|
||||
(
|
||||
row.get::<i64, _>(0) as u64,
|
||||
row.get::<bool, _>(1),
|
||||
row.get::<i64, _>(2) as u64,
|
||||
)
|
||||
});
|
||||
|
||||
match decide_store(existing, record) {
|
||||
StoreDecision::Ignore => return Ok(false),
|
||||
StoreDecision::Write | StoreDecision::RefreshExpiry(_) => {}
|
||||
}
|
||||
|
||||
let payload = postcard::to_stdvec(record).map_err(db_error)?;
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__federation_dht_record
|
||||
(dht_key, item_id, owner_peer_id, payload, revision, deleted, expires_at_ms)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (dht_key, item_id, owner_peer_id) DO UPDATE SET
|
||||
payload = EXCLUDED.payload,
|
||||
revision = EXCLUDED.revision,
|
||||
deleted = EXCLUDED.deleted,
|
||||
expires_at_ms = EXCLUDED.expires_at_ms",
|
||||
)
|
||||
.bind(key.as_bytes().as_slice())
|
||||
.bind(record.item.id.as_bytes().as_slice())
|
||||
.bind(record.item.owner.to_string())
|
||||
.bind(payload)
|
||||
.bind(record.item.revision as i64)
|
||||
.bind(record.item.deleted)
|
||||
.bind(record.expires_at_ms as i64)
|
||||
.execute(&mut *conn)
|
||||
.await
|
||||
.map_err(db_error)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn db_error(err: impl std::fmt::Display) -> MusicDhtError {
|
||||
MusicDhtError::Database(err.to_string())
|
||||
}
|
||||
|
||||
@@ -309,6 +309,7 @@ translations! {
|
||||
player_cancel: "Cancel" , "Отмена";
|
||||
player_create: "Create" , "Создать";
|
||||
player_save: "Save" , "Сохранить";
|
||||
player_done: "Done" , "Готово";
|
||||
player_delete: "Delete" , "Удалить";
|
||||
player_delete_playlist_confirm: "Delete this playlist?" , "Удалить этот плейлист?";
|
||||
player_rename: "Rename" , "Переименовать";
|
||||
|
||||
+8
-1
@@ -74,6 +74,13 @@ pub(super) struct TrackItem {
|
||||
pub(super) lastfm_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlaylistTrackItem {
|
||||
pub(super) playlist_track_id: Option<i64>,
|
||||
#[serde(flatten)]
|
||||
pub(super) track: TrackItem,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistAppearanceTrack {
|
||||
pub(super) id: i64,
|
||||
@@ -286,7 +293,7 @@ pub(super) struct PlaylistDetail {
|
||||
pub(super) is_public: bool,
|
||||
pub(super) is_saved: bool,
|
||||
pub(super) kind: String,
|
||||
pub(super) tracks: Vec<TrackItem>,
|
||||
pub(super) tracks: Vec<PlaylistTrackItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
|
||||
+200
-12
@@ -3414,6 +3414,7 @@ async fn artist_detail_handler(
|
||||
let top_tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||
r#"SELECT * FROM (
|
||||
SELECT DISTINCT ON (lower(t.title::text))
|
||||
NULL::bigint AS playlist_track_id,
|
||||
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
t.duration_seconds, t.cover_file_id,
|
||||
r.cover_file_id as release_cover_file_id,
|
||||
@@ -3515,7 +3516,8 @@ async fn release_detail_handler(
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
t.duration_seconds, t.cover_file_id,
|
||||
r.cover_file_id as release_cover_file_id,
|
||||
r.id as release_id,
|
||||
@@ -3687,7 +3689,8 @@ async fn playlist_detail_handler(
|
||||
};
|
||||
|
||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
r#"SELECT pt.id AS playlist_track_id,
|
||||
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
t.duration_seconds, t.cover_file_id,
|
||||
r.cover_file_id as release_cover_file_id,
|
||||
r.id as release_id,
|
||||
@@ -3715,7 +3718,7 @@ async fn playlist_detail_handler(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let track_items = build_track_items(tracks, pool).await?;
|
||||
let track_items = build_playlist_track_items(tracks, pool).await?;
|
||||
|
||||
Json(PlaylistDetail {
|
||||
id: info.id,
|
||||
@@ -3813,13 +3816,34 @@ async fn build_track_items(
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn build_playlist_track_items(
|
||||
tracks: Vec<PlaylistTrackRow>,
|
||||
pool: &sqlx::PgPool,
|
||||
) -> cot::Result<Vec<PlaylistTrackItem>> {
|
||||
let playlist_track_ids = tracks
|
||||
.iter()
|
||||
.map(|track| track.playlist_track_id)
|
||||
.collect::<Vec<_>>();
|
||||
let track_items = build_track_items(tracks, pool).await?;
|
||||
|
||||
Ok(track_items
|
||||
.into_iter()
|
||||
.zip(playlist_track_ids)
|
||||
.map(|(track, playlist_track_id)| PlaylistTrackItem {
|
||||
playlist_track_id,
|
||||
track,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Result<Vec<TrackItem>> {
|
||||
if ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
t.duration_seconds, t.cover_file_id,
|
||||
r.cover_file_id as release_cover_file_id,
|
||||
r.id as release_id,
|
||||
@@ -3976,7 +4000,8 @@ async fn likes_playlist_handler(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let tracks = sqlx::query_as::<_, PlaylistTrackRow>(
|
||||
r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
r#"SELECT NULL::bigint AS playlist_track_id,
|
||||
t.id, t.title::text as title, t.track_number, t.disc_number,
|
||||
t.duration_seconds, t.cover_file_id,
|
||||
r.cover_file_id as release_cover_file_id,
|
||||
r.id as release_id,
|
||||
@@ -4004,7 +4029,14 @@ async fn likes_playlist_handler(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let track_items = build_track_items(tracks, pool).await?;
|
||||
let track_items = build_track_items(tracks, pool)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|track| PlaylistTrackItem {
|
||||
playlist_track_id: None,
|
||||
track,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Json(PlaylistDetail {
|
||||
id: -1,
|
||||
@@ -5609,6 +5641,110 @@ async fn add_tracks_to_playlist_handler(
|
||||
Json(serde_json::json!({"ok": true})).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PUT /api/player/playlists/{id}/tracks — reorder playlist tracks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn reorder_playlist_tracks_handler(
|
||||
auth_ctx: auth::AuthContext,
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &sqlx::PgPool,
|
||||
path: Path<PathId>,
|
||||
Json(body): Json<ReorderPlaylistRequest>,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
|
||||
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
|
||||
};
|
||||
let playlist_id = path.0.id;
|
||||
let owner: Option<(i64,)> =
|
||||
sqlx::query_as("SELECT owner_id FROM furumusic__playlist WHERE id = $1")
|
||||
.bind(playlist_id)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let Some(owner) = owner else {
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "playlist not found"));
|
||||
};
|
||||
if owner.0 != user.id {
|
||||
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||
}
|
||||
|
||||
let requested_ids = body
|
||||
.playlist_track_ids
|
||||
.into_iter()
|
||||
.filter(|id| *id > 0)
|
||||
.collect::<Vec<_>>();
|
||||
let unique_requested = requested_ids.iter().copied().collect::<HashSet<_>>();
|
||||
if unique_requested.len() != requested_ids.len() {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"duplicate playlist track ids",
|
||||
));
|
||||
}
|
||||
|
||||
let visible_ids = sqlx::query_scalar::<_, i64>(
|
||||
r#"SELECT pt.id
|
||||
FROM furumusic__playlist_track pt
|
||||
JOIN furumusic__track t ON t.id = pt.track_id
|
||||
WHERE pt.playlist_id = $1 AND t.is_hidden = false
|
||||
ORDER BY pt.position"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let visible_set = visible_ids.iter().copied().collect::<HashSet<_>>();
|
||||
if visible_set != unique_requested {
|
||||
return Ok(json_error(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"playlist track ids do not match this playlist",
|
||||
));
|
||||
}
|
||||
|
||||
let hidden_ids = sqlx::query_scalar::<_, i64>(
|
||||
r#"SELECT pt.id
|
||||
FROM furumusic__playlist_track pt
|
||||
JOIN furumusic__track t ON t.id = pt.track_id
|
||||
WHERE pt.playlist_id = $1 AND t.is_hidden = true
|
||||
ORDER BY pt.position"#,
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.fetch_all(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let mut ordered_ids = requested_ids;
|
||||
ordered_ids.extend(hidden_ids);
|
||||
if !ordered_ids.is_empty() {
|
||||
sqlx::query(
|
||||
r#"WITH ordered AS (
|
||||
SELECT id, ord::integer - 1 AS position
|
||||
FROM unnest($1::bigint[]) WITH ORDINALITY AS u(id, ord)
|
||||
)
|
||||
UPDATE furumusic__playlist_track pt
|
||||
SET position = ordered.position
|
||||
FROM ordered
|
||||
WHERE pt.id = ordered.id AND pt.playlist_id = $2"#,
|
||||
)
|
||||
.bind(&ordered_ids)
|
||||
.bind(playlist_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
}
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||
.bind(&now)
|
||||
.bind(playlist_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
Json(serde_json::json!({"ok": true})).into_response()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /api/player/playlists/{id}/tracks — remove a track from playlist
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -5638,12 +5774,29 @@ async fn remove_track_from_playlist_handler(
|
||||
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
|
||||
}
|
||||
|
||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2")
|
||||
.bind(playlist_id)
|
||||
.bind(body.track_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
match (body.playlist_track_id, body.track_id) {
|
||||
(Some(playlist_track_id), _) => {
|
||||
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND id = $2")
|
||||
.bind(playlist_id)
|
||||
.bind(playlist_track_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
}
|
||||
(None, Some(track_id)) => {
|
||||
sqlx::query(
|
||||
"DELETE FROM furumusic__playlist_track WHERE playlist_id = $1 AND track_id = $2",
|
||||
)
|
||||
.bind(playlist_id)
|
||||
.bind(track_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
}
|
||||
(None, None) => {
|
||||
return Ok(json_error(StatusCode::BAD_REQUEST, "missing track id"));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-number positions
|
||||
sqlx::query(
|
||||
@@ -5660,6 +5813,14 @@ async fn remove_track_from_playlist_handler(
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
|
||||
sqlx::query("UPDATE furumusic__playlist SET updated_at = $1 WHERE id = $2")
|
||||
.bind(&now)
|
||||
.bind(playlist_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
|
||||
Json(serde_json::json!({"ok": true})).into_response()
|
||||
}
|
||||
|
||||
@@ -7618,6 +7779,33 @@ impl App for PlayerApp {
|
||||
}
|
||||
}
|
||||
})
|
||||
.put({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |auth_ctx: auth::AuthContext,
|
||||
session: Session,
|
||||
db: Database,
|
||||
path: Path<PathId>,
|
||||
json: Json<ReorderPlaylistRequest>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("player pool")
|
||||
})
|
||||
.await;
|
||||
reorder_playlist_tracks_handler(
|
||||
auth_ctx, session, db, pg_pool, path, json,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
})
|
||||
.delete({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
|
||||
@@ -42,7 +42,13 @@ pub(super) struct AddTracksRequest {
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct RemoveTrackRequest {
|
||||
pub(super) track_id: i64,
|
||||
pub(super) track_id: Option<i64>,
|
||||
pub(super) playlist_track_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct ReorderPlaylistRequest {
|
||||
pub(super) playlist_track_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
||||
@@ -95,6 +95,7 @@ pub(super) struct PlaylistInfoRow {
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlaylistTrackRow {
|
||||
pub(super) playlist_track_id: Option<i64>,
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
|
||||
@@ -2745,6 +2745,7 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
async openPlaylist(id, options = {}) {
|
||||
this._beginNavigation('#playlist/' + id, options);
|
||||
Alpine.store('playlists')?.stopEdit?.();
|
||||
this.view = 'playlist_detail';
|
||||
this.currentPlaylist = null;
|
||||
try {
|
||||
@@ -2756,6 +2757,7 @@ document.addEventListener('alpine:init', () => {
|
||||
},
|
||||
|
||||
showSharedPlaylist(share, options = {}) {
|
||||
Alpine.store('playlists')?.stopEdit?.();
|
||||
this._saveScrollPosition(this._activeHash);
|
||||
this.searchQuery = '';
|
||||
this.searchResults = null;
|
||||
@@ -4301,6 +4303,11 @@ document.addEventListener('alpine:init', () => {
|
||||
list: [],
|
||||
modal: null, // { mode: 'create'|'rename', title: '', id?: number }
|
||||
picker: null, // { trackIds: [1,2,3] }
|
||||
editingPlaylistId: null,
|
||||
_dragIdx: null,
|
||||
_dragOverIdx: null,
|
||||
_pointerDragMove: null,
|
||||
_pointerDragEnd: null,
|
||||
|
||||
init() {
|
||||
this.reload();
|
||||
@@ -4333,6 +4340,214 @@ document.addEventListener('alpine:init', () => {
|
||||
return pl?.kind === 'likes' ? T.likesPlaylist : (pl?.title || '');
|
||||
},
|
||||
|
||||
currentPlaylist() {
|
||||
return Alpine.store('library')?.currentPlaylist || null;
|
||||
},
|
||||
|
||||
canEditCurrent() {
|
||||
const playlist = this.currentPlaylist();
|
||||
return !!playlist && playlist.kind === 'user' && playlist.is_own && Number(playlist.id) > 0;
|
||||
},
|
||||
|
||||
isEditingCurrent() {
|
||||
const playlist = this.currentPlaylist();
|
||||
return this.canEditCurrent() && Number(this.editingPlaylistId) === Number(playlist.id);
|
||||
},
|
||||
|
||||
toggleEditCurrent() {
|
||||
if (this.isEditingCurrent()) {
|
||||
this.stopEdit();
|
||||
return;
|
||||
}
|
||||
if (!this.canEditCurrent()) return;
|
||||
this.editingPlaylistId = Number(this.currentPlaylist().id);
|
||||
},
|
||||
|
||||
stopEdit() {
|
||||
this._endPointerReorder(false);
|
||||
this.endDrag();
|
||||
this.editingPlaylistId = null;
|
||||
},
|
||||
|
||||
_updateListTrackCount(playlistId, count) {
|
||||
const id = Number(playlistId);
|
||||
this.list = this.list.map(pl => (
|
||||
Number(pl.id) === id ? { ...pl, track_count: count } : pl
|
||||
));
|
||||
},
|
||||
|
||||
_playlistTrackIds(tracks) {
|
||||
return (tracks || []).map(track => Number(track?.playlist_track_id || 0)).filter(Boolean);
|
||||
},
|
||||
|
||||
async removeCurrentTrack(track, idx) {
|
||||
if (!this.isEditingCurrent() || !track) return;
|
||||
const playlist = this.currentPlaylist();
|
||||
const playlistId = Number(playlist.id);
|
||||
const playlistTrackId = Number(track.playlist_track_id || 0);
|
||||
const previous = (playlist.tracks || []).slice();
|
||||
if (!playlistTrackId) return;
|
||||
|
||||
playlist.tracks = previous.filter(item => Number(item.playlist_track_id || 0) !== playlistTrackId);
|
||||
this._updateListTrackCount(playlistId, playlist.tracks.length);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playlist_track_id: playlistTrackId }),
|
||||
});
|
||||
if (!res.ok) throw new Error('remove failed');
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
if (Number(this.currentPlaylist()?.id) === playlistId) {
|
||||
this.currentPlaylist().tracks = previous;
|
||||
this._updateListTrackCount(playlistId, previous.length);
|
||||
}
|
||||
console.warn(err);
|
||||
}
|
||||
},
|
||||
|
||||
startDrag(event, idx) {
|
||||
const tracks = this.currentPlaylist()?.tracks || [];
|
||||
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) {
|
||||
return false;
|
||||
}
|
||||
this._dragIdx = idx;
|
||||
this._dragOverIdx = idx;
|
||||
if (event?.dataTransfer) {
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
event.dataTransfer.setData('text/plain', String(tracks[idx].playlist_track_id));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
dragOver(event, idx) {
|
||||
if (!this.isEditingCurrent() || this._dragIdx === null) return;
|
||||
const tracks = this.currentPlaylist()?.tracks || [];
|
||||
if (idx < 0 || idx >= tracks.length) return;
|
||||
this._dragOverIdx = idx;
|
||||
if (event?.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
||||
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
if (idx !== this._dragIdx) event.currentTarget.classList.add('drag-over');
|
||||
},
|
||||
|
||||
dropOn(idx) {
|
||||
const fromIdx = this._dragIdx;
|
||||
this.endDrag();
|
||||
if (!Number.isInteger(fromIdx) || fromIdx === idx) return;
|
||||
this.moveCurrentTrack(fromIdx, idx);
|
||||
},
|
||||
|
||||
endDrag() {
|
||||
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
this._dragIdx = null;
|
||||
this._dragOverIdx = null;
|
||||
},
|
||||
|
||||
async moveCurrentTrack(fromIdx, toIdx) {
|
||||
if (!this.isEditingCurrent()) return;
|
||||
const playlist = this.currentPlaylist();
|
||||
const playlistId = Number(playlist.id);
|
||||
const previous = (playlist.tracks || []).slice();
|
||||
if (fromIdx < 0 || fromIdx >= previous.length || toIdx < 0 || toIdx >= previous.length) return;
|
||||
const next = previous.slice();
|
||||
const [track] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, track);
|
||||
playlist.tracks = next;
|
||||
|
||||
const playlistTrackIds = this._playlistTrackIds(next);
|
||||
if (playlistTrackIds.length !== next.length) {
|
||||
playlist.tracks = previous;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/player/playlists/${playlistId}/tracks`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ playlist_track_ids: playlistTrackIds }),
|
||||
});
|
||||
if (!res.ok) throw new Error('reorder failed');
|
||||
await this.reload();
|
||||
} catch (err) {
|
||||
if (Number(this.currentPlaylist()?.id) === playlistId) {
|
||||
this.currentPlaylist().tracks = previous;
|
||||
}
|
||||
console.warn(err);
|
||||
}
|
||||
},
|
||||
|
||||
startPointerReorder(event, idx) {
|
||||
if (event.pointerType === 'mouse') return;
|
||||
if (event.button && event.button !== 0) return;
|
||||
const tracks = this.currentPlaylist()?.tracks || [];
|
||||
if (!this.isEditingCurrent() || idx < 0 || idx >= tracks.length || !tracks[idx]?.playlist_track_id) return;
|
||||
event.preventDefault();
|
||||
this._endPointerReorder(false);
|
||||
this._dragIdx = idx;
|
||||
this._dragOverIdx = idx;
|
||||
const handle = event.currentTarget;
|
||||
try {
|
||||
handle?.setPointerCapture?.(event.pointerId);
|
||||
} catch (_) {}
|
||||
|
||||
this._pointerDragMove = (moveEvent) => {
|
||||
moveEvent.preventDefault();
|
||||
this._autoScrollDuringReorder(moveEvent.clientY);
|
||||
const target = document
|
||||
.elementFromPoint(moveEvent.clientX, moveEvent.clientY)
|
||||
?.closest?.('.playlist-track-row[data-playlist-index]');
|
||||
const targetIdx = Number(target?.dataset?.playlistIndex);
|
||||
const currentTracks = this.currentPlaylist()?.tracks || [];
|
||||
if (!Number.isInteger(targetIdx) || targetIdx < 0 || targetIdx >= currentTracks.length) return;
|
||||
this._dragOverIdx = targetIdx;
|
||||
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
if (targetIdx !== this._dragIdx) target.classList.add('drag-over');
|
||||
};
|
||||
|
||||
this._pointerDragEnd = (endEvent) => {
|
||||
try {
|
||||
if (handle?.hasPointerCapture?.(endEvent.pointerId)) handle.releasePointerCapture(endEvent.pointerId);
|
||||
} catch (_) {}
|
||||
this._endPointerReorder(true);
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', this._pointerDragMove, { passive: false });
|
||||
window.addEventListener('pointerup', this._pointerDragEnd, { passive: false });
|
||||
window.addEventListener('pointercancel', this._pointerDragEnd, { passive: false });
|
||||
},
|
||||
|
||||
_autoScrollDuringReorder(clientY) {
|
||||
const scroller = document.getElementById('center-scroll');
|
||||
if (!scroller) return;
|
||||
const rect = scroller.getBoundingClientRect();
|
||||
const edge = 52;
|
||||
if (clientY < rect.top + edge) {
|
||||
scroller.scrollTop -= Math.ceil((rect.top + edge - clientY) / 4);
|
||||
} else if (clientY > rect.bottom - edge) {
|
||||
scroller.scrollTop += Math.ceil((clientY - (rect.bottom - edge)) / 4);
|
||||
}
|
||||
},
|
||||
|
||||
_endPointerReorder(commit) {
|
||||
if (this._pointerDragMove) window.removeEventListener('pointermove', this._pointerDragMove);
|
||||
if (this._pointerDragEnd) {
|
||||
window.removeEventListener('pointerup', this._pointerDragEnd);
|
||||
window.removeEventListener('pointercancel', this._pointerDragEnd);
|
||||
}
|
||||
document.querySelectorAll('.playlist-track-row.drag-over').forEach(el => el.classList.remove('drag-over'));
|
||||
const fromIdx = this._dragIdx;
|
||||
const toIdx = this._dragOverIdx;
|
||||
this._pointerDragMove = null;
|
||||
this._pointerDragEnd = null;
|
||||
this._dragIdx = null;
|
||||
this._dragOverIdx = null;
|
||||
if (commit && Number.isInteger(fromIdx) && Number.isInteger(toIdx) && fromIdx !== toIdx) {
|
||||
this.moveCurrentTrack(fromIdx, toIdx);
|
||||
}
|
||||
},
|
||||
|
||||
showCreate() {
|
||||
this.modal = { mode: 'create', title: '' };
|
||||
},
|
||||
|
||||
+65
-15
@@ -951,7 +951,23 @@
|
||||
<span>/</span>
|
||||
<span x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></span>
|
||||
</div>
|
||||
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
||||
<div class="playlist-detail-heading">
|
||||
<h1 class="section-title" x-text="$store.playlists.displayTitle($store.library.currentPlaylist)"></h1>
|
||||
<button class="release-action-btn secondary playlist-edit-toggle"
|
||||
x-show="$store.playlists.canEditCurrent()"
|
||||
x-cloak
|
||||
@click="$store.playlists.toggleEditCurrent()"
|
||||
:title="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'">
|
||||
<svg x-show="!$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/>
|
||||
<path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
<svg x-show="$store.playlists.isEditingCurrent()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M20 6L9 17l-5-5"/>
|
||||
</svg>
|
||||
<span x-text="$store.playlists.isEditingCurrent() ? '{{ t.player_done }}' : '{{ t.player_edit }}'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="playlist-detail-meta"
|
||||
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
||||
<span x-show="$store.library.currentPlaylist.owner_name"
|
||||
@@ -963,27 +979,61 @@
|
||||
<template x-if="$store.library.currentPlaylist.description">
|
||||
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
||||
</template>
|
||||
<div class="track-list-header">
|
||||
<div class="track-list-header playlist-track-list-header"
|
||||
:class="{ editing: $store.playlists.isEditingCurrent() }">
|
||||
<span class="playlist-edit-cell"
|
||||
x-show="$store.playlists.isEditingCurrent()"
|
||||
x-cloak></span>
|
||||
<span>#</span>
|
||||
<span>{{ t.player_title }}</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.id">
|
||||
<div class="track-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)">
|
||||
<template x-for="(track, idx) in $store.library.currentPlaylist.tracks" :key="track.playlist_track_id || (track.id + '-' + idx)">
|
||||
<div class="track-row playlist-track-row"
|
||||
:data-playlist-index="idx"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, editing: $store.playlists.isEditingCurrent(), dragging: $store.playlists._dragIdx === idx }"
|
||||
:draggable="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||
@dblclick="if (!$store.playlists.isEditingCurrent()) $store.queue.playRelease($store.library.currentPlaylist.tracks, idx)"
|
||||
@dragstart="if (!$store.playlists.startDrag($event, idx)) $event.preventDefault()"
|
||||
@dragend="$store.playlists.endDrag()"
|
||||
@dragover.prevent="$store.playlists.dragOver($event, idx)"
|
||||
@dragleave="$event.currentTarget.classList.remove('drag-over')"
|
||||
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); $store.playlists.dropOn(idx)">
|
||||
<button class="playlist-track-remove"
|
||||
x-show="$store.playlists.isEditingCurrent()"
|
||||
x-cloak
|
||||
@click.stop="$store.playlists.removeCurrentTrack(track, idx)"
|
||||
title="{{ t.player_remove }}"
|
||||
aria-label="{{ t.player_remove }}">
|
||||
<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>
|
||||
<span class="track-num" x-text="idx + 1"></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>
|
||||
</span>
|
||||
</template>
|
||||
<div class="track-info playlist-track-info">
|
||||
<button class="playlist-drag-handle"
|
||||
x-show="$store.playlists.isEditingCurrent() && !!track.playlist_track_id"
|
||||
x-cloak
|
||||
@mousedown.stop
|
||||
@click.stop
|
||||
@pointerdown.stop="$store.playlists.startPointerReorder($event, idx)"
|
||||
title="{{ t.player_edit }}"
|
||||
aria-label="{{ t.player_edit }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
|
||||
</button>
|
||||
<div class="playlist-track-copy">
|
||||
<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>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
|
||||
@@ -489,6 +489,25 @@ button.user-stat:hover {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.playlist-detail-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.playlist-detail-heading .section-title {
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.playlist-edit-toggle {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -735,6 +754,93 @@ button.user-stat:hover {
|
||||
.track-row:hover { background: var(--bg-hover); }
|
||||
.track-row.playing { color: var(--accent); }
|
||||
.track-row.playing .track-num { color: var(--accent); }
|
||||
.playlist-track-list-header.editing,
|
||||
.playlist-track-row.editing {
|
||||
grid-template-columns: 32px 40px minmax(0, 1fr) minmax(0, 1fr) 154px 60px;
|
||||
}
|
||||
|
||||
.playlist-track-row.editing {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.playlist-track-row.editing:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.playlist-track-row.dragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.playlist-track-row.drag-over {
|
||||
border-top: 2px solid var(--accent);
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.playlist-track-remove {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-subdued);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.playlist-track-remove:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.playlist-track-remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.playlist-track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.playlist-track-copy {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-drag-handle {
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text-subdued);
|
||||
cursor: grab;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 24px;
|
||||
padding: 0;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.playlist-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.playlist-drag-handle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.playlist-drag-handle svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.track-row.shared-target {
|
||||
background: rgba(29, 185, 84, 0.12);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
@@ -4233,6 +4339,16 @@ button.user-stat:hover {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.playlist-track-list-header.editing,
|
||||
.playlist-track-row.editing {
|
||||
grid-template-columns: 30px 32px minmax(0, 1fr) auto 54px;
|
||||
}
|
||||
|
||||
.playlist-track-list-header.editing span:nth-child(4),
|
||||
.playlist-track-row.editing > span:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.history-table-head,
|
||||
.history-row.track-row {
|
||||
grid-template-columns: 44px minmax(0, 1fr) auto;
|
||||
@@ -5158,6 +5274,26 @@ button.user-stat:hover {
|
||||
padding: 10px 6px;
|
||||
}
|
||||
|
||||
.playlist-detail-heading {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.playlist-edit-toggle {
|
||||
padding: 8px 10px;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.playlist-track-row.editing {
|
||||
grid-template-columns: 30px minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.playlist-track-row.editing .track-num,
|
||||
.playlist-track-row.editing > span:nth-child(4) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.track-row > span:nth-child(3),
|
||||
.track-duration {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user