Compare commits

..
5 Commits
Author SHA1 Message Date
Ultradesu b737ced3fc Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m1s
2026-07-24 16:54:41 +03:00
Ultradesu c2bdd62a51 Added FED devices
Build and Publish / Build and Publish Docker Image (push) Successful in 4m10s
2026-07-24 16:45:11 +03:00
Ultradesu d1370c6a28 Improved UI. Added playlist manager
Build and Publish / Build and Publish Docker Image (push) Successful in 3m56s
2026-07-23 17:24:28 +03:00
Ultradesu 2fc5fd7960 Improved DHT publication. Adjust batching, added validation per key
Build and Publish / Build and Publish Docker Image (push) Successful in 4m9s
2026-07-23 16:44:35 +03:00
Ultradesu 63506e3af2 Aupdated FRID to v4 protocol
Build and Publish / Build and Publish Docker Image (push) Successful in 5m4s
2026-07-23 15:52:42 +03:00
14 changed files with 6375 additions and 211 deletions
Generated
+76 -119
View File
@@ -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]]
@@ -617,9 +617,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.3.0"
version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -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#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
dependencies = [
"blake3",
"data-encoding",
@@ -1844,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.6.5-fd"
version = "0.8.0"
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#e5353fa9b93d78be6cd811b8430d7dd5e725e6e5"
dependencies = [
"async-trait",
"blake3",
@@ -3633,6 +3633,7 @@ dependencies = [
"rand 0.9.5",
"rusqlite",
"serde",
"serde_json",
"thiserror 2.0.19",
"tokio",
"tracing",
@@ -3847,9 +3848,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 +3870,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 +3897,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 +4035,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 +4952,7 @@ checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.3",
]
[[package]]
@@ -5209,9 +5201,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 +5356,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 +5464,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.3",
]
[[package]]
@@ -5672,6 +5664,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 +5909,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 +6272,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 +6384,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.2",
"syn 3.0.3",
]
[[package]]
@@ -6409,9 +6404,7 @@ checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
dependencies = [
"deranged",
"js-sys",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
@@ -6461,9 +6454,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 +6529,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 +6541,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 +6862,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 +6932,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 +7604,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
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.6.6-fd"
version = "0.8.1"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
File diff suppressed because it is too large Load Diff
+135 -2
View File
@@ -12,6 +12,7 @@
//! `federation_network_id`) and apply on the fly — saving the settings
//! starts, stops or re-joins the node without a server restart.
pub mod devices;
mod serve;
mod storage;
@@ -196,6 +197,7 @@ impl Federation {
.rendezvous(RendezvousConfig::default())
.stream_protocol(AUDIO_ALPN)
.stream_protocol(CATALOG_ALPN)
.stream_protocol(devices::SYNC_ALPN)
.build()
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
let (service, mut events) =
@@ -240,15 +242,34 @@ impl Federation {
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
let catalog_task = tokio::spawn(serve::serve_catalog(
catalog_acceptor,
pool,
pool.clone(),
storage_dir,
service.endpoint_id(),
));
let device_acceptor = service
.stream_acceptor(devices::SYNC_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take the device-sync acceptor: {err}"))?;
let device_hub = crate::player::PlayerDeviceHub::shared();
let device_task = tokio::spawn(devices::serve_peers(
device_acceptor,
pool.clone(),
Arc::clone(&service),
Arc::clone(&device_hub),
));
let device_sync_task =
tokio::spawn(devices::sync_loop(pool, Arc::clone(&service), device_hub));
*guard = Some(Running {
service,
network_name,
tasks: vec![event_task, sync_task, audio_task, catalog_task],
tasks: vec![
event_task,
sync_task,
audio_task,
catalog_task,
device_task,
device_sync_task,
],
});
self.set_error(None);
drop(guard);
@@ -607,6 +628,118 @@ impl Federation {
.map_err(|err| anyhow::anyhow!("connect failed: {err}"))?;
Ok(peer.to_string())
}
pub async fn fed_device_status(
&self,
user_id: i64,
user_name: &str,
) -> Result<devices::FedDeviceStatus> {
let pool = self.pool().await?;
devices::status(&pool, user_id, user_name).await
}
pub async fn fed_device_invite(&self, user_id: i64, user_name: &str) -> Result<String> {
let service = self.service().await?;
let pool = self.pool().await?;
devices::create_invite(&pool, service, user_id, user_name).await
}
pub async fn fed_device_connect(
&self,
user_id: i64,
user_name: &str,
invite: &str,
) -> Result<String> {
let network_id = devices::invite_network_id(invite)?;
{
let guard = self.running.lock().await;
let Some(running) = guard.as_ref() else {
anyhow::bail!("federation is not running");
};
let expected = NetworkId::from_name(&running.network_name);
anyhow::ensure!(
network_id == expected,
"device invite belongs to a different federation network"
);
}
let service = self.service().await?;
let pool = self.pool().await?;
devices::connect_invite(
&pool,
service,
crate::player::PlayerDeviceHub::shared(),
user_id,
user_name,
invite,
)
.await
}
pub async fn fed_device_answer_pairing(
&self,
user_id: i64,
request_id: &str,
accept: bool,
use_requester_group: bool,
) -> Result<()> {
let pool = self.pool().await?;
devices::answer_pairing(&pool, user_id, request_id, accept, use_requester_group).await
}
pub async fn fed_device_revoke(&self, user_id: i64, device_id: &str) -> Result<()> {
let pool = self.pool().await?;
devices::revoke_device(&pool, user_id, device_id).await
}
pub async fn fed_device_sync_now(&self, user_id: i64) -> Result<()> {
let service = self.service().await?;
let pool = self.pool().await?;
devices::sync_once(
&pool,
service,
crate::player::PlayerDeviceHub::shared(),
user_id,
)
.await
}
pub async fn fed_device_web_command(
&self,
user_id: i64,
target_device_id: &str,
command: &str,
payload: serde_json::Value,
current_state: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let pool = self.pool().await?;
devices::record_web_playback_command(
&pool,
user_id,
target_device_id,
command,
payload,
current_state,
)
.await
}
pub async fn fed_device_web_active_transfer(
&self,
user_id: i64,
target_device_id: &str,
previous_device_id: Option<&str>,
state: serde_json::Value,
) -> Result<()> {
let pool = self.pool().await?;
devices::record_web_active_transfer(
&pool,
user_id,
target_device_id,
previous_device_id,
state,
)
.await
}
}
async fn persist_content_id(
+69 -44
View File
@@ -165,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(
@@ -319,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())
}
+1
View File
@@ -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" , "Переименовать";
+274
View File
@@ -1951,6 +1951,278 @@ pub mod db_migrations {
&[Operation::custom(create_playlist_share_links).build()];
}
#[cot::db::migrations::migration_op]
async fn create_fed_device_sync(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> {
ctx.db
.raw(
"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
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
ON furumusic__federation_content_id_cache (content_id)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_identity (
user_id BIGINT PRIMARY KEY,
device_id TEXT NOT NULL UNIQUE,
group_id TEXT NOT NULL,
device_name TEXT NOT NULL,
local_seq BIGINT NOT NULL DEFAULT 0,
last_hlc_ms BIGINT NOT NULL DEFAULT 0,
local_seeded_at_ms BIGINT NOT NULL DEFAULT 0,
last_sync TEXT,
last_error TEXT
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_device (
user_id BIGINT NOT NULL,
device_id TEXT NOT NULL,
name TEXT NOT NULL DEFAULT '',
client_version TEXT NOT NULL DEFAULT '',
protocol_version INTEGER NOT NULL DEFAULT 1,
endpoint_id TEXT NOT NULL DEFAULT '',
endpoint_ticket TEXT NOT NULL DEFAULT '',
trusted_at_ms BIGINT,
last_seen_ms BIGINT,
revoked_at_ms BIGINT,
revoked_by TEXT,
revoke_cutoff_seq BIGINT,
PRIMARY KEY (user_id, device_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_device_single_user
ON furumusic__fed_device (device_id)
WHERE trusted_at_ms IS NOT NULL",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_device_invite (
invite_id TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
secret_hash TEXT NOT NULL,
expires_at_ms BIGINT NOT NULL,
created_at_ms BIGINT NOT NULL,
used_at_ms BIGINT
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_pending_pairing (
request_id TEXT PRIMARY KEY,
user_id BIGINT NOT NULL,
device_id TEXT NOT NULL,
name TEXT NOT NULL,
client_version TEXT NOT NULL,
endpoint_id TEXT NOT NULL,
endpoint_ticket TEXT NOT NULL,
invite_id TEXT NOT NULL,
created_at_ms BIGINT NOT NULL,
answered_at_ms BIGINT,
status TEXT NOT NULL,
requester_group_id TEXT,
requester_group_active_devices BIGINT NOT NULL DEFAULT 1,
requester_group_devices_json TEXT NOT NULL DEFAULT '[]',
use_requester_group BOOLEAN NOT NULL DEFAULT false
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_fed_pending_pairing_user_status
ON furumusic__fed_pending_pairing (user_id, status, created_at_ms DESC)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_ops (
user_id BIGINT NOT NULL,
op_id TEXT NOT NULL,
origin_device_id TEXT NOT NULL,
seq BIGINT NOT NULL,
kind TEXT NOT NULL,
payload_json JSONB NOT NULL,
hlc_ms BIGINT NOT NULL,
received_at_ms BIGINT NOT NULL,
tombstone BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (user_id, op_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_origin_seq
ON furumusic__fed_sync_ops (user_id, origin_device_id, seq)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_fed_sync_ops_tombstone
ON furumusic__fed_sync_ops (user_id, tombstone)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_sync_vector (
user_id BIGINT NOT NULL,
device_id TEXT NOT NULL,
max_seq BIGINT NOT NULL,
PRIMARY KEY (user_id, device_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_peer_ack (
user_id BIGINT NOT NULL,
peer_device_id TEXT NOT NULL,
origin_device_id TEXT NOT NULL,
max_seq BIGINT NOT NULL,
updated_at_ms BIGINT NOT NULL,
PRIMARY KEY (user_id, peer_device_id, origin_device_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_like (
user_id BIGINT NOT NULL,
content_id TEXT NOT NULL,
liked BOOLEAN NOT NULL,
hlc_ms BIGINT NOT NULL,
op_id TEXT NOT NULL,
local_track_id BIGINT,
fed_json JSONB,
PRIMARY KEY (user_id, content_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist (
user_id BIGINT NOT NULL,
playlist_id TEXT NOT NULL,
local_playlist_id BIGINT,
title TEXT NOT NULL,
deleted BOOLEAN NOT NULL DEFAULT false,
hlc_ms BIGINT NOT NULL,
op_id TEXT NOT NULL,
PRIMARY KEY (user_id, playlist_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_fed_state_playlist_local
ON furumusic__fed_state_playlist (user_id, local_playlist_id)
WHERE local_playlist_id IS NOT NULL",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_state_playlist_item (
user_id BIGINT NOT NULL,
playlist_id TEXT NOT NULL,
content_id TEXT NOT NULL,
present BOOLEAN NOT NULL DEFAULT true,
position BIGINT NOT NULL DEFAULT 0,
hlc_ms BIGINT NOT NULL,
op_id TEXT NOT NULL,
local_track_id BIGINT,
fed_json JSONB,
PRIMARY KEY (user_id, playlist_id, content_id)
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_fed_state_playlist_item_playlist
ON furumusic__fed_state_playlist_item
(user_id, playlist_id, present, position)",
)
.await?;
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__fed_playback_applied (
user_id BIGINT NOT NULL,
op_id TEXT NOT NULL,
applied_at_ms BIGINT NOT NULL,
PRIMARY KEY (user_id, op_id)
)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0038CreateFedDeviceSync;
impl migrations::Migration for M0038CreateFedDeviceSync {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0038_create_fed_device_sync";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0037_create_playlist_share_links",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_fed_device_sync).build()];
}
#[cot::db::migrations::migration_op]
async fn ensure_federation_content_id_cache(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"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
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_furumusic_federation_content_id_cache_content_id
ON furumusic__federation_content_id_cache (content_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0039EnsureFederationContentIdCache;
impl migrations::Migration for M0039EnsureFederationContentIdCache {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0039_ensure_federation_content_id_cache";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0038_create_fed_device_sync",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(ensure_federation_content_id_cache).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -1979,5 +2251,7 @@ pub mod db_migrations {
&M0035CreateEntityGenreTags,
&M0036CreateExternalMetadataIds,
&M0037CreatePlaylistShareLinks,
&M0038CreateFedDeviceSync,
&M0039EnsureFederationContentIdCache,
];
}
+26 -1
View File
@@ -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,
@@ -265,6 +272,24 @@ pub(super) struct PlayerDevicesResponse {
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FedDeviceConnectRequest {
pub(super) invite: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FedDevicePairingAnswerRequest {
pub(super) request_id: String,
pub(super) accept: bool,
#[serde(default)]
pub(super) use_requester_group: bool,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub(super) struct FedDeviceRevokeRequest {
pub(super) device_id: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlayerDevicePollResponse {
pub(super) device_id: String,
@@ -286,7 +311,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)]
+727 -23
View File
@@ -1,5 +1,5 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, OnceLock};
use cot::db::Database;
use cot::http::StatusCode;
@@ -59,6 +59,7 @@ const PLAYER_JAM_MAX_INVITEES: usize = 25;
const PLAYER_RADIO_TRACK_LIMIT: usize = 40;
const PLAYER_RADIO_CANDIDATE_LIMIT: i64 = 220;
const PLAYER_RADIO_RELEASE_SEED_TRACKS: i64 = 4;
const FED_DEVICE_PREFIX: &str = "fed:";
#[derive(Debug, Clone)]
struct PlayerDevice {
@@ -108,11 +109,136 @@ struct PlayerDeviceHubState {
}
#[derive(Debug, Default)]
struct PlayerDeviceHub {
pub(crate) struct PlayerDeviceHub {
state: Mutex<PlayerDeviceHubState>,
}
impl PlayerDeviceHub {
pub(crate) fn shared() -> Arc<Self> {
static HUB: OnceLock<Arc<PlayerDeviceHub>> = OnceLock::new();
Arc::clone(HUB.get_or_init(|| Arc::new(PlayerDeviceHub::default())))
}
pub(crate) fn enqueue_fed_command(
&self,
user_id: i64,
command: &str,
payload: serde_json::Value,
) -> Result<(), &'static str> {
let now = current_millis();
let mut state = self.state.lock().expect("player device hub lock");
self.prune_locked(&mut state, now);
let devices = state
.devices_by_user
.get(&user_id)
.ok_or("no browser playback device")?;
let target = state
.active_device_by_user
.get(&user_id)
.filter(|id| !is_fed_virtual_device_id(id))
.filter(|id| devices.contains_key(*id))
.cloned()
.or_else(|| {
devices
.values()
.filter(|device| !is_fed_virtual_device_id(&device.id))
.max_by_key(|device| device.last_seen_ms)
.map(|device| device.id.clone())
})
.ok_or("no browser playback device")?;
state.active_device_by_user.insert(user_id, target.clone());
self.enqueue_command_locked(&mut state, user_id, &target, command, payload, now);
Ok(())
}
pub(crate) fn current_playback_state_json(&self, user_id: i64) -> Option<serde_json::Value> {
let state = self.state.lock().expect("player device hub lock");
if state
.active_device_by_user
.get(&user_id)
.is_some_and(|id| is_fed_virtual_device_id(id))
{
return None;
}
state
.playback_state_by_user
.get(&user_id)
.and_then(|playback| serde_json::to_value(playback).ok())
}
pub(crate) fn playback_state_json_for_commands(
&self,
user_id: i64,
) -> Option<serde_json::Value> {
let now = current_millis();
let state = self.state.lock().expect("player device hub lock");
state
.playback_state_by_user
.get(&user_id)
.cloned()
.map(|playback| playback_state_at(playback, now))
.and_then(|playback| serde_json::to_value(playback).ok())
}
pub(crate) fn active_device_id_for_commands(&self, user_id: i64) -> Option<String> {
let state = self.state.lock().expect("player device hub lock");
state.active_device_by_user.get(&user_id).cloned()
}
pub(crate) fn fed_device_name_for_commands(
&self,
user_id: i64,
fed_device_id: &str,
) -> Option<String> {
let virtual_id = fed_virtual_device_id(fed_device_id);
let state = self.state.lock().expect("player device hub lock");
state
.devices_by_user
.get(&user_id)
.and_then(|devices| devices.get(&virtual_id))
.map(|device| device.name.clone())
}
pub(crate) fn apply_fed_playback_state_json(
&self,
user_id: i64,
fed_device_id: &str,
fed_device_name: &str,
active: bool,
payload: serde_json::Value,
) -> Result<(), &'static str> {
let mut playback_state: PlayerDevicePlaybackStateDto =
serde_json::from_value(payload).map_err(|_| "invalid playback state")?;
let now = current_millis();
playback_state.updated_at_ms = now;
let virtual_id = fed_virtual_device_id(fed_device_id);
let mut state = self.state.lock().expect("player device hub lock");
self.prune_locked(&mut state, now);
state.devices_by_user.entry(user_id).or_default().insert(
virtual_id.clone(),
PlayerDevice {
id: virtual_id.clone(),
name: fed_device_name.to_string(),
kind: "fed".to_string(),
last_seen_ms: now,
},
);
let should_update_playback = active
|| state
.active_device_by_user
.get(&user_id)
.is_some_and(|active_id| active_id == &virtual_id);
if active {
state
.active_device_by_user
.insert(user_id, virtual_id.clone());
}
if should_update_playback {
state.playback_state_by_user.insert(user_id, playback_state);
}
Ok(())
}
fn heartbeat(
&self,
user_id: i64,
@@ -194,7 +320,9 @@ impl PlayerDeviceHub {
state
.playback_state_by_user
.insert(user_id, transfer_state.clone());
if let Ok(payload) = serde_json::to_value(transfer_state) {
if !is_fed_virtual_device_id(target_device_id)
&& let Ok(payload) = serde_json::to_value(transfer_state)
{
self.enqueue_command_locked(
&mut state,
user_id,
@@ -772,6 +900,18 @@ fn current_millis() -> i64 {
chrono::Utc::now().timestamp_millis()
}
fn fed_virtual_device_id(device_id: &str) -> String {
format!("{FED_DEVICE_PREFIX}{device_id}")
}
fn is_fed_virtual_device_id(device_id: &str) -> bool {
device_id.starts_with(FED_DEVICE_PREFIX)
}
fn fed_device_id_from_virtual(device_id: &str) -> Option<&str> {
device_id.strip_prefix(FED_DEVICE_PREFIX)
}
fn playback_state_at(
mut playback_state: PlayerDevicePlaybackStateDto,
now: i64,
@@ -796,7 +936,7 @@ fn normalize_device_id(raw: &str) -> Option<String> {
}
if !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == ':')
{
return None;
}
@@ -949,6 +1089,14 @@ mod device_tests {
);
assert_eq!(device_kind_from_user_agent(user_agent), "phone");
}
#[test]
fn accepts_virtual_fed_device_ids() {
assert_eq!(
normalize_device_id("fed:dev_e5ffc3b65642770c26c53ecf"),
Some("fed:dev_e5ffc3b65642770c26c53ecf".to_string())
);
}
}
#[derive(Debug, sqlx::FromRow)]
@@ -3414,6 +3562,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 +3664,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 +3837,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 +3866,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 +3964,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 +4148,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 +4177,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,
@@ -4501,6 +4681,7 @@ async fn devices_select_handler(
.as_deref()
.and_then(normalize_device_id)
.unwrap_or_else(|| target_device_id.clone());
let previous_active_id = hub.active_device_id_for_commands(user.id);
let Some(response) = hub.select(user.id, &current_device_id, &target_device_id) else {
return Ok(json_error(
@@ -4508,6 +4689,28 @@ async fn devices_select_handler(
"target device is offline",
));
};
if let Some(fed_device_id) = fed_device_id_from_virtual(&target_device_id) {
let Some(playback_state) = response.playback_state.clone() else {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"no playback state to transfer",
));
};
let state = match serde_json::to_value(playback_state) {
Ok(state) => state,
Err(err) => return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
};
let previous_fed_device_id = previous_active_id
.as_deref()
.and_then(fed_device_id_from_virtual)
.filter(|previous| *previous != fed_device_id);
if let Err(err) = crate::federation::handle()
.fed_device_web_active_transfer(user.id, fed_device_id, previous_fed_device_id, state)
.await
{
return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}")));
}
}
Json(response).into_response()
}
@@ -4552,6 +4755,33 @@ async fn devices_command_handler(
stamp_jam_queue_tracks(&mut payload, user.id, &user.name);
}
if jam_id.is_none()
&& let Some(fed_device_id) = target_device_id
.as_deref()
.and_then(fed_device_id_from_virtual)
{
let current_state = hub.playback_state_json_for_commands(user.id);
match crate::federation::handle()
.fed_device_web_command(user.id, fed_device_id, command, payload, current_state)
.await
{
Ok(next_state) => {
let name = hub
.fed_device_name_for_commands(user.id, fed_device_id)
.unwrap_or_else(|| fed_device_id.to_string());
let _ = hub.apply_fed_playback_state_json(
user.id,
fed_device_id,
&name,
true,
next_state,
);
return Json(serde_json::json!({"ok": true})).into_response();
}
Err(err) => return Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
}
}
match hub.enqueue_command(
user.id,
target_device_id.as_deref(),
@@ -4564,6 +4794,116 @@ async fn devices_command_handler(
}
}
async fn fed_devices_status_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
) -> 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"));
};
match crate::federation::handle()
.fed_device_status(user.id, &user.name)
.await
{
Ok(status) => Json(status).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
}
}
async fn fed_devices_invite_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
) -> 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"));
};
match crate::federation::handle()
.fed_device_invite(user.id, &user.name)
.await
{
Ok(invite) => Json(serde_json::json!({ "invite": invite })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
}
}
async fn fed_devices_connect_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
Json(dto): Json<FedDeviceConnectRequest>,
) -> 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"));
};
match crate::federation::handle()
.fed_device_connect(user.id, &user.name, dto.invite.trim())
.await
{
Ok(message) => Json(serde_json::json!({ "ok": true, "message": message })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
}
}
async fn fed_devices_pairing_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
Json(dto): Json<FedDevicePairingAnswerRequest>,
) -> 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"));
};
match crate::federation::handle()
.fed_device_answer_pairing(
user.id,
dto.request_id.trim(),
dto.accept,
dto.use_requester_group,
)
.await
{
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
}
}
async fn fed_devices_revoke_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
Json(dto): Json<FedDeviceRevokeRequest>,
) -> 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"));
};
match crate::federation::handle()
.fed_device_revoke(user.id, dto.device_id.trim())
.await
{
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
}
}
async fn fed_devices_sync_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
) -> 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"));
};
match crate::federation::handle()
.fed_device_sync_now(user.id)
.await
{
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(err) => Ok(json_error(StatusCode::BAD_REQUEST, &format!("{err}"))),
}
}
fn stamp_jam_queue_tracks(payload: &mut serde_json::Value, user_id: i64, user_name: &str) {
let Some(tracks) = payload
.get_mut("tracks")
@@ -4586,6 +4926,103 @@ fn stamp_jam_queue_tracks(payload: &mut serde_json::Value, user_id: i64, user_na
}
}
async fn record_fed_track_like(pool: &sqlx::PgPool, user_id: i64, track_id: i64, liked: bool) {
if let Err(err) =
crate::federation::devices::record_track_like(pool, user_id, track_id, liked).await
{
tracing::warn!(
track_id,
liked,
"fed device like op was not recorded: {err:#}"
);
}
}
async fn record_fed_playlist_created(
pool: &sqlx::PgPool,
user_id: i64,
playlist_id: i64,
title: &str,
) {
if let Err(err) =
crate::federation::devices::record_playlist_created(pool, user_id, playlist_id, title).await
{
tracing::warn!(
playlist_id,
"fed device playlist create op was not recorded: {err:#}"
);
}
}
async fn record_fed_playlist_renamed(
pool: &sqlx::PgPool,
user_id: i64,
playlist_id: i64,
title: &str,
) {
if let Err(err) =
crate::federation::devices::record_playlist_renamed(pool, user_id, playlist_id, title).await
{
tracing::warn!(
playlist_id,
"fed device playlist rename op was not recorded: {err:#}"
);
}
}
async fn record_fed_playlist_deleted(pool: &sqlx::PgPool, user_id: i64, playlist_id: i64) {
if let Err(err) =
crate::federation::devices::record_playlist_deleted(pool, user_id, playlist_id).await
{
tracing::warn!(
playlist_id,
"fed device playlist delete op was not recorded: {err:#}"
);
}
}
async fn record_fed_playlist_tracks_added(
pool: &sqlx::PgPool,
user_id: i64,
playlist_id: i64,
track_ids: &[i64],
) {
if let Err(err) = crate::federation::devices::record_playlist_tracks_added(
pool,
user_id,
playlist_id,
track_ids,
)
.await
{
tracing::warn!(
playlist_id,
"fed device playlist add op was not recorded: {err:#}"
);
}
}
async fn record_fed_playlist_tracks_removed(
pool: &sqlx::PgPool,
user_id: i64,
playlist_id: i64,
content_ids: &[String],
) {
if let Err(err) = crate::federation::devices::record_playlist_tracks_removed(
pool,
user_id,
playlist_id,
content_ids,
)
.await
{
tracing::warn!(
playlist_id,
"fed device playlist remove op was not recorded: {err:#}"
);
}
}
async fn jam_users_search_handler(
auth_ctx: auth::AuthContext,
session: Session,
@@ -5428,6 +5865,8 @@ async fn create_playlist_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
record_fed_playlist_created(pool, user.id, row.0, &title).await;
Json(PlaylistCard {
id: row.0,
title,
@@ -5481,6 +5920,7 @@ async fn update_playlist_handler(
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
record_fed_playlist_renamed(pool, user.id, playlist_id, t).await;
}
}
if let Some(desc) = &body.description {
@@ -5524,6 +5964,7 @@ async fn delete_playlist_handler(
if owner.0 != user.id {
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
}
record_fed_playlist_deleted(pool, user.id, playlist_id).await;
sqlx::query("DELETE FROM furumusic__playlist_track WHERE playlist_id = $1")
.bind(playlist_id)
.execute(pool)
@@ -5606,6 +6047,125 @@ async fn add_tracks_to_playlist_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
record_fed_playlist_tracks_added(pool, user.id, playlist_id, &body.track_ids).await;
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()))?;
let reordered_track_ids = sqlx::query_scalar::<_, i64>(
r#"SELECT pt.track_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()))?;
record_fed_playlist_tracks_added(pool, user.id, playlist_id, &reordered_track_ids).await;
Json(serde_json::json!({"ok": true})).into_response()
}
@@ -5637,13 +6197,47 @@ async fn remove_track_from_playlist_handler(
if owner.0 != user.id {
return Ok(json_error(StatusCode::FORBIDDEN, "not your playlist"));
}
let removed_content_ids = match crate::federation::devices::playlist_content_ids_for_removal(
pool,
playlist_id,
body.playlist_track_id,
body.track_id,
)
.await
{
Ok(ids) => ids,
Err(err) => {
tracing::warn!(
playlist_id,
"fed device playlist removal lookup failed: {err:#}"
);
Vec::new()
}
};
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 +6254,16 @@ 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()))?;
record_fed_playlist_tracks_removed(pool, user.id, playlist_id, &removed_content_ids).await;
Json(serde_json::json!({"ok": true})).into_response()
}
@@ -5694,6 +6298,7 @@ async fn toggle_like_track_handler(
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
record_fed_track_like(pool, user.id, track_id, false).await;
Json(LikeStatus { liked: false }).into_response()
} else {
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
@@ -5706,6 +6311,7 @@ async fn toggle_like_track_handler(
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
record_fed_track_like(pool, user.id, track_id, true).await;
Json(LikeStatus { liked: true }).into_response()
}
}
@@ -5726,16 +6332,17 @@ async fn like_release_handler(
};
let release_id = path.0.id;
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
// Check if ALL tracks in this release are already liked
let total: (i64,) = sqlx::query_as(
"SELECT COUNT(*) FROM furumusic__track WHERE release_id = $1 AND is_hidden = false",
let track_ids = sqlx::query_scalar::<_, i64>(
"SELECT id FROM furumusic__track WHERE release_id = $1 AND is_hidden = false",
)
.bind(release_id)
.fetch_one(pool)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
// Check if ALL tracks in this release are already liked
let total = track_ids.len() as i64;
let liked_count: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM furumusic__user_liked_track ult
JOIN furumusic__track t ON t.id = ult.track_id
@@ -5747,7 +6354,7 @@ async fn like_release_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
if liked_count.0 >= total.0 && total.0 > 0 {
if liked_count.0 >= total && total > 0 {
// Unlike all tracks in release
sqlx::query(
r#"DELETE FROM furumusic__user_liked_track
@@ -5760,6 +6367,9 @@ async fn like_release_handler(
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
for track_id in &track_ids {
record_fed_track_like(pool, user.id, *track_id, false).await;
}
Json(LikeStatus { liked: false }).into_response()
} else {
// Like all tracks in release (skip already liked)
@@ -5779,6 +6389,9 @@ async fn like_release_handler(
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
for track_id in &track_ids {
record_fed_track_like(pool, user.id, *track_id, true).await;
}
Json(LikeStatus { liked: true }).into_response()
}
}
@@ -6439,7 +7052,7 @@ impl PlayerApp {
Self {
config,
scheduler_handle,
device_hub: Arc::new(PlayerDeviceHub::default()),
device_hub: PlayerDeviceHub::shared(),
}
}
}
@@ -7618,6 +8231,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);
@@ -7957,6 +8597,70 @@ impl App for PlayerApp {
}),
"player_devices_command",
),
// -- Federated TUI clients --
Route::with_handler_and_name(
"/fed-devices",
get(
move |auth_ctx: auth::AuthContext, session: Session, db: Database| async move {
fed_devices_status_handler(auth_ctx, session, db).await
},
),
"player_fed_devices",
),
Route::with_handler_and_name(
"/fed-devices/invite",
post(
move |auth_ctx: auth::AuthContext, session: Session, db: Database| async move {
fed_devices_invite_handler(auth_ctx, session, db).await
},
),
"player_fed_devices_invite",
),
Route::with_handler_and_name(
"/fed-devices/connect",
post(
move |auth_ctx: auth::AuthContext,
session: Session,
db: Database,
json: Json<FedDeviceConnectRequest>| async move {
fed_devices_connect_handler(auth_ctx, session, db, json).await
},
),
"player_fed_devices_connect",
),
Route::with_handler_and_name(
"/fed-devices/pairing",
post(
move |auth_ctx: auth::AuthContext,
session: Session,
db: Database,
json: Json<FedDevicePairingAnswerRequest>| async move {
fed_devices_pairing_handler(auth_ctx, session, db, json).await
},
),
"player_fed_devices_pairing",
),
Route::with_handler_and_name(
"/fed-devices/revoke",
post(
move |auth_ctx: auth::AuthContext,
session: Session,
db: Database,
json: Json<FedDeviceRevokeRequest>| async move {
fed_devices_revoke_handler(auth_ctx, session, db, json).await
},
),
"player_fed_devices_revoke",
),
Route::with_handler_and_name(
"/fed-devices/sync",
post(
move |auth_ctx: auth::AuthContext, session: Session, db: Database| async move {
fed_devices_sync_handler(auth_ctx, session, db).await
},
),
"player_fed_devices_sync",
),
Route::with_handler_and_name(
"/jams/users",
get({
+7 -1
View File
@@ -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)]
+1
View File
@@ -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>,
+371 -3
View File
@@ -1251,7 +1251,12 @@ document.addEventListener('alpine:init', () => {
queue.currentIndex = Math.max(0, Math.min(Number(payload.index || 0), queue.tracks.length - 1));
}
const track = payload.track || queue.tracks[queue.currentIndex];
if (track) this._playLocal(track, payload);
if (track?.unavailable || (track && !track.stream_url)) {
this._applyRemotePlaybackState({ ...payload, track, paused: true });
this._pauseLocal();
} else if (track) {
this._playLocal(track, payload);
}
} else if (command.command === 'pause') {
this.pause();
} else if (command.command === 'resume') {
@@ -1496,12 +1501,18 @@ document.addEventListener('alpine:init', () => {
jamSelectedUsers: [],
jamSearching: false,
jamLocalPlayback: false,
fed: null,
fedInvite: '',
fedInviteInput: '',
fedBusy: false,
fedError: '',
remoteHintVisible: false,
remoteHintDeviceName: '',
_remoteHintShown: false,
_remoteHintTimer: null,
_pollTimer: null,
_jamSearchTimer: null,
_fedRefreshTick: 0,
_stateRefreshTick: 0,
_lastPlaybackState: null,
@@ -1509,9 +1520,13 @@ document.addEventListener('alpine:init', () => {
this.id = this._ensureId();
this.currentJamId = sessionStorage.getItem('furu_player_jam_id') || null;
this.heartbeat();
this.loadFedDevices();
this._pollTimer = setInterval(() => this.poll(), 500);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) this.poll();
if (!document.hidden) {
this.poll();
this.loadFedDevices();
}
});
},
@@ -1561,6 +1576,9 @@ document.addEventListener('alpine:init', () => {
const data = await res.json();
if (data.playback_state) this._lastPlaybackState = data.playback_state;
this._apply(data);
if (this.open || (++this._fedRefreshTick % 10 === 0)) {
this.loadFedDevices();
}
const player = Alpine.store('player');
if (player && Array.isArray(data.commands)) {
@@ -1730,7 +1748,142 @@ document.addEventListener('alpine:init', () => {
toggle() {
this.dismissRemoteHint();
this.open = !this.open;
if (this.open) this.poll();
if (this.open) {
this.poll();
this.loadFedDevices();
}
},
fedDevices() {
return (this.fed?.devices || []).filter(device => !device.revoked);
},
fedPending() {
return this.fed?.pending || [];
},
fedSummary() {
if (!this.fed) return 'Fed sync unavailable';
const outbox = Number(this.fed.outbox_ops || 0);
const unresolved = Number(this.fed.unresolved_items || 0);
return `${this.fed.active_devices || 0} linked · ${outbox} queued · ${unresolved} unresolved`;
},
async loadFedDevices() {
try {
const res = await fetch('/api/player/fed-devices');
if (!res.ok) {
this.fedError = await this._errorText(res);
return;
}
this.fed = await res.json();
this.fedError = '';
} catch (err) {
this.fedError = String(err?.message || err || 'Fed sync unavailable');
}
},
async generateFedInvite() {
if (this.fedBusy) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/invite', { method: 'POST' });
if (!res.ok) throw new Error(await this._errorText(res));
const data = await res.json();
this.fedInvite = data.invite || '';
if (this.fedInvite && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(this.fedInvite).catch(() => {});
}
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Invite failed');
} finally {
this.fedBusy = false;
}
},
async connectFedInvite() {
const invite = this.fedInviteInput.trim();
if (!invite || this.fedBusy) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/connect', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invite }),
});
if (!res.ok) throw new Error(await this._errorText(res));
this.fedInviteInput = '';
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Connect failed');
} finally {
this.fedBusy = false;
}
},
async answerFedPairing(request, accept, useRequesterGroup = false) {
if (!request?.request_id || this.fedBusy) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/pairing', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
request_id: request.request_id,
accept,
use_requester_group: useRequesterGroup,
}),
});
if (!res.ok) throw new Error(await this._errorText(res));
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Pairing failed');
} finally {
this.fedBusy = false;
}
},
async revokeFedDevice(device) {
if (!device?.device_id || device.is_self || this.fedBusy) return;
if (!window.confirm(`Revoke ${device.name || device.device_id}?`)) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/revoke', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_id: device.device_id }),
});
if (!res.ok) throw new Error(await this._errorText(res));
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Revoke failed');
} finally {
this.fedBusy = false;
}
},
async syncFedDevices() {
if (this.fedBusy) return;
this.fedBusy = true;
try {
const res = await fetch('/api/player/fed-devices/sync', { method: 'POST' });
if (!res.ok) throw new Error(await this._errorText(res));
await this.loadFedDevices();
} catch (err) {
this.fedError = String(err?.message || err || 'Sync failed');
} finally {
this.fedBusy = false;
}
},
async _errorText(res) {
try {
const data = await res.json();
return data.error || res.statusText || 'Request failed';
} catch {
return res.statusText || 'Request failed';
}
},
async select(deviceId) {
@@ -2745,6 +2898,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 +2910,7 @@ document.addEventListener('alpine:init', () => {
},
showSharedPlaylist(share, options = {}) {
Alpine.store('playlists')?.stopEdit?.();
this._saveScrollPosition(this._activeHash);
this.searchQuery = '';
this.searchResults = null;
@@ -4301,6 +4456,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 +4493,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: '' };
},
+126 -15
View File
@@ -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>
@@ -1285,6 +1335,67 @@
</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>
</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 x-if="$store.devices.jams.length > 0">
<div class="device-section-label jam-section-label">Jams</div>
</template>
+274 -2
View File
@@ -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);
@@ -1750,9 +1856,9 @@ button.user-stat:hover {
position: absolute;
right: 0;
bottom: 38px;
width: 260px;
width: 320px;
max-width: calc(100vw - 24px);
max-height: min(320px, calc(100dvh - var(--player-bar-space) - 24px));
max-height: min(440px, calc(100dvh - var(--player-bar-space) - 24px));
overflow-y: auto;
padding: 6px;
border: 1px solid var(--border-color);
@@ -1849,6 +1955,142 @@ button.user-stat:hover {
text-transform: uppercase;
}
.fed-section-label {
margin-top: 4px;
color: #b8d6ff;
}
.fed-device-panel {
margin: 2px 2px 6px;
padding: 8px;
border: 1px solid rgba(82,145,255,0.18);
border-radius: 6px;
background: rgba(82,145,255,0.045);
display: grid;
gap: 7px;
}
.fed-device-status,
.fed-device-error,
.fed-pairing-note {
color: var(--text-subdued);
font-size: 11px;
line-height: 1.35;
}
.fed-device-error {
color: #ffb2b2;
}
.fed-device-row {
min-height: 32px;
display: grid;
grid-template-columns: 9px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
}
.fed-device-dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: #73d795;
}
.fed-device-dot.self {
background: #ffd166;
}
.fed-device-main {
min-width: 0;
display: grid;
gap: 1px;
}
.fed-device-name,
.fed-pairing-title {
color: var(--text-primary);
font-size: 12px;
font-weight: 750;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fed-device-meta,
.fed-pairing-meta {
color: var(--text-subdued);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fed-pairing-card {
padding: 7px;
border-radius: 5px;
background: rgba(255,255,255,0.04);
display: grid;
gap: 5px;
}
.fed-device-actions,
.fed-connect-row {
display: flex;
gap: 6px;
min-width: 0;
}
.fed-connect-row .fed-device-input {
flex: 1;
}
.fed-action-btn,
.fed-revoke-btn {
height: 28px;
border: 0;
border-radius: 4px;
background: rgba(255,255,255,0.08);
color: var(--text-secondary);
padding: 0 8px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.fed-action-btn:hover,
.fed-revoke-btn:hover {
background: rgba(255,255,255,0.13);
color: var(--text-primary);
}
.fed-action-btn.primary {
background: rgba(82,145,255,0.16);
color: #c9dcff;
}
.fed-action-btn:disabled {
opacity: 0.45;
cursor: default;
}
.fed-revoke-btn {
background: rgba(255,96,96,0.1);
color: #ffb2b2;
}
.fed-device-input {
width: 100%;
min-width: 0;
height: 30px;
border: 1px solid rgba(82,145,255,0.2);
border-radius: 4px;
background: rgba(0,0,0,0.18);
color: var(--text-primary);
padding: 0 8px;
font-size: 12px;
}
.jam-section-label,
.jam-row,
.start-jam-row,
@@ -4233,6 +4475,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 +5410,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;