fed fixes
This commit is contained in:
Generated
+101
-88
@@ -103,9 +103,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.103"
|
version = "1.0.104"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "approx"
|
name = "approx"
|
||||||
@@ -167,7 +167,7 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"async-task",
|
"async-task",
|
||||||
"concurrent-queue",
|
"concurrent-queue",
|
||||||
"fastrand 2.4.1",
|
"fastrand 2.5.0",
|
||||||
"futures-lite 2.6.1",
|
"futures-lite 2.6.1",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"slab",
|
"slab",
|
||||||
@@ -297,13 +297,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "async-trait"
|
name = "async-trait"
|
||||||
version = "0.1.89"
|
version = "0.1.91"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -365,7 +365,7 @@ version = "1.6.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
|
checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand 2.4.1",
|
"fastrand 2.5.0",
|
||||||
"gloo-timers",
|
"gloo-timers",
|
||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
@@ -489,9 +489,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.1"
|
version = "1.25.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
|
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "byteorder"
|
name = "byteorder"
|
||||||
@@ -522,9 +522,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.2.67"
|
version = "1.3.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
|
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
"shlex",
|
"shlex",
|
||||||
@@ -593,7 +593,7 @@ version = "0.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1477,9 +1477,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.4.1"
|
version = "2.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
@@ -1493,7 +1493,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#06158b78a4b606761c06b78fd4ac520620f10d2b"
|
source = "git+https://gt.hexor.cy/ab/frid.git#8ee1db9cf89ea604c6b8a8c3fe089a714c1e321f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1505,7 +1505,7 @@ dependencies = [
|
|||||||
"postcard",
|
"postcard",
|
||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
@@ -1607,6 +1607,7 @@ name = "furumi_tui"
|
|||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"blake3",
|
||||||
"core-foundation 0.10.1",
|
"core-foundation 0.10.1",
|
||||||
"crokey",
|
"crokey",
|
||||||
"crossterm",
|
"crossterm",
|
||||||
@@ -1622,7 +1623,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"souvlaki",
|
"souvlaki",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -1633,9 +1634,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -1661,9 +1662,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -1671,15 +1672,15 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-core"
|
name = "futures-core"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-executor"
|
name = "futures-executor"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-task",
|
"futures-task",
|
||||||
@@ -1688,9 +1689,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-io"
|
name = "futures-io"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-lite"
|
name = "futures-lite"
|
||||||
@@ -1713,7 +1714,7 @@ version = "2.6.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand 2.4.1",
|
"fastrand 2.5.0",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"parking",
|
"parking",
|
||||||
@@ -1722,9 +1723,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-macro"
|
name = "futures-macro"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
@@ -1733,21 +1734,21 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-sink"
|
name = "futures-sink"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-task"
|
name = "futures-task"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-util"
|
name = "futures-util"
|
||||||
version = "0.3.32"
|
version = "0.3.33"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
@@ -1983,7 +1984,7 @@ dependencies = [
|
|||||||
"jni 0.22.4",
|
"jni 0.22.4",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"rustls",
|
"rustls",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -2005,7 +2006,7 @@ dependencies = [
|
|||||||
"prefix-trie",
|
"prefix-trie",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"ring",
|
"ring",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
@@ -2033,7 +2034,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -2095,9 +2096,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.10.1"
|
version = "1.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -2647,7 +2648,7 @@ dependencies = [
|
|||||||
"jni-sys 0.4.1",
|
"jni-sys 0.4.1",
|
||||||
"log",
|
"log",
|
||||||
"simd_cesu8",
|
"simd_cesu8",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
@@ -2712,7 +2713,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.16.1",
|
"hashbrown 0.16.1",
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2916,7 +2917,7 @@ dependencies = [
|
|||||||
"serde_bencode",
|
"serde_bencode",
|
||||||
"serde_bytes",
|
"serde_bytes",
|
||||||
"sha1_smol",
|
"sha1_smol",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3026,8 +3027,9 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
source = "git+https://gt.hexor.cy/ab/frid.git#06158b78a4b606761c06b78fd4ac520620f10d2b"
|
source = "git+https://gt.hexor.cy/ab/frid.git#8ee1db9cf89ea604c6b8a8c3fe089a714c1e321f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
"federation-net",
|
"federation-net",
|
||||||
@@ -3036,7 +3038,7 @@ dependencies = [
|
|||||||
"rand 0.9.5",
|
"rand 0.9.5",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
@@ -3183,7 +3185,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"netlink-packet-core",
|
"netlink-packet-core",
|
||||||
"netlink-sys",
|
"netlink-sys",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3286,7 +3288,7 @@ dependencies = [
|
|||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.6.5",
|
"socket2 0.6.5",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-stream",
|
"tokio-stream",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -3314,7 +3316,7 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"sorted-index-buffer",
|
"sorted-index-buffer",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -3853,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atomic-waker",
|
"atomic-waker",
|
||||||
"fastrand 2.4.1",
|
"fastrand 2.5.0",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3949,9 +3951,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portable-atomic"
|
name = "portable-atomic"
|
||||||
version = "1.13.1"
|
version = "1.14.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -4066,9 +4068,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.107"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
@@ -4096,9 +4098,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.46"
|
version = "1.0.47"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
]
|
]
|
||||||
@@ -4232,7 +4234,7 @@ dependencies = [
|
|||||||
"palette",
|
"palette",
|
||||||
"serde",
|
"serde",
|
||||||
"strum",
|
"strum",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"unicode-segmentation",
|
"unicode-segmentation",
|
||||||
"unicode-truncate",
|
"unicode-truncate",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
@@ -4318,7 +4320,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4417,7 +4419,7 @@ dependencies = [
|
|||||||
"dasp_sample",
|
"dasp_sample",
|
||||||
"num-rational",
|
"num-rational",
|
||||||
"symphonia",
|
"symphonia",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -4653,9 +4655,9 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.229"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
@@ -4683,29 +4685,29 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_core"
|
name = "serde_core"
|
||||||
version = "1.0.228"
|
version = "1.0.229"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_derive",
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_derive"
|
name = "serde_derive"
|
||||||
version = "1.0.228"
|
version = "1.0.229"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_json"
|
name = "serde_json"
|
||||||
version = "1.0.150"
|
version = "1.0.151"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"itoa",
|
"itoa",
|
||||||
"memchr",
|
"memchr",
|
||||||
@@ -4716,13 +4718,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_repr"
|
name = "serde_repr"
|
||||||
version = "0.1.20"
|
version = "0.1.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5196,6 +5198,17 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sync_wrapper"
|
name = "sync_wrapper"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -5249,7 +5262,7 @@ version = "3.27.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastrand 2.4.1",
|
"fastrand 2.5.0",
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.1.4",
|
"rustix 1.1.4",
|
||||||
@@ -5343,11 +5356,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"thiserror-impl 2.0.18",
|
"thiserror-impl 2.0.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5363,13 +5376,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror-impl"
|
name = "thiserror-impl"
|
||||||
version = "2.0.18"
|
version = "2.0.19"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 3.0.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5383,9 +5396,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time"
|
name = "time"
|
||||||
version = "0.3.53"
|
version = "0.3.54"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"deranged",
|
"deranged",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -5406,9 +5419,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "time-macros"
|
name = "time-macros"
|
||||||
version = "0.2.31"
|
version = "0.2.32"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
|
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"num-conv",
|
"num-conv",
|
||||||
"time-core",
|
"time-core",
|
||||||
@@ -6020,18 +6033,18 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-root-certs"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.8"
|
version = "1.0.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
|
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.8"
|
version = "1.0.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -6538,7 +6551,7 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"windows 0.62.2",
|
"windows 0.62.2",
|
||||||
"windows-core",
|
"windows-core",
|
||||||
]
|
]
|
||||||
@@ -6562,7 +6575,7 @@ dependencies = [
|
|||||||
"pharos",
|
"pharos",
|
||||||
"rustc_version",
|
"rustc_version",
|
||||||
"send_wrapper",
|
"send_wrapper",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.19",
|
||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ path = "src/main.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
anyhow = "1.0.102"
|
anyhow = "1.0.102"
|
||||||
|
blake3 = "1"
|
||||||
crokey = "1.4.0"
|
crokey = "1.4.0"
|
||||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||||
directories = "6.0.0"
|
directories = "6.0.0"
|
||||||
|
|||||||
+6
-2
@@ -21,8 +21,12 @@ pub enum Action {
|
|||||||
PlayPause,
|
PlayPause,
|
||||||
NextTrack,
|
NextTrack,
|
||||||
PrevTrack,
|
PrevTrack,
|
||||||
SeekForward { seconds: u32 },
|
SeekForward {
|
||||||
SeekBackward { seconds: u32 },
|
seconds: u32,
|
||||||
|
},
|
||||||
|
SeekBackward {
|
||||||
|
seconds: u32,
|
||||||
|
},
|
||||||
VolumeUp,
|
VolumeUp,
|
||||||
VolumeDown,
|
VolumeDown,
|
||||||
ToggleShuffle,
|
ToggleShuffle,
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
|||||||
// One-shot commands have no live effect.
|
// One-shot commands have no live effect.
|
||||||
Command::Quit
|
Command::Quit
|
||||||
| Command::Import(_)
|
| Command::Import(_)
|
||||||
|
| Command::Open(_)
|
||||||
| Command::Volume(_)
|
| Command::Volume(_)
|
||||||
| Command::Seek(_)
|
| Command::Seek(_)
|
||||||
| Command::SeekTo(_)
|
| Command::SeekTo(_)
|
||||||
@@ -165,6 +166,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
|||||||
Command::Search(_) => {}
|
Command::Search(_) => {}
|
||||||
Command::Quit => state.should_quit = true,
|
Command::Quit => state.should_quit = true,
|
||||||
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
||||||
|
Command::Open(link) => open_frid_link(state, runtime, link),
|
||||||
Command::Volume(value) => {
|
Command::Volume(value) => {
|
||||||
state.player.volume = value;
|
state.player.volume = value;
|
||||||
super::perform_effect(state, runtime, Effect::SetVolume(value));
|
super::perform_effect(state, runtime, Effect::SetVolume(value));
|
||||||
@@ -208,6 +210,26 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn open_frid_link(state: &mut AppState, runtime: &Runtime, link: String) {
|
||||||
|
let Some(content_id) = crate::share::parse_frid_content_id(&link) else {
|
||||||
|
state.status_message = Some("usage: :open frid://<content_id>".into());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
state.status_message = Some("federation: opening shared track…".into());
|
||||||
|
let federation = Arc::clone(&runtime.federation);
|
||||||
|
let tx = runtime.event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let event = match federation.track_by_content_id(&content_id).await {
|
||||||
|
Ok(track) => AppEvent::EnqueueTracks {
|
||||||
|
tracks: vec![crate::federation::pending_track(&track)],
|
||||||
|
next: false,
|
||||||
|
},
|
||||||
|
Err(err) => AppEvent::StatusMessage(format!("open failed: {err:#}")),
|
||||||
|
};
|
||||||
|
let _ = tx.send(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Esc: close the line and undo any live effect it had.
|
/// Esc: close the line and undo any live effect it had.
|
||||||
fn cancel(state: &mut AppState) {
|
fn cancel(state: &mut AppState) {
|
||||||
retract_live(state);
|
retract_live(state);
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ pub enum Command {
|
|||||||
/// `:import <path>` — import an audio file or a directory into the
|
/// `:import <path>` — import an audio file or a directory into the
|
||||||
/// library.
|
/// library.
|
||||||
Import(String),
|
Import(String),
|
||||||
|
/// `:open frid://...` — open a shared federation content link.
|
||||||
|
Open(String),
|
||||||
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
||||||
Volume(u8),
|
Volume(u8),
|
||||||
/// `:seek +30` / `:seek -10` — relative seek in seconds.
|
/// `:seek +30` / `:seek -10` — relative seek in seconds.
|
||||||
@@ -82,6 +84,13 @@ pub fn parse(input: &str) -> Parsed {
|
|||||||
_ => Parsed::Invalid("usage: :import <file or directory>".to_string()),
|
_ => Parsed::Invalid("usage: :import <file or directory>".to_string()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"open" => {
|
||||||
|
let value = input.trim_start().split_once(char::is_whitespace);
|
||||||
|
match value.map(|(_, rest)| rest.trim()) {
|
||||||
|
Some(value) if !value.is_empty() => Parsed::Command(Command::Open(value.into())),
|
||||||
|
_ => Parsed::Invalid("usage: :open frid://<content_id>".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
|
"volume" | "vol" => match arg.and_then(|a| a.parse::<u8>().ok()) {
|
||||||
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
||||||
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
||||||
@@ -162,7 +171,17 @@ mod tests {
|
|||||||
parse("import ~/Music/My Album"),
|
parse("import ~/Music/My Album"),
|
||||||
Parsed::Command(Command::Import("~/Music/My Album".to_string()))
|
Parsed::Command(Command::Import("~/Music/My Album".to_string()))
|
||||||
);
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse(
|
||||||
|
"open frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=A-B"
|
||||||
|
),
|
||||||
|
Parsed::Command(Command::Open(
|
||||||
|
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=A-B"
|
||||||
|
.to_string()
|
||||||
|
))
|
||||||
|
);
|
||||||
assert!(matches!(parse("import"), Parsed::Invalid(_)));
|
assert!(matches!(parse("import"), Parsed::Invalid(_)));
|
||||||
|
assert!(matches!(parse("open"), Parsed::Invalid(_)));
|
||||||
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
|
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
|
||||||
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
||||||
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
||||||
|
|||||||
@@ -119,6 +119,13 @@ pub enum AppEvent {
|
|||||||
placeholder_id: i64,
|
placeholder_id: i64,
|
||||||
result: Result<Box<crate::federation::FedPlayable>, String>,
|
result: Result<Box<crate::federation::FedPlayable>, String>,
|
||||||
},
|
},
|
||||||
|
/// Rich metadata for a federated track-info preview arrived without
|
||||||
|
/// downloading the audio file.
|
||||||
|
FedTrackInfoLoaded {
|
||||||
|
placeholder_id: i64,
|
||||||
|
item_id: String,
|
||||||
|
result: Result<TrackItem, String>,
|
||||||
|
},
|
||||||
/// This peer's connection ticket, requested from the Federation tab.
|
/// This peer's connection ticket, requested from the Federation tab.
|
||||||
FedTicket(Result<String, String>),
|
FedTicket(Result<String, String>),
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-25
@@ -1,8 +1,8 @@
|
|||||||
pub mod action;
|
pub mod action;
|
||||||
mod cmdline;
|
mod cmdline;
|
||||||
pub mod input;
|
|
||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
|
pub mod input;
|
||||||
mod popup;
|
mod popup;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
@@ -290,9 +290,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
|||||||
}
|
}
|
||||||
for detail in state.release_views.values() {
|
for detail in state.release_views.values() {
|
||||||
if let state::Loadable::Ready(detail) = detail
|
if let state::Loadable::Ready(detail) = detail
|
||||||
&& let Some(path) = &detail.cover_path {
|
&& let Some(path) = &detail.cover_path
|
||||||
wanted.push((path.clone(), header.0, header.1));
|
{
|
||||||
}
|
wanted.push((path.clone(), header.0, header.1));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if let Some((_, state::Loadable::Ready(card))) = &state.fed_artist_view {
|
if let Some((_, state::Loadable::Ready(card))) = &state.fed_artist_view {
|
||||||
if let Some(path) = &card.image_path {
|
if let Some(path) = &card.image_path {
|
||||||
@@ -501,6 +502,26 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None),
|
Effect::FedDownload { tracks } => fed_download_spawn(runtime, tracks, None),
|
||||||
|
Effect::FedFetchTrackInfo { tracks } => {
|
||||||
|
for (placeholder_id, fed_track) in tracks {
|
||||||
|
let federation = Arc::clone(&runtime.federation);
|
||||||
|
let tx = runtime.event_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut preview = crate::federation::pending_track(&fed_track);
|
||||||
|
preview.id = placeholder_id;
|
||||||
|
let item_id = fed_track.item_id.clone();
|
||||||
|
let result = federation
|
||||||
|
.track_info(preview)
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("{err:#}"));
|
||||||
|
let _ = tx.send(AppEvent::FedTrackInfoLoaded {
|
||||||
|
placeholder_id,
|
||||||
|
item_id,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Effect::RemoveQueueIndices {
|
Effect::RemoveQueueIndices {
|
||||||
restart_paused,
|
restart_paused,
|
||||||
stop,
|
stop,
|
||||||
@@ -900,9 +921,10 @@ pub(super) fn spawn_import(state: &mut AppState, runtime: &Runtime, path: &str)
|
|||||||
|
|
||||||
fn expand_tilde(path: &str) -> PathBuf {
|
fn expand_tilde(path: &str) -> PathBuf {
|
||||||
if let Some(rest) = path.strip_prefix("~/")
|
if let Some(rest) = path.strip_prefix("~/")
|
||||||
&& let Some(home) = std::env::home_dir() {
|
&& let Some(home) = std::env::home_dir()
|
||||||
return home.join(rest);
|
{
|
||||||
}
|
return home.join(rest);
|
||||||
|
}
|
||||||
PathBuf::from(path)
|
PathBuf::from(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1071,11 +1093,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
state.federation.status = Some(status);
|
state.federation.status = Some(status);
|
||||||
}
|
}
|
||||||
AppEvent::FedSearchLoaded { seq, result } => {
|
AppEvent::FedSearchLoaded { seq, result } => {
|
||||||
if runtime
|
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
||||||
.search_seq
|
|
||||||
.load(std::sync::atomic::Ordering::SeqCst)
|
|
||||||
!= seq
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
state.search.fed_loading = false;
|
state.search.fed_loading = false;
|
||||||
@@ -1153,6 +1171,25 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
AppEvent::FedTrackInfoLoaded {
|
||||||
|
placeholder_id,
|
||||||
|
item_id,
|
||||||
|
result,
|
||||||
|
} => match result {
|
||||||
|
Ok(enriched) => {
|
||||||
|
if let Some(state::Popup::TrackInfo { tracks, .. }) = &mut state.popup
|
||||||
|
&& let Some(slot) = tracks.iter_mut().find(|track| {
|
||||||
|
track.id == placeholder_id
|
||||||
|
&& track.fed.as_ref().is_some_and(|fed| fed.item_id == item_id)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
*slot = enriched;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(message) => {
|
||||||
|
state.status_message = Some(format!("federation metadata: {message}"));
|
||||||
|
}
|
||||||
|
},
|
||||||
AppEvent::FedArtistLoaded { name, result } => {
|
AppEvent::FedArtistLoaded { name, result } => {
|
||||||
if let Some((current, data)) = &mut state.fed_artist_view
|
if let Some((current, data)) = &mut state.fed_artist_view
|
||||||
&& *current == name
|
&& *current == name
|
||||||
@@ -1250,21 +1287,23 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
|||||||
state.release_views.insert(id, entry);
|
state.release_views.insert(id, entry);
|
||||||
// A Shift-J jump was waiting for this release: focus its track.
|
// A Shift-J jump was waiting for this release: focus its track.
|
||||||
if let Some((release_id, track_id)) = state.pending_release_focus
|
if let Some((release_id, track_id)) = state.pending_release_focus
|
||||||
&& release_id == id {
|
&& release_id == id
|
||||||
state.pending_release_focus = None;
|
{
|
||||||
if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) {
|
state.pending_release_focus = None;
|
||||||
let position = detail
|
if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) {
|
||||||
.tracks
|
let position = detail
|
||||||
.iter()
|
.tracks
|
||||||
.position(|t| t.id == track_id)
|
.iter()
|
||||||
.unwrap_or(0);
|
.position(|t| t.id == track_id)
|
||||||
if let Some(state::GlobalView::Release { id: top, cursor }) =
|
.unwrap_or(0);
|
||||||
state.global.stack.last_mut()
|
if let Some(state::GlobalView::Release { id: top, cursor }) =
|
||||||
&& *top == release_id {
|
state.global.stack.last_mut()
|
||||||
*cursor = position;
|
&& *top == release_id
|
||||||
}
|
{
|
||||||
|
*cursor = position;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
AppEvent::SearchLoaded { seq, result } => {
|
AppEvent::SearchLoaded { seq, result } => {
|
||||||
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
||||||
|
|||||||
+69
-1
@@ -3,6 +3,8 @@
|
|||||||
//! the state, handled as an owned value and put back unless the action
|
//! the state, handled as an owned value and put back unless the action
|
||||||
//! closed it.
|
//! closed it.
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent};
|
||||||
@@ -111,7 +113,10 @@ pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
|||||||
// Edit form
|
// Edit form
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments, reason = "owned popup state passed back in")]
|
#[allow(
|
||||||
|
clippy::too_many_arguments,
|
||||||
|
reason = "owned popup state passed back in"
|
||||||
|
)]
|
||||||
fn handle_edit(
|
fn handle_edit(
|
||||||
state: &mut AppState,
|
state: &mut AppState,
|
||||||
runtime: &Runtime,
|
runtime: &Runtime,
|
||||||
@@ -342,6 +347,22 @@ fn handle_track_info(
|
|||||||
scroll: 0,
|
scroll: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('c') => {
|
||||||
|
if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) {
|
||||||
|
match crate::share::track_share_link(track) {
|
||||||
|
Some(link) => match copy_to_clipboard(&link) {
|
||||||
|
Ok(()) => state.status_message = Some("frid link copied".into()),
|
||||||
|
Err(err) => state.status_message = Some(format!("copy failed: {err}")),
|
||||||
|
},
|
||||||
|
None => state.status_message = Some("no content id for this track yet".into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.popup = Some(Popup::TrackInfo {
|
||||||
|
tracks,
|
||||||
|
cursor: cursor.min(len.saturating_sub(1)),
|
||||||
|
scroll,
|
||||||
|
});
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
state.popup = Some(Popup::TrackInfo {
|
state.popup = Some(Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
@@ -352,6 +373,53 @@ fn handle_track_info(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn copy_to_clipboard(text: &str) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
return run_clipboard_command("pbcopy", &[], text);
|
||||||
|
}
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
{
|
||||||
|
return run_clipboard_command("cmd", &["/C", "clip"], text);
|
||||||
|
}
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
|
{
|
||||||
|
for (program, args) in [
|
||||||
|
("wl-copy", &[][..]),
|
||||||
|
("xclip", &["-selection", "clipboard"][..]),
|
||||||
|
("xsel", &["--clipboard", "--input"][..]),
|
||||||
|
] {
|
||||||
|
if run_clipboard_command(program, args, text).is_ok() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err("clipboard command not found (tried wl-copy, xclip, xsel)".into())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_clipboard_command(program: &str, args: &[&str], text: &str) -> Result<(), String> {
|
||||||
|
let mut child = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.spawn()
|
||||||
|
.map_err(|err| format!("{program}: {err}"))?;
|
||||||
|
let Some(stdin) = child.stdin.as_mut() else {
|
||||||
|
return Err(format!("{program}: stdin unavailable"));
|
||||||
|
};
|
||||||
|
stdin
|
||||||
|
.write_all(text.as_bytes())
|
||||||
|
.map_err(|err| format!("{program}: {err}"))?;
|
||||||
|
drop(child.stdin.take());
|
||||||
|
let status = child.wait().map_err(|err| format!("{program}: {err}"))?;
|
||||||
|
if status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("{program}: exited with {status}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Add-to-playlist picker & new playlist
|
// Add-to-playlist picker & new playlist
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
+45
-7
@@ -120,24 +120,31 @@ impl Default for GlobalTab {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Releases of an artist in display order: grouped by type (albums, EPs,
|
/// Releases of an artist in display order: grouped by type (albums, singles,
|
||||||
/// singles, compilations, then anything else), keeping server order within a
|
/// EPs, compilations, then anything else), keeping server order within a
|
||||||
/// group. Returns (group label, indices into the original slice). Cursor
|
/// group. Returns (group label, indices into the original slice). Cursor
|
||||||
/// positions use this flattened order, so update() and ui must both go
|
/// positions use this flattened order, so update() and ui must both go
|
||||||
/// through here.
|
/// through here.
|
||||||
pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>)> {
|
pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>)> {
|
||||||
|
release_type_groups(releases, |release| &release.release_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn release_type_groups<T>(
|
||||||
|
items: &[T],
|
||||||
|
release_type: impl Fn(&T) -> &str,
|
||||||
|
) -> Vec<(&'static str, Vec<usize>)> {
|
||||||
const GROUPS: [(&str, &str); 4] = [
|
const GROUPS: [(&str, &str); 4] = [
|
||||||
("album", "Albums"),
|
("album", "Albums"),
|
||||||
("ep", "EPs"),
|
|
||||||
("single", "Singles"),
|
("single", "Singles"),
|
||||||
|
("ep", "EPs"),
|
||||||
("compilation", "Compilations"),
|
("compilation", "Compilations"),
|
||||||
];
|
];
|
||||||
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
|
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
|
||||||
for (kind, label) in GROUPS {
|
for (kind, label) in GROUPS {
|
||||||
let indices: Vec<usize> = releases
|
let indices: Vec<usize> = items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.filter(|(_, r)| r.release_type.eq_ignore_ascii_case(kind))
|
.filter(|(_, item)| release_type(item).eq_ignore_ascii_case(kind))
|
||||||
.map(|(i, _)| i)
|
.map(|(i, _)| i)
|
||||||
.collect();
|
.collect();
|
||||||
if !indices.is_empty() {
|
if !indices.is_empty() {
|
||||||
@@ -145,7 +152,7 @@ pub fn release_groups(releases: &[ReleaseCard]) -> Vec<(&'static str, Vec<usize>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let known: Vec<usize> = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect();
|
let known: Vec<usize> = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect();
|
||||||
let other: Vec<usize> = (0..releases.len()).filter(|i| !known.contains(i)).collect();
|
let other: Vec<usize> = (0..items.len()).filter(|i| !known.contains(i)).collect();
|
||||||
if !other.is_empty() {
|
if !other.is_empty() {
|
||||||
groups.push(("Other", other));
|
groups.push(("Other", other));
|
||||||
}
|
}
|
||||||
@@ -164,10 +171,39 @@ pub fn release_display_order(releases: &[ReleaseCard]) -> Vec<usize> {
|
|||||||
/// rows, chunked by the column count. Values are display-order positions.
|
/// rows, chunked by the column count. Values are display-order positions.
|
||||||
/// Vertical cursor movement must follow these rows to match the rendering.
|
/// Vertical cursor movement must follow these rows to match the rendering.
|
||||||
pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>> {
|
pub fn release_rows(releases: &[ReleaseCard], columns: usize) -> Vec<Vec<usize>> {
|
||||||
|
grouped_release_rows(release_groups(releases), columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fed_release_groups(
|
||||||
|
releases: &[crate::federation::FedRelease],
|
||||||
|
) -> Vec<(&'static str, Vec<usize>)> {
|
||||||
|
release_type_groups(releases, |release| &release.release_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Flattened display order of federated releases (concatenated groups).
|
||||||
|
pub fn fed_release_display_order(releases: &[crate::federation::FedRelease]) -> Vec<usize> {
|
||||||
|
fed_release_groups(releases)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|(_, indices)| indices)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual tile-grid rows of the federated releases section.
|
||||||
|
pub fn fed_release_rows(
|
||||||
|
releases: &[crate::federation::FedRelease],
|
||||||
|
columns: usize,
|
||||||
|
) -> Vec<Vec<usize>> {
|
||||||
|
grouped_release_rows(fed_release_groups(releases), columns)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn grouped_release_rows(
|
||||||
|
groups: Vec<(&'static str, Vec<usize>)>,
|
||||||
|
columns: usize,
|
||||||
|
) -> Vec<Vec<usize>> {
|
||||||
let columns = columns.max(1);
|
let columns = columns.max(1);
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut position = 0;
|
let mut position = 0;
|
||||||
for (_, group) in release_groups(releases) {
|
for (_, group) in groups {
|
||||||
for chunk in group.chunks(columns) {
|
for chunk in group.chunks(columns) {
|
||||||
rows.push((position..position + chunk.len()).collect());
|
rows.push((position..position + chunk.len()).collect());
|
||||||
position += chunk.len();
|
position += chunk.len();
|
||||||
@@ -212,6 +248,8 @@ pub enum TrackSelectionScope {
|
|||||||
FedSearch,
|
FedSearch,
|
||||||
/// The tracklist of the open federated release view.
|
/// The tracklist of the open federated release view.
|
||||||
FedRelease(usize),
|
FedRelease(usize),
|
||||||
|
/// The appears-on track list of the open federated artist card.
|
||||||
|
FedAppearsOn,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Vim-like Shift-V selection for line-oriented track lists. The selected
|
/// Vim-like Shift-V selection for line-oriented track lists. The selected
|
||||||
|
|||||||
+274
-59
@@ -5,7 +5,8 @@ use crate::library::models::TrackItem;
|
|||||||
|
|
||||||
use super::state::{
|
use super::state::{
|
||||||
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
AppState, GlobalView, Loadable, OpenedPlaylist, SearchState, TILE_HEIGHT, TILE_WIDTH, Tab,
|
||||||
TrackSelectionScope, ViewMode, release_display_order, release_rows,
|
TrackSelectionScope, ViewMode, fed_release_display_order, fed_release_rows,
|
||||||
|
release_display_order, release_rows,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
|
pub const QUIT_CONFIRM_WINDOW: Duration = Duration::from_millis(1500);
|
||||||
@@ -54,6 +55,10 @@ pub enum Effect {
|
|||||||
FedDownload {
|
FedDownload {
|
||||||
tracks: Vec<crate::federation::FedTrack>,
|
tracks: Vec<crate::federation::FedTrack>,
|
||||||
},
|
},
|
||||||
|
/// Fetch richer metadata for federated tracks without downloading audio.
|
||||||
|
FedFetchTrackInfo {
|
||||||
|
tracks: Vec<(i64, crate::federation::FedTrack)>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||||
@@ -280,14 +285,25 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
let tracks = selected_tracks(state);
|
let tracks = selected_tracks(state);
|
||||||
if tracks.is_empty() {
|
if tracks.is_empty() {
|
||||||
state.status_message = Some("no track selected".into());
|
state.status_message = Some("no track selected".into());
|
||||||
|
None
|
||||||
} else {
|
} else {
|
||||||
|
let fed_tracks = tracks
|
||||||
|
.iter()
|
||||||
|
.filter(|track| track_info_needs_fed_metadata(track))
|
||||||
|
.filter_map(|track| track.fed.as_ref().map(|fed| (track.id, fed.clone())))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
state.popup = Some(super::state::Popup::TrackInfo {
|
state.popup = Some(super::state::Popup::TrackInfo {
|
||||||
tracks,
|
tracks,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
scroll: 0,
|
scroll: 0,
|
||||||
});
|
});
|
||||||
|
if fed_tracks.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
state.status_message = Some("federation: fetching track metadata…".to_string());
|
||||||
|
Some(Effect::FedFetchTrackInfo { tracks: fed_tracks })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
Action::RemoveFromQueue => remove_selected_from_queue(state),
|
Action::RemoveFromQueue => remove_selected_from_queue(state),
|
||||||
Action::QueueAddNext => queue_add(state, true),
|
Action::QueueAddNext => queue_add(state, true),
|
||||||
@@ -372,6 +388,17 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn track_info_needs_fed_metadata(track: &TrackItem) -> bool {
|
||||||
|
track.fed.is_some()
|
||||||
|
&& (track.featured_artists.is_empty()
|
||||||
|
|| track.audio_format.is_none()
|
||||||
|
|| track.audio_bitrate.is_none()
|
||||||
|
|| track.audio_sample_rate.is_none()
|
||||||
|
|| track.audio_bit_depth.is_none()
|
||||||
|
|| track.file_size_bytes.is_none()
|
||||||
|
|| track.file_path.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
/// `e`: open the metadata edit form for whatever is under the cursor —
|
/// `e`: open the metadata edit form for whatever is under the cursor —
|
||||||
/// an artist tile, a release, a track or a playlist.
|
/// an artist tile, a release, a track or a playlist.
|
||||||
fn open_edit_popup(state: &mut AppState) {
|
fn open_edit_popup(state: &mut AppState) {
|
||||||
@@ -402,11 +429,19 @@ fn open_edit_popup(state: &mut AppState) {
|
|||||||
let Some(artist) = state.global.artists.get(state.global.selected).cloned() else {
|
let Some(artist) = state.global.artists.get(state.global.selected).cloned() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
|
state.popup = Some(artist_edit_popup(
|
||||||
|
artist.id,
|
||||||
|
&artist.name,
|
||||||
|
artist.image_path,
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(artist) = selected_search_artist(state) {
|
if let Some(artist) = selected_search_artist(state) {
|
||||||
state.popup = Some(artist_edit_popup(artist.id, &artist.name, artist.image_path));
|
state.popup = Some(artist_edit_popup(
|
||||||
|
artist.id,
|
||||||
|
&artist.name,
|
||||||
|
artist.image_path,
|
||||||
|
));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Some(release) = selected_release_card(state) {
|
if let Some(release) = selected_release_card(state) {
|
||||||
@@ -433,11 +468,7 @@ fn open_edit_popup(state: &mut AppState) {
|
|||||||
state.status_message = Some("nothing to edit here".into());
|
state.status_message = Some("nothing to edit here".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn artist_edit_popup(
|
fn artist_edit_popup(id: i64, name: &str, image_path: Option<String>) -> super::state::Popup {
|
||||||
id: i64,
|
|
||||||
name: &str,
|
|
||||||
image_path: Option<String>,
|
|
||||||
) -> super::state::Popup {
|
|
||||||
use super::state::{EditField, EditTarget, Popup};
|
use super::state::{EditField, EditTarget, Popup};
|
||||||
Popup::Edit {
|
Popup::Edit {
|
||||||
target: EditTarget::Artist(id),
|
target: EditTarget::Artist(id),
|
||||||
@@ -469,7 +500,10 @@ fn track_edit_popup(track: &TrackItem) -> super::state::Popup {
|
|||||||
EditField::new("Featured", join(&track.featured_artists)),
|
EditField::new("Featured", join(&track.featured_artists)),
|
||||||
EditField::new(
|
EditField::new(
|
||||||
"Track #",
|
"Track #",
|
||||||
track.track_number.map(|n| n.to_string()).unwrap_or_default(),
|
track
|
||||||
|
.track_number
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.unwrap_or_default(),
|
||||||
),
|
),
|
||||||
EditField::new(
|
EditField::new(
|
||||||
"Disc #",
|
"Disc #",
|
||||||
@@ -527,8 +561,10 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
|||||||
fed_tracks: vec![],
|
fed_tracks: vec![],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
state.status_message =
|
state.status_message = Some(format!(
|
||||||
Some(format!("removing {} track(s) from playlist", track_ids.len()));
|
"removing {} track(s) from playlist",
|
||||||
|
track_ids.len()
|
||||||
|
));
|
||||||
return Some(Effect::RemoveFromPlaylist {
|
return Some(Effect::RemoveFromPlaylist {
|
||||||
playlist_id: opened.id,
|
playlist_id: opened.id,
|
||||||
track_ids,
|
track_ids,
|
||||||
@@ -543,14 +579,20 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
|||||||
let artist = state.global.artists.get(state.global.selected).cloned()?;
|
let artist = state.global.artists.get(state.global.selected).cloned()?;
|
||||||
state.popup = Some(Popup::ConfirmDelete {
|
state.popup = Some(Popup::ConfirmDelete {
|
||||||
target: DeleteTarget::Artist(artist.id),
|
target: DeleteTarget::Artist(artist.id),
|
||||||
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
|
label: format!(
|
||||||
|
"artist \"{}\" with all their releases and tracks",
|
||||||
|
artist.name
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
if let Some(artist) = selected_search_artist(state) {
|
if let Some(artist) = selected_search_artist(state) {
|
||||||
state.popup = Some(Popup::ConfirmDelete {
|
state.popup = Some(Popup::ConfirmDelete {
|
||||||
target: DeleteTarget::Artist(artist.id),
|
target: DeleteTarget::Artist(artist.id),
|
||||||
label: format!("artist \"{}\" with all their releases and tracks", artist.name),
|
label: format!(
|
||||||
|
"artist \"{}\" with all their releases and tracks",
|
||||||
|
artist.name
|
||||||
|
),
|
||||||
});
|
});
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -604,7 +646,9 @@ fn selected_release_card(state: &AppState) -> Option<crate::library::models::Rel
|
|||||||
let offset = cursor.checked_sub(results.artists.len())?;
|
let offset = cursor.checked_sub(results.artists.len())?;
|
||||||
results.releases.get(offset).cloned()
|
results.releases.get(offset).cloned()
|
||||||
}
|
}
|
||||||
GlobalView::Release { .. } | GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
|
GlobalView::Release { .. }
|
||||||
|
| GlobalView::FedArtist { .. }
|
||||||
|
| GlobalView::FedRelease { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,9 +723,10 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
|||||||
}
|
}
|
||||||
TrackSelectionScope::Playlist(id) => {
|
TrackSelectionScope::Playlist(id) => {
|
||||||
if let Some(opened) = &mut state.playlists.opened
|
if let Some(opened) = &mut state.playlists.opened
|
||||||
&& opened.id == *id {
|
&& opened.id == *id
|
||||||
opened.cursor = value;
|
{
|
||||||
}
|
opened.cursor = value;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
TrackSelectionScope::Queue => {
|
TrackSelectionScope::Queue => {
|
||||||
state.queue_tab.cursor = value;
|
state.queue_tab.cursor = value;
|
||||||
@@ -694,6 +739,13 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
|||||||
TrackSelectionScope::FedRelease(_) => {
|
TrackSelectionScope::FedRelease(_) => {
|
||||||
set_view_cursor(state, value + 1);
|
set_view_cursor(state, value + 1);
|
||||||
}
|
}
|
||||||
|
TrackSelectionScope::FedAppearsOn => {
|
||||||
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let release_count = card.releases.len();
|
||||||
|
set_view_cursor(state, release_count + value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -739,13 +791,19 @@ fn current_track_list_context(state: &AppState) -> Option<(TrackSelectionScope,
|
|||||||
GlobalView::FedRelease { index, cursor } => {
|
GlobalView::FedRelease { index, cursor } => {
|
||||||
let len = fed_card_release(state, *index)?.tracks.len();
|
let len = fed_card_release(state, *index)?.tracks.len();
|
||||||
let relative = cursor.checked_sub(1)?;
|
let relative = cursor.checked_sub(1)?;
|
||||||
(relative < len).then_some((
|
(relative < len).then_some((TrackSelectionScope::FedRelease(*index), relative, len))
|
||||||
TrackSelectionScope::FedRelease(*index),
|
}
|
||||||
|
GlobalView::FedArtist { cursor } => {
|
||||||
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let relative = cursor.checked_sub(card.releases.len())?;
|
||||||
|
(relative < card.appears_on.len()).then_some((
|
||||||
|
TrackSelectionScope::FedAppearsOn,
|
||||||
relative,
|
relative,
|
||||||
len,
|
card.appears_on.len(),
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
_ => None,
|
|
||||||
},
|
},
|
||||||
Tab::Playlists => {
|
Tab::Playlists => {
|
||||||
let opened = state.playlists.opened.as_ref()?;
|
let opened = state.playlists.opened.as_ref()?;
|
||||||
@@ -990,17 +1048,24 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
GlobalView::FedArtist { .. } => None,
|
GlobalView::FedArtist { cursor } => {
|
||||||
GlobalView::FedRelease { index, cursor } => {
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
let name = state.fed_artist_view.as_ref().map(|(name, _)| name.clone())?;
|
return None;
|
||||||
let release = fed_card_release(state, *index)?;
|
};
|
||||||
release
|
let index = cursor.checked_sub(card.releases.len())?;
|
||||||
.tracks
|
card.appears_on
|
||||||
.get(cursor.checked_sub(1)?)
|
.get(index)
|
||||||
.and_then(|track| fed_track_from_card(&name, release, track))
|
.and_then(|appearance| {
|
||||||
|
fed_track_from_appearance(appearance, card.own_owner.as_deref())
|
||||||
|
})
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(crate::federation::pending_track)
|
.map(crate::federation::pending_track)
|
||||||
}
|
}
|
||||||
|
GlobalView::FedRelease { index, cursor } => fed_release_tracks(state, *index)
|
||||||
|
.into_iter()
|
||||||
|
.nth(cursor.checked_sub(1)?)
|
||||||
|
.as_ref()
|
||||||
|
.map(crate::federation::pending_track),
|
||||||
},
|
},
|
||||||
Tab::Playlists => {
|
Tab::Playlists => {
|
||||||
let opened = state.playlists.opened.as_ref()?;
|
let opened = state.playlists.opened.as_ref()?;
|
||||||
@@ -1040,7 +1105,9 @@ fn selected_release_id(state: &AppState) -> Option<i64> {
|
|||||||
let offset = cursor.checked_sub(results.artists.len())?;
|
let offset = cursor.checked_sub(results.artists.len())?;
|
||||||
results.releases.get(offset).map(|r| r.id)
|
results.releases.get(offset).map(|r| r.id)
|
||||||
}
|
}
|
||||||
GlobalView::Release { .. } | GlobalView::FedArtist { .. } | GlobalView::FedRelease { .. } => None,
|
GlobalView::Release { .. }
|
||||||
|
| GlobalView::FedArtist { .. }
|
||||||
|
| GlobalView::FedRelease { .. } => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1052,6 +1119,7 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
|||||||
let count = tracks.len();
|
let count = tracks.len();
|
||||||
let title = tracks[0].title.clone();
|
let title = tracks[0].title.clone();
|
||||||
enqueue_tracks(state, tracks, next);
|
enqueue_tracks(state, tracks, next);
|
||||||
|
state.track_selection.clear();
|
||||||
state.status_message = Some(if count == 1 && next {
|
state.status_message = Some(if count == 1 && next {
|
||||||
format!("queued next: {title}")
|
format!("queued next: {title}")
|
||||||
} else if count == 1 {
|
} else if count == 1 {
|
||||||
@@ -1130,9 +1198,10 @@ pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool)
|
|||||||
player.queue.insert(insert_at + offset, track);
|
player.queue.insert(insert_at + offset, track);
|
||||||
}
|
}
|
||||||
if let Some(prefetched) = &mut player.prefetched_pos
|
if let Some(prefetched) = &mut player.prefetched_pos
|
||||||
&& insert_at <= *prefetched {
|
&& insert_at <= *prefetched
|
||||||
*prefetched += count;
|
{
|
||||||
}
|
*prefetched += count;
|
||||||
|
}
|
||||||
if insert_at <= player.queue_pos && player.current.is_some() {
|
if insert_at <= player.queue_pos && player.current.is_some() {
|
||||||
player.queue_pos += count;
|
player.queue_pos += count;
|
||||||
}
|
}
|
||||||
@@ -1310,8 +1379,14 @@ fn page_step(state: &AppState) -> isize {
|
|||||||
}
|
}
|
||||||
Some(GlobalView::Release { .. })
|
Some(GlobalView::Release { .. })
|
||||||
| Some(GlobalView::Search { .. })
|
| Some(GlobalView::Search { .. })
|
||||||
| Some(GlobalView::FedArtist { .. })
|
|
||||||
| Some(GlobalView::FedRelease { .. }) => lines,
|
| Some(GlobalView::FedRelease { .. }) => lines,
|
||||||
|
Some(GlobalView::FedArtist { cursor }) => {
|
||||||
|
let in_release_tiles = match &state.fed_artist_view {
|
||||||
|
Some((_, Loadable::Ready(card))) => *cursor < card.releases.len(),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if in_release_tiles { tile_rows } else { lines }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1492,14 +1567,39 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
}
|
}
|
||||||
Some(GlobalView::FedArtist { cursor }) => {
|
Some(GlobalView::FedArtist { cursor }) => {
|
||||||
// A flat tile grid of releases.
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
let total = fed_card_len(state) as isize;
|
return;
|
||||||
|
};
|
||||||
|
let releases = card.releases.len();
|
||||||
|
let appears_on = card.appears_on.len();
|
||||||
|
let total = (releases + appears_on) as isize;
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let columns = grid_columns() as isize;
|
let in_release_tiles = cursor < releases;
|
||||||
let step = if dx != 0 { dx } else { dy * columns };
|
let next = if !in_release_tiles {
|
||||||
let next = (cursor as isize + step).clamp(0, total - 1);
|
(cursor as isize + dy).clamp(0, total - 1) as usize
|
||||||
|
} else {
|
||||||
|
let rows = fed_release_rows(&card.releases, grid_columns());
|
||||||
|
let (row, column) = rows
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.find_map(|(r, items)| items.iter().position(|p| *p == cursor).map(|c| (r, c)))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
if dx != 0 {
|
||||||
|
(cursor as isize + dx).clamp(0, releases as isize - 1) as usize
|
||||||
|
} else {
|
||||||
|
let target = row as isize + dy;
|
||||||
|
if target < 0 {
|
||||||
|
cursor
|
||||||
|
} else if target as usize >= rows.len() {
|
||||||
|
if appears_on > 0 { releases } else { cursor }
|
||||||
|
} else {
|
||||||
|
let items = &rows[target as usize];
|
||||||
|
items[column.min(items.len() - 1)]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
set_view_cursor(state, next as usize);
|
set_view_cursor(state, next as usize);
|
||||||
state.track_selection.clear();
|
state.track_selection.clear();
|
||||||
}
|
}
|
||||||
@@ -1513,10 +1613,10 @@ fn move_selection(state: &mut AppState, dx: isize, dy: isize) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Selectable tiles of the open federated artist card (its releases).
|
/// Selectable rows of the open federated artist card: releases, then appearances.
|
||||||
pub(crate) fn fed_card_len(state: &AppState) -> usize {
|
pub(crate) fn fed_card_len(state: &AppState) -> usize {
|
||||||
match &state.fed_artist_view {
|
match &state.fed_artist_view {
|
||||||
Some((_, Loadable::Ready(card))) => card.releases.len(),
|
Some((_, Loadable::Ready(card))) => card.releases.len() + card.appears_on.len(),
|
||||||
_ => 0,
|
_ => 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1607,10 +1707,11 @@ fn jump_selection(state: &mut AppState, first: bool) {
|
|||||||
if first {
|
if first {
|
||||||
let level = super::state::LOG_LEVELS[state.logs.level_index];
|
let level = super::state::LOG_LEVELS[state.logs.level_index];
|
||||||
if let Some(buffer) = crate::config::logging::buffer()
|
if let Some(buffer) = crate::config::logging::buffer()
|
||||||
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
|
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN)
|
||||||
state.logs.selected_seq = Some(seq);
|
{
|
||||||
state.logs.follow = false;
|
state.logs.selected_seq = Some(seq);
|
||||||
}
|
state.logs.follow = false;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
state.logs.follow = true;
|
state.logs.follow = true;
|
||||||
state.logs.selected_seq = None;
|
state.logs.selected_seq = None;
|
||||||
@@ -1787,18 +1888,42 @@ fn select_current(state: &mut AppState) -> Option<Effect> {
|
|||||||
},
|
},
|
||||||
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
Some(GlobalView::FedArtist { cursor }) => match &state.fed_artist_view {
|
||||||
Some((_, Loadable::Ready(card))) if cursor < card.releases.len() => {
|
Some((_, Loadable::Ready(card))) if cursor < card.releases.len() => {
|
||||||
|
let order = fed_release_display_order(&card.releases);
|
||||||
|
let Some(&release_index) = order.get(cursor) else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
// Focus starts on the first track; Up from it reaches the
|
// Focus starts on the first track; Up from it reaches the
|
||||||
// download-release button (row 0).
|
// download-release button (row 0).
|
||||||
let start = if card.releases[cursor].tracks.is_empty() {
|
let start = if card.releases[release_index].tracks.is_empty() {
|
||||||
0
|
0
|
||||||
} else {
|
} else {
|
||||||
1
|
1
|
||||||
};
|
};
|
||||||
Outcome::Push(GlobalView::FedRelease {
|
Outcome::Push(GlobalView::FedRelease {
|
||||||
index: cursor,
|
index: release_index,
|
||||||
cursor: start,
|
cursor: start,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
Some((_, Loadable::Ready(card))) => {
|
||||||
|
let start = cursor - card.releases.len();
|
||||||
|
if card.appears_on.get(start).is_none() {
|
||||||
|
Outcome::Nothing
|
||||||
|
} else {
|
||||||
|
let tracks: Vec<_> = card
|
||||||
|
.appears_on
|
||||||
|
.iter()
|
||||||
|
.filter_map(|appearance| {
|
||||||
|
fed_track_from_appearance(appearance, card.own_owner.as_deref())
|
||||||
|
})
|
||||||
|
.map(|fed| crate::federation::pending_track(&fed))
|
||||||
|
.collect();
|
||||||
|
if tracks.is_empty() {
|
||||||
|
Outcome::Nothing
|
||||||
|
} else {
|
||||||
|
Outcome::Play { tracks, start }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => Outcome::Nothing,
|
_ => Outcome::Nothing,
|
||||||
},
|
},
|
||||||
Some(GlobalView::FedRelease { index, cursor }) => match fed_card_release(state, index) {
|
Some(GlobalView::FedRelease { index, cursor }) => match fed_card_release(state, index) {
|
||||||
@@ -1882,7 +2007,17 @@ pub(crate) fn fed_release_tracks(
|
|||||||
release
|
release
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|track| fed_track_from_card(name, release, track))
|
.filter_map(|track| fed_track_from_card(name, release, track, card.own_owner.as_deref()))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn fed_appears_on_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
|
||||||
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
card.appears_on
|
||||||
|
.iter()
|
||||||
|
.filter_map(|appearance| fed_track_from_appearance(appearance, card.own_owner.as_deref()))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1904,7 +2039,19 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
|||||||
TrackSelectionScope::FedRelease(index) => {
|
TrackSelectionScope::FedRelease(index) => {
|
||||||
let all = fed_release_tracks(state, index);
|
let all = fed_release_tracks(state, index);
|
||||||
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
|
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
|
||||||
return indices.into_iter().filter_map(|i| all.get(i).cloned()).collect();
|
return indices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|i| all.get(i).cloned())
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrackSelectionScope::FedAppearsOn => {
|
||||||
|
let all = fed_appears_on_tracks(state);
|
||||||
|
if let Some(indices) = state.track_selection.indices(&scope, all.len()) {
|
||||||
|
return indices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|i| all.get(i).cloned())
|
||||||
|
.collect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -1926,6 +2073,16 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
|||||||
.and_then(|i| fed_release_tracks(state, *index).into_iter().nth(i))
|
.and_then(|i| fed_release_tracks(state, *index).into_iter().nth(i))
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.collect(),
|
.collect(),
|
||||||
|
Some(GlobalView::FedArtist { cursor }) => {
|
||||||
|
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
cursor
|
||||||
|
.checked_sub(card.releases.len())
|
||||||
|
.and_then(|i| fed_appears_on_tracks(state).into_iter().nth(i))
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1970,22 +2127,73 @@ fn fed_track_from_card(
|
|||||||
artist: &str,
|
artist: &str,
|
||||||
release: &crate::federation::FedRelease,
|
release: &crate::federation::FedRelease,
|
||||||
track: &crate::federation::FedCardTrack,
|
track: &crate::federation::FedCardTrack,
|
||||||
|
own_owner: Option<&str>,
|
||||||
) -> Option<crate::federation::FedTrack> {
|
) -> Option<crate::federation::FedTrack> {
|
||||||
let (owner, item_id) = track.sources.first()?.clone();
|
let (owner, item_id) = track.sources.first()?.clone();
|
||||||
|
let own = own_owner == Some(owner.as_str());
|
||||||
Some(crate::federation::FedTrack {
|
Some(crate::federation::FedTrack {
|
||||||
item_id,
|
item_id,
|
||||||
owner,
|
owner,
|
||||||
own: false,
|
own,
|
||||||
title: track.title.clone(),
|
title: track.title.clone(),
|
||||||
artist_names: vec![artist.to_string()],
|
artist_names: fed_card_main_artist_names(track, Some(artist)),
|
||||||
|
featured_artist_names: fed_card_featured_artist_names(track),
|
||||||
year: release.year,
|
year: release.year,
|
||||||
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
|
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
|
||||||
|
content_id: track.content_id.clone(),
|
||||||
release_title: Some(release.title.clone()),
|
release_title: Some(release.title.clone()),
|
||||||
track_number: track.track_number,
|
track_number: track.track_number,
|
||||||
disc_number: track.disc_number,
|
disc_number: track.disc_number,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn fed_track_from_appearance(
|
||||||
|
appearance: &crate::federation::FedAppearsOn,
|
||||||
|
own_owner: Option<&str>,
|
||||||
|
) -> Option<crate::federation::FedTrack> {
|
||||||
|
let (owner, item_id) = appearance.track.sources.first()?.clone();
|
||||||
|
let own = own_owner == Some(owner.as_str());
|
||||||
|
Some(crate::federation::FedTrack {
|
||||||
|
item_id,
|
||||||
|
owner,
|
||||||
|
own,
|
||||||
|
title: appearance.track.title.clone(),
|
||||||
|
artist_names: fed_card_main_artist_names(&appearance.track, None),
|
||||||
|
featured_artist_names: fed_card_featured_artist_names(&appearance.track),
|
||||||
|
year: appearance.year,
|
||||||
|
duration_seconds: appearance.track.duration_seconds.map(|d| d.round() as i64),
|
||||||
|
content_id: appearance.track.content_id.clone(),
|
||||||
|
release_title: (!appearance.release_title.is_empty())
|
||||||
|
.then(|| appearance.release_title.clone()),
|
||||||
|
track_number: appearance.track.track_number,
|
||||||
|
disc_number: appearance.track.disc_number,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fed_card_main_artist_names(
|
||||||
|
track: &crate::federation::FedCardTrack,
|
||||||
|
fallback: Option<&str>,
|
||||||
|
) -> Vec<String> {
|
||||||
|
if track.artists.is_empty() {
|
||||||
|
fallback.into_iter().map(str::to_string).collect()
|
||||||
|
} else {
|
||||||
|
track.artists.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Vec<String> {
|
||||||
|
let mut names: Vec<String> = Vec::new();
|
||||||
|
for artist in &track.featured_artists {
|
||||||
|
if !names
|
||||||
|
.iter()
|
||||||
|
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
|
||||||
|
{
|
||||||
|
names.push(artist.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
/// Enter on the Federation tab: toggle switches, open text inputs, run
|
/// Enter on the Federation tab: toggle switches, open text inputs, run
|
||||||
/// one-shot operations. The heavy lifting happens in perform_effect().
|
/// one-shot operations. The heavy lifting happens in perform_effect().
|
||||||
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
fn federation_select(state: &mut AppState) -> Option<Effect> {
|
||||||
@@ -2071,12 +2279,13 @@ fn go_back(state: &mut AppState) {
|
|||||||
// Esc on a view opened by Shift-J from another tab goes back to
|
// Esc on a view opened by Shift-J from another tab goes back to
|
||||||
// that tab, not down the Global stack.
|
// that tab, not down the Global stack.
|
||||||
if let Some((origin, depth)) = state.jump_origin
|
if let Some((origin, depth)) = state.jump_origin
|
||||||
&& state.global.stack.len() == depth + 1 {
|
&& state.global.stack.len() == depth + 1
|
||||||
state.global.stack.pop();
|
{
|
||||||
state.jump_origin = None;
|
state.global.stack.pop();
|
||||||
state.active_tab = origin;
|
state.jump_origin = None;
|
||||||
return;
|
state.active_tab = origin;
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
if let Some(popped) = state.global.stack.pop() {
|
if let Some(popped) = state.global.stack.pop() {
|
||||||
if matches!(popped, GlobalView::Search { .. }) {
|
if matches!(popped, GlobalView::Search { .. }) {
|
||||||
state.search = SearchState::default();
|
state.search = SearchState::default();
|
||||||
@@ -2161,6 +2370,7 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_path: None,
|
cover_path: None,
|
||||||
file_path: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
|
content_id: None,
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
@@ -2370,8 +2580,8 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn queue_advances_and_respects_repeat() {
|
fn queue_advances_and_respects_repeat() {
|
||||||
use crate::library::models::TrackItem;
|
|
||||||
use crate::app::state::RepeatMode;
|
use crate::app::state::RepeatMode;
|
||||||
|
use crate::library::models::TrackItem;
|
||||||
|
|
||||||
let track = |id: i64| TrackItem {
|
let track = |id: i64| TrackItem {
|
||||||
id,
|
id,
|
||||||
@@ -2386,6 +2596,7 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_path: None,
|
cover_path: None,
|
||||||
file_path: format!("/api/player/stream/{id}"),
|
file_path: format!("/api/player/stream/{id}"),
|
||||||
|
content_id: None,
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
@@ -2450,6 +2661,7 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_path: None,
|
cover_path: None,
|
||||||
file_path: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
|
content_id: None,
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
@@ -2541,6 +2753,7 @@ mod tests {
|
|||||||
assert_eq!(update(&mut state, Action::QueueAddLast), None,);
|
assert_eq!(update(&mut state, Action::QueueAddLast), None,);
|
||||||
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||||
assert_eq!(queued, vec![1, 2]);
|
assert_eq!(queued, vec![1, 2]);
|
||||||
|
assert!(!state.track_selection.is_active());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2615,6 +2828,7 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_path: None,
|
cover_path: None,
|
||||||
file_path: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
|
content_id: None,
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
@@ -2661,6 +2875,7 @@ mod tests {
|
|||||||
release_year: None,
|
release_year: None,
|
||||||
cover_path: None,
|
cover_path: None,
|
||||||
file_path: format!("/s/{id}"),
|
file_path: format!("/s/{id}"),
|
||||||
|
content_id: None,
|
||||||
audio_format: None,
|
audio_format: None,
|
||||||
audio_bitrate: None,
|
audio_bitrate: None,
|
||||||
audio_sample_rate: None,
|
audio_sample_rate: None,
|
||||||
|
|||||||
+27
-23
@@ -82,20 +82,21 @@ impl Keymap {
|
|||||||
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
|
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
|
||||||
let mut warning = None;
|
let mut warning = None;
|
||||||
if let Some(path) = user_keymap_path()
|
if let Some(path) = user_keymap_path()
|
||||||
&& path.exists() {
|
&& path.exists()
|
||||||
match fs::read_to_string(&path)
|
{
|
||||||
.map_err(anyhow::Error::from)
|
match fs::read_to_string(&path)
|
||||||
.and_then(|text| parse_bindings(&text))
|
.map_err(anyhow::Error::from)
|
||||||
{
|
.and_then(|text| parse_bindings(&text))
|
||||||
Ok(user) => merge(&mut bindings, user),
|
{
|
||||||
Err(err) => {
|
Ok(user) => merge(&mut bindings, user),
|
||||||
warning = Some(format!(
|
Err(err) => {
|
||||||
"{} ignored: {err:#}; using default keybindings",
|
warning = Some(format!(
|
||||||
path.display()
|
"{} ignored: {err:#}; using default keybindings",
|
||||||
));
|
path.display()
|
||||||
}
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
let keymap = Self {
|
let keymap = Self {
|
||||||
bindings,
|
bindings,
|
||||||
pending: Vec::new(),
|
pending: Vec::new(),
|
||||||
@@ -219,9 +220,11 @@ enum Lookup {
|
|||||||
/// symbols drop it so a "?" binding matches everywhere.
|
/// symbols drop it so a "?" binding matches everywhere.
|
||||||
fn normalize(key: KeyCombination) -> KeyCombination {
|
fn normalize(key: KeyCombination) -> KeyCombination {
|
||||||
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes
|
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes
|
||||||
&& !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
|
&& !c.is_alphabetic()
|
||||||
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
|
&& key.modifiers.contains(KeyModifiers::SHIFT)
|
||||||
}
|
{
|
||||||
|
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
|
||||||
|
}
|
||||||
key
|
key
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,14 +305,15 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
|
|||||||
// bindings) are built directly.
|
// bindings) are built directly.
|
||||||
let mut chars = chord.chars();
|
let mut chars = chord.chars();
|
||||||
if let (Some(c), None) = (chars.next(), chars.next())
|
if let (Some(c), None) = (chars.next(), chars.next())
|
||||||
&& !c.is_ascii() {
|
&& !c.is_ascii()
|
||||||
let modifiers = if c.is_uppercase() {
|
{
|
||||||
KeyModifiers::SHIFT
|
let modifiers = if c.is_uppercase() {
|
||||||
} else {
|
KeyModifiers::SHIFT
|
||||||
KeyModifiers::NONE
|
} else {
|
||||||
};
|
KeyModifiers::NONE
|
||||||
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
|
};
|
||||||
}
|
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
|
||||||
|
}
|
||||||
KeyCombination::from_str(chord)
|
KeyCombination::from_str(chord)
|
||||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||||
.map(normalize)
|
.map(normalize)
|
||||||
|
|||||||
+101
-19
@@ -2,7 +2,8 @@
|
|||||||
//!
|
//!
|
||||||
//! One byte stream per request: the requester sends one JSON line
|
//! One byte stream per request: the requester sends one JSON line
|
||||||
//! ([`AudioRequest`]) and receives one JSON line ([`AudioResponseHeader`])
|
//! ([`AudioRequest`]) and receives one JSON line ([`AudioResponseHeader`])
|
||||||
//! followed by the raw file bytes from the requested offset.
|
//! followed by the raw file bytes from the requested offset, unless the
|
||||||
|
//! requester asked for metadata only.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -32,6 +33,11 @@ struct AudioRequest {
|
|||||||
/// older peers in both directions.
|
/// older peers in both directions.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
want_cover: bool,
|
want_cover: bool,
|
||||||
|
/// Ask only for the response header with metadata and file facts.
|
||||||
|
/// Older peers ignore the field and may start streaming audio; the
|
||||||
|
/// requester simply drops the stream after reading the header.
|
||||||
|
#[serde(default)]
|
||||||
|
metadata_only: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -115,6 +121,16 @@ pub struct TrackMetadata {
|
|||||||
pub track_number: Option<i32>,
|
pub track_number: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disc_number: Option<i32>,
|
pub disc_number: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub duration_seconds: Option<f64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio_format: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio_bitrate: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio_sample_rate: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub audio_bit_depth: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn hex_encode(bytes: &[u8]) -> String {
|
pub fn hex_encode(bytes: &[u8]) -> String {
|
||||||
@@ -167,6 +183,13 @@ fn extension_for_mime(mime: &str) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort audio format label for metadata previews, from the owner's
|
||||||
|
/// response mime type.
|
||||||
|
pub fn format_for_mime(mime: &str) -> Option<String> {
|
||||||
|
let extension = extension_for_mime(mime);
|
||||||
|
(extension != "bin").then(|| extension.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
/// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`].
|
/// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`].
|
||||||
pub(super) async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
pub(super) async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||||
let mut line = Vec::new();
|
let mut line = Vec::new();
|
||||||
@@ -186,7 +209,10 @@ pub(super) async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Ve
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn write_line<W: AsyncWriteExt + Unpin>(writer: &mut W, value: &impl Serialize) -> Result<()> {
|
async fn write_line<W: AsyncWriteExt + Unpin>(
|
||||||
|
writer: &mut W,
|
||||||
|
value: &impl Serialize,
|
||||||
|
) -> Result<()> {
|
||||||
let mut line = serde_json::to_vec(value)?;
|
let mut line = serde_json::to_vec(value)?;
|
||||||
line.push(b'\n');
|
line.push(b'\n');
|
||||||
writer.write_all(&line).await?;
|
writer.write_all(&line).await?;
|
||||||
@@ -208,6 +234,50 @@ pub struct Downloaded {
|
|||||||
pub artist_image: Option<(Vec<u8>, &'static str)>,
|
pub artist_image: Option<(Vec<u8>, &'static str)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Header-only metadata fetched without downloading the audio bytes.
|
||||||
|
pub struct FetchedMetadata {
|
||||||
|
pub mime_type: String,
|
||||||
|
pub total_size: u64,
|
||||||
|
pub metadata: Option<TrackMetadata>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches the owner's response header for a track and closes the stream
|
||||||
|
/// before audio bytes are read.
|
||||||
|
pub async fn fetch_metadata(
|
||||||
|
service: &MusicDhtService,
|
||||||
|
owner: EndpointId,
|
||||||
|
item_id_hex: &str,
|
||||||
|
) -> Result<FetchedMetadata> {
|
||||||
|
let mut stream = service
|
||||||
|
.open_stream(owner, AUDIO_ALPN)
|
||||||
|
.await
|
||||||
|
.map_err(|err| anyhow::anyhow!("cannot reach the owner peer: {err}"))?;
|
||||||
|
write_line(
|
||||||
|
&mut stream.send,
|
||||||
|
&AudioRequest {
|
||||||
|
item_id: item_id_hex.to_string(),
|
||||||
|
offset: 0,
|
||||||
|
want_cover: false,
|
||||||
|
metadata_only: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
stream.send.finish()?;
|
||||||
|
let header: AudioResponseHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)
|
||||||
|
.context("malformed response header")?;
|
||||||
|
if !header.ok {
|
||||||
|
anyhow::bail!(
|
||||||
|
"peer refused the metadata: {}",
|
||||||
|
header.error.unwrap_or_else(|| "unknown error".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(FetchedMetadata {
|
||||||
|
mime_type: header.mime_type,
|
||||||
|
total_size: header.total_size,
|
||||||
|
metadata: header.metadata,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Downloads a whole track (with metadata and cover art) from `owner` into
|
/// Downloads a whole track (with metadata and cover art) from `owner` into
|
||||||
/// `dir/<stem>.<ext>`. An already complete cached audio file is reused;
|
/// `dir/<stem>.<ext>`. An already complete cached audio file is reused;
|
||||||
/// the metadata and cover still come fresh from the header.
|
/// the metadata and cover still come fresh from the header.
|
||||||
@@ -228,6 +298,7 @@ pub async fn download_track(
|
|||||||
item_id: item_id_hex.to_string(),
|
item_id: item_id_hex.to_string(),
|
||||||
offset: 0,
|
offset: 0,
|
||||||
want_cover: true,
|
want_cover: true,
|
||||||
|
metadata_only: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -243,22 +314,23 @@ pub async fn download_track(
|
|||||||
|
|
||||||
// The image segments precede the audio bytes and are read regardless of
|
// The image segments precede the audio bytes and are read regardless of
|
||||||
// the cache state — they sit first in the stream.
|
// the cache state — they sit first in the stream.
|
||||||
let mut read_image = async |size: u64, mime: &str, what: &str| -> Result<Option<(Vec<u8>, &'static str)>> {
|
let mut read_image =
|
||||||
if size == 0 {
|
async |size: u64, mime: &str, what: &str| -> Result<Option<(Vec<u8>, &'static str)>> {
|
||||||
return Ok(None);
|
if size == 0 {
|
||||||
}
|
return Ok(None);
|
||||||
anyhow::ensure!(
|
}
|
||||||
size <= MAX_COVER_BYTES,
|
anyhow::ensure!(
|
||||||
"{what} of {size} bytes exceeds the {MAX_COVER_BYTES} byte limit"
|
size <= MAX_COVER_BYTES,
|
||||||
);
|
"{what} of {size} bytes exceeds the {MAX_COVER_BYTES} byte limit"
|
||||||
let mut bytes = vec![0u8; size as usize];
|
);
|
||||||
stream
|
let mut bytes = vec![0u8; size as usize];
|
||||||
.recv
|
stream
|
||||||
.read_exact(&mut bytes)
|
.recv
|
||||||
.await
|
.read_exact(&mut bytes)
|
||||||
.with_context(|| format!("stream ended inside the {what} segment"))?;
|
.await
|
||||||
Ok(Some((bytes, image_extension(mime))))
|
.with_context(|| format!("stream ended inside the {what} segment"))?;
|
||||||
};
|
Ok(Some((bytes, image_extension(mime))))
|
||||||
|
};
|
||||||
let cover = read_image(header.cover_size, &header.cover_mime, "cover").await?;
|
let cover = read_image(header.cover_size, &header.cover_mime, "cover").await?;
|
||||||
let artist_image = read_image(
|
let artist_image = read_image(
|
||||||
header.artist_image_size,
|
header.artist_image_size,
|
||||||
@@ -373,6 +445,11 @@ fn resolve_for_serving(
|
|||||||
year: track.release_year,
|
year: track.release_year,
|
||||||
track_number: track.track_number,
|
track_number: track.track_number,
|
||||||
disc_number: track.disc_number,
|
disc_number: track.disc_number,
|
||||||
|
duration_seconds: Some(track.duration_seconds),
|
||||||
|
audio_format: track.audio_format.clone(),
|
||||||
|
audio_bitrate: track.audio_bitrate,
|
||||||
|
audio_sample_rate: track.audio_sample_rate,
|
||||||
|
audio_bit_depth: track.audio_bit_depth,
|
||||||
};
|
};
|
||||||
let artist_image_path = track
|
let artist_image_path = track
|
||||||
.artists
|
.artists
|
||||||
@@ -441,7 +518,7 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Images ride between the header and the audio, when asked for.
|
// Images ride between the header and the audio, when asked for.
|
||||||
let (cover, artist_image) = if request.want_cover {
|
let (cover, artist_image) = if request.want_cover && !request.metadata_only {
|
||||||
(
|
(
|
||||||
load_cover(served.cover_path.as_deref()).await,
|
load_cover(served.cover_path.as_deref()).await,
|
||||||
load_cover(served.artist_image_path.as_deref()).await,
|
load_cover(served.artist_image_path.as_deref()).await,
|
||||||
@@ -473,6 +550,11 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
if request.metadata_only {
|
||||||
|
stream.send.finish()?;
|
||||||
|
let _ = stream.send.stopped().await;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
if let Some((bytes, _)) = &cover {
|
if let Some((bytes, _)) = &cover {
|
||||||
stream.send.write_all(bytes).await?;
|
stream.send.write_all(bytes).await?;
|
||||||
}
|
}
|
||||||
|
|||||||
+244
-37
@@ -1,6 +1,6 @@
|
|||||||
//! The peer catalog protocol: one peer asks another for its library slice
|
//! The peer catalog protocol: one peer asks another for its library slice
|
||||||
//! of a single artist (releases with full tracklists), used to assemble a
|
//! of a single artist (releases with full tracklists plus featured
|
||||||
//! federated artist card.
|
//! appearances), used to assemble a federated artist card.
|
||||||
//!
|
//!
|
||||||
//! Wire shape on the `furumi-fd/catalog/1` ALPN: the requester sends one
|
//! Wire shape on the `furumi-fd/catalog/1` ALPN: the requester sends one
|
||||||
//! JSON line ([`CatalogRequest`]) and finishes; the owner answers with one
|
//! JSON line ([`CatalogRequest`]) and finishes; the owner answers with one
|
||||||
@@ -67,6 +67,10 @@ pub struct CatalogArtist {
|
|||||||
pub name: String,
|
pub name: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub releases: Vec<CatalogRelease>,
|
pub releases: Vec<CatalogRelease>,
|
||||||
|
/// Tracks where the requested artist is featured instead of being a
|
||||||
|
/// release/main artist.
|
||||||
|
#[serde(default)]
|
||||||
|
pub appears_on: Vec<CatalogAppearance>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
@@ -81,16 +85,35 @@ pub struct CatalogRelease {
|
|||||||
pub tracks: Vec<CatalogTrack>,
|
pub tracks: Vec<CatalogTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CatalogAppearance {
|
||||||
|
#[serde(default)]
|
||||||
|
pub release_title: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub release_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub year: Option<i32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub track: CatalogTrack,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct CatalogTrack {
|
pub struct CatalogTrack {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub artists: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub featured_artists: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
pub track_number: Option<i32>,
|
pub track_number: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub disc_number: Option<i32>,
|
pub disc_number: Option<i32>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub duration_seconds: Option<f64>,
|
pub duration_seconds: Option<f64>,
|
||||||
|
/// Stable audio content id (`b3:<64 hex>`) when known.
|
||||||
|
#[serde(default)]
|
||||||
|
pub content_id: Option<String>,
|
||||||
/// Hex DHT item id — the key the audio is requested by (FedPlay).
|
/// Hex DHT item id — the key the audio is requested by (FedPlay).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub item_id: String,
|
pub item_id: String,
|
||||||
@@ -159,7 +182,10 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
|
|||||||
error: Some(format!("unknown request kind '{other}'")),
|
error: Some(format!("unknown request kind '{other}'")),
|
||||||
artist: None,
|
artist: None,
|
||||||
};
|
};
|
||||||
stream.send.write_all(&serde_json::to_vec(&response)?).await?;
|
stream
|
||||||
|
.send
|
||||||
|
.write_all(&serde_json::to_vec(&response)?)
|
||||||
|
.await?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stream.send.finish()?;
|
stream.send.finish()?;
|
||||||
@@ -218,13 +244,29 @@ fn image_mime_by_path(path: &str) -> &'static str {
|
|||||||
|
|
||||||
/// Builds this instance's library slice for `artist`.
|
/// Builds this instance's library slice for `artist`.
|
||||||
fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result<CatalogResponse> {
|
fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result<CatalogResponse> {
|
||||||
let Some(artist_id) = library.artist_id_by_name(artist)? else {
|
let Some(artist) = build_catalog_artist(library, own, artist)? else {
|
||||||
return Ok(CatalogResponse {
|
return Ok(CatalogResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some("artist not found in the library".to_string()),
|
error: Some("artist not found in the library".to_string()),
|
||||||
artist: None,
|
artist: None,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
Ok(CatalogResponse {
|
||||||
|
ok: true,
|
||||||
|
error: None,
|
||||||
|
artist: Some(artist),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the successful payload for this instance's library slice.
|
||||||
|
pub(crate) fn build_catalog_artist(
|
||||||
|
library: &Library,
|
||||||
|
own: EndpointId,
|
||||||
|
artist: &str,
|
||||||
|
) -> Result<Option<CatalogArtist>> {
|
||||||
|
let Some(artist_id) = library.artist_id_by_name(artist)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
let detail = library.artist(artist_id)?;
|
let detail = library.artist(artist_id)?;
|
||||||
let item_id_of = |track_id: i64| -> String {
|
let item_id_of = |track_id: i64| -> String {
|
||||||
super::audio::hex_encode(
|
super::audio::hex_encode(
|
||||||
@@ -242,25 +284,46 @@ fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result<Cat
|
|||||||
tracks: release
|
tracks: release
|
||||||
.tracks
|
.tracks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|track| CatalogTrack {
|
.map(|track| catalog_track(track, item_id_of(track.id)))
|
||||||
title: track.title.clone(),
|
|
||||||
track_number: track.track_number,
|
|
||||||
disc_number: track.disc_number,
|
|
||||||
duration_seconds: (track.duration_seconds > 0.0)
|
|
||||||
.then_some(track.duration_seconds),
|
|
||||||
item_id: item_id_of(track.id),
|
|
||||||
})
|
|
||||||
.collect(),
|
.collect(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(CatalogResponse {
|
let mut appears_on = Vec::new();
|
||||||
ok: true,
|
for track in &detail.featured_tracks {
|
||||||
error: None,
|
let release = library.release(track.release_id)?;
|
||||||
artist: Some(CatalogArtist {
|
appears_on.push(CatalogAppearance {
|
||||||
name: detail.name,
|
release_title: track.release_title.clone(),
|
||||||
releases,
|
release_type: release.release_type,
|
||||||
}),
|
year: track.release_year,
|
||||||
})
|
track: catalog_track(track, item_id_of(track.id)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(Some(CatalogArtist {
|
||||||
|
name: detail.name,
|
||||||
|
releases,
|
||||||
|
appears_on,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn catalog_track(track: &crate::library::models::TrackItem, item_id: String) -> CatalogTrack {
|
||||||
|
CatalogTrack {
|
||||||
|
title: track.title.clone(),
|
||||||
|
artists: track
|
||||||
|
.artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| artist.name.clone())
|
||||||
|
.collect(),
|
||||||
|
featured_artists: track
|
||||||
|
.featured_artists
|
||||||
|
.iter()
|
||||||
|
.map(|artist| artist.name.clone())
|
||||||
|
.collect(),
|
||||||
|
track_number: track.track_number,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds),
|
||||||
|
content_id: track.content_id.clone(),
|
||||||
|
item_id,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -383,6 +446,9 @@ impl tokio::io::AsyncRead for StreamReader<'_> {
|
|||||||
pub struct FedArtistCard {
|
pub struct FedArtistCard {
|
||||||
#[allow(dead_code, reason = "the open card is keyed by name in AppState")]
|
#[allow(dead_code, reason = "the open card is keyed by name in AppState")]
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
/// This node's endpoint id, used to keep own tracks local when an open
|
||||||
|
/// card includes the local catalog alongside remote peers.
|
||||||
|
pub own_owner: Option<String>,
|
||||||
/// Peers whose catalogs contributed to the card.
|
/// Peers whose catalogs contributed to the card.
|
||||||
pub peers: usize,
|
pub peers: usize,
|
||||||
/// Every contributing peer (hex ids) — where images are fetched from.
|
/// Every contributing peer (hex ids) — where images are fetched from.
|
||||||
@@ -390,6 +456,7 @@ pub struct FedArtistCard {
|
|||||||
/// Local cache path of the artist image, streamed from a peer.
|
/// Local cache path of the artist image, streamed from a peer.
|
||||||
pub image_path: Option<String>,
|
pub image_path: Option<String>,
|
||||||
pub releases: Vec<FedRelease>,
|
pub releases: Vec<FedRelease>,
|
||||||
|
pub appears_on: Vec<FedAppearsOn>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -404,12 +471,23 @@ pub struct FedRelease {
|
|||||||
pub tracks: Vec<FedCardTrack>,
|
pub tracks: Vec<FedCardTrack>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct FedAppearsOn {
|
||||||
|
pub release_title: String,
|
||||||
|
pub release_type: String,
|
||||||
|
pub year: Option<i32>,
|
||||||
|
pub track: FedCardTrack,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct FedCardTrack {
|
pub struct FedCardTrack {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
pub artists: Vec<String>,
|
||||||
|
pub featured_artists: Vec<String>,
|
||||||
pub track_number: Option<i32>,
|
pub track_number: Option<i32>,
|
||||||
pub disc_number: Option<i32>,
|
pub disc_number: Option<i32>,
|
||||||
pub duration_seconds: Option<f64>,
|
pub duration_seconds: Option<f64>,
|
||||||
|
pub content_id: Option<String>,
|
||||||
/// Every peer that can serve this track: (owner hex, item id hex).
|
/// Every peer that can serve this track: (owner hex, item id hex).
|
||||||
/// Duplicates collapse into one row; all sources stay playable.
|
/// Duplicates collapse into one row; all sources stay playable.
|
||||||
pub sources: Vec<(String, String)>,
|
pub sources: Vec<(String, String)>,
|
||||||
@@ -422,6 +500,8 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed
|
|||||||
let peers = catalogs.len();
|
let peers = catalogs.len();
|
||||||
let mut releases: Vec<FedRelease> = Vec::new();
|
let mut releases: Vec<FedRelease> = Vec::new();
|
||||||
let mut release_index: HashMap<String, usize> = HashMap::new();
|
let mut release_index: HashMap<String, usize> = HashMap::new();
|
||||||
|
let mut appears_on: Vec<FedAppearsOn> = Vec::new();
|
||||||
|
let mut appearance_index: HashMap<String, usize> = HashMap::new();
|
||||||
|
|
||||||
let mut card_owners: Vec<String> = Vec::new();
|
let mut card_owners: Vec<String> = Vec::new();
|
||||||
for (owner_hex, catalog) in catalogs {
|
for (owner_hex, catalog) in catalogs {
|
||||||
@@ -463,31 +543,67 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed
|
|||||||
});
|
});
|
||||||
match existing {
|
match existing {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
if t.track_number.is_none() {
|
merge_card_track(t, &owner_hex, track);
|
||||||
t.track_number = track.track_number;
|
|
||||||
}
|
|
||||||
if t.duration_seconds.is_none() {
|
|
||||||
t.duration_seconds = track.duration_seconds;
|
|
||||||
}
|
|
||||||
t.sources.push((owner_hex.clone(), track.item_id));
|
|
||||||
}
|
}
|
||||||
None => merged.tracks.push(FedCardTrack {
|
None => merged.tracks.push(card_track(owner_hex.clone(), track)),
|
||||||
title: track.title,
|
|
||||||
track_number: track.track_number,
|
|
||||||
disc_number: track.disc_number,
|
|
||||||
duration_seconds: track.duration_seconds,
|
|
||||||
sources: vec![(owner_hex.clone(), track.item_id)],
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for appearance in catalog.appears_on {
|
||||||
|
if appearance.track.item_id.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let key = format!(
|
||||||
|
"{}:{}:{:?}",
|
||||||
|
music_dht::normalize_name(&appearance.release_title),
|
||||||
|
music_dht::normalize_name(&appearance.track.title),
|
||||||
|
appearance.track.track_number
|
||||||
|
);
|
||||||
|
let slot = *appearance_index.entry(key).or_insert_with(|| {
|
||||||
|
appears_on.push(FedAppearsOn {
|
||||||
|
release_title: appearance.release_title.clone(),
|
||||||
|
release_type: appearance.release_type.clone(),
|
||||||
|
year: appearance.year,
|
||||||
|
track: FedCardTrack::default(),
|
||||||
|
});
|
||||||
|
appears_on.len() - 1
|
||||||
|
});
|
||||||
|
let merged = &mut appears_on[slot];
|
||||||
|
if merged.release_type.is_empty() {
|
||||||
|
merged.release_type = appearance.release_type.clone();
|
||||||
|
}
|
||||||
|
if merged.year.is_none() {
|
||||||
|
merged.year = appearance.year;
|
||||||
|
}
|
||||||
|
if merged.track.title.is_empty() {
|
||||||
|
merged.track = card_track(owner_hex.clone(), appearance.track);
|
||||||
|
} else {
|
||||||
|
merge_card_track(&mut merged.track, &owner_hex, appearance.track);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for release in &mut releases {
|
for release in &mut releases {
|
||||||
release
|
release.tracks.sort_by_key(|t| {
|
||||||
.tracks
|
(
|
||||||
.sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(i32::MAX)));
|
t.disc_number.unwrap_or(1),
|
||||||
|
t.track_number.unwrap_or(i32::MAX),
|
||||||
|
)
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
appears_on.sort_by(|a, b| {
|
||||||
|
b.year
|
||||||
|
.unwrap_or(i32::MIN)
|
||||||
|
.cmp(&a.year.unwrap_or(i32::MIN))
|
||||||
|
.then_with(|| a.release_title.cmp(&b.release_title))
|
||||||
|
.then_with(|| {
|
||||||
|
a.track
|
||||||
|
.track_number
|
||||||
|
.unwrap_or(i32::MAX)
|
||||||
|
.cmp(&b.track.track_number.unwrap_or(i32::MAX))
|
||||||
|
})
|
||||||
|
.then_with(|| a.track.title.cmp(&b.track.title))
|
||||||
|
});
|
||||||
releases.sort_by(|a, b| {
|
releases.sort_by(|a, b| {
|
||||||
a.year
|
a.year
|
||||||
.unwrap_or(i32::MAX)
|
.unwrap_or(i32::MAX)
|
||||||
@@ -497,10 +613,50 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed
|
|||||||
|
|
||||||
FedArtistCard {
|
FedArtistCard {
|
||||||
name: name.to_string(),
|
name: name.to_string(),
|
||||||
|
own_owner: None,
|
||||||
peers,
|
peers,
|
||||||
owners: card_owners,
|
owners: card_owners,
|
||||||
image_path: None,
|
image_path: None,
|
||||||
releases,
|
releases,
|
||||||
|
appears_on,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card_track(owner_hex: String, track: CatalogTrack) -> FedCardTrack {
|
||||||
|
FedCardTrack {
|
||||||
|
title: track.title,
|
||||||
|
artists: track.artists,
|
||||||
|
featured_artists: track.featured_artists,
|
||||||
|
track_number: track.track_number,
|
||||||
|
disc_number: track.disc_number,
|
||||||
|
duration_seconds: track.duration_seconds,
|
||||||
|
content_id: track.content_id,
|
||||||
|
sources: vec![(owner_hex, track.item_id)],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn merge_card_track(target: &mut FedCardTrack, owner_hex: &str, track: CatalogTrack) {
|
||||||
|
if target.artists.is_empty() {
|
||||||
|
target.artists = track.artists;
|
||||||
|
}
|
||||||
|
if target.featured_artists.is_empty() {
|
||||||
|
target.featured_artists = track.featured_artists;
|
||||||
|
}
|
||||||
|
if target.track_number.is_none() {
|
||||||
|
target.track_number = track.track_number;
|
||||||
|
}
|
||||||
|
if target.disc_number.is_none() {
|
||||||
|
target.disc_number = track.disc_number;
|
||||||
|
}
|
||||||
|
if target.duration_seconds.is_none() {
|
||||||
|
target.duration_seconds = track.duration_seconds;
|
||||||
|
}
|
||||||
|
if target.content_id.is_none() {
|
||||||
|
target.content_id = track.content_id;
|
||||||
|
}
|
||||||
|
let source = (owner_hex.to_string(), track.item_id);
|
||||||
|
if !target.sources.contains(&source) {
|
||||||
|
target.sources.push(source);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,9 +667,12 @@ mod tests {
|
|||||||
fn track(title: &str, number: i32, item: &str) -> CatalogTrack {
|
fn track(title: &str, number: i32, item: &str) -> CatalogTrack {
|
||||||
CatalogTrack {
|
CatalogTrack {
|
||||||
title: title.into(),
|
title: title.into(),
|
||||||
|
artists: vec!["Metallica".into()],
|
||||||
|
featured_artists: Vec::new(),
|
||||||
track_number: Some(number),
|
track_number: Some(number),
|
||||||
disc_number: None,
|
disc_number: None,
|
||||||
duration_seconds: Some(100.0),
|
duration_seconds: Some(100.0),
|
||||||
|
content_id: None,
|
||||||
item_id: item.into(),
|
item_id: item.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -531,6 +690,7 @@ mod tests {
|
|||||||
track("Sad But True", 2, &format!("{item_prefix}2")),
|
track("Sad But True", 2, &format!("{item_prefix}2")),
|
||||||
],
|
],
|
||||||
}],
|
}],
|
||||||
|
appears_on: Vec::new(),
|
||||||
};
|
};
|
||||||
let card = merge_catalogs(
|
let card = merge_catalogs(
|
||||||
"Metallica",
|
"Metallica",
|
||||||
@@ -547,4 +707,51 @@ mod tests {
|
|||||||
// Both peers stay as sources of the deduplicated track.
|
// Both peers stay as sources of the deduplicated track.
|
||||||
assert_eq!(release.tracks[0].sources.len(), 2);
|
assert_eq!(release.tracks[0].sources.len(), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merges_featured_appearances_across_peers() {
|
||||||
|
let mut featured = track("Guest Verse", 3, "a1");
|
||||||
|
featured.artists = vec!["Host".into()];
|
||||||
|
featured.featured_artists = vec!["Guest".into()];
|
||||||
|
let mut same_featured = featured.clone();
|
||||||
|
same_featured.item_id = "b1".into();
|
||||||
|
|
||||||
|
let card = merge_catalogs(
|
||||||
|
"Guest",
|
||||||
|
vec![
|
||||||
|
(
|
||||||
|
"peer-a".to_string(),
|
||||||
|
CatalogArtist {
|
||||||
|
name: "Guest".into(),
|
||||||
|
releases: Vec::new(),
|
||||||
|
appears_on: vec![CatalogAppearance {
|
||||||
|
release_title: "Host Album".into(),
|
||||||
|
release_type: "album".into(),
|
||||||
|
year: Some(2024),
|
||||||
|
track: featured,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"peer-b".to_string(),
|
||||||
|
CatalogArtist {
|
||||||
|
name: "Guest".into(),
|
||||||
|
releases: Vec::new(),
|
||||||
|
appears_on: vec![CatalogAppearance {
|
||||||
|
release_title: "Host Album".into(),
|
||||||
|
release_type: "album".into(),
|
||||||
|
year: Some(2024),
|
||||||
|
track: same_featured,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(card.releases.is_empty());
|
||||||
|
assert_eq!(card.appears_on.len(), 1);
|
||||||
|
assert_eq!(card.appears_on[0].track.sources.len(), 2);
|
||||||
|
assert_eq!(card.appears_on[0].track.artists, vec!["Host"]);
|
||||||
|
assert_eq!(card.appears_on[0].track.featured_artists, vec!["Guest"]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+993
-50
File diff suppressed because it is too large
Load Diff
+12
-8
@@ -10,7 +10,7 @@ use lofty::picture::MimeType;
|
|||||||
use lofty::tag::{Accessor as _, ItemKey};
|
use lofty::tag::{Accessor as _, ItemKey};
|
||||||
use rusqlite::{OptionalExtension as _, params};
|
use rusqlite::{OptionalExtension as _, params};
|
||||||
|
|
||||||
use super::{Library, find_or_create_artist};
|
use super::{Library, audio_content_id, find_or_create_artist};
|
||||||
|
|
||||||
/// Extensions the playback engine can decode (rodio/symphonia feature set).
|
/// Extensions the playback engine can decode (rodio/symphonia feature set).
|
||||||
const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"];
|
const AUDIO_EXTENSIONS: [&str; 8] = ["mp3", "flac", "ogg", "oga", "wav", "m4a", "mp4", "aac"];
|
||||||
@@ -213,6 +213,7 @@ pub fn read_file(path: &Path) -> Result<TrackImport> {
|
|||||||
/// Insert or update one track (matching by file path). Returns the track id
|
/// Insert or update one track (matching by file path). Returns the track id
|
||||||
/// and whether a new row was created.
|
/// and whether a new row was created.
|
||||||
pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> {
|
pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, bool)> {
|
||||||
|
let content_id = audio_content_id(&import.file_path);
|
||||||
let mut conn = library.lock();
|
let mut conn = library.lock();
|
||||||
let tx = conn.transaction()?;
|
let tx = conn.transaction()?;
|
||||||
|
|
||||||
@@ -280,7 +281,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
|||||||
"UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4,
|
"UPDATE tracks SET title = ?2, track_number = ?3, disc_number = ?4,
|
||||||
duration_seconds = ?5, release_id = ?6, audio_format = ?7,
|
duration_seconds = ?5, release_id = ?6, audio_format = ?7,
|
||||||
audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10,
|
audio_bitrate = ?8, audio_sample_rate = ?9, audio_bit_depth = ?10,
|
||||||
file_size_bytes = ?11
|
file_size_bytes = ?11, content_id = ?12
|
||||||
WHERE id = ?1",
|
WHERE id = ?1",
|
||||||
params![
|
params![
|
||||||
id,
|
id,
|
||||||
@@ -294,6 +295,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
|||||||
import.audio_sample_rate,
|
import.audio_sample_rate,
|
||||||
import.audio_bit_depth,
|
import.audio_bit_depth,
|
||||||
import.file_size_bytes,
|
import.file_size_bytes,
|
||||||
|
content_id.as_deref(),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?;
|
tx.execute("DELETE FROM track_artists WHERE track_id = ?1", [id])?;
|
||||||
@@ -303,8 +305,8 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
|||||||
tx.execute(
|
tx.execute(
|
||||||
"INSERT INTO tracks (title, track_number, disc_number, duration_seconds,
|
"INSERT INTO tracks (title, track_number, disc_number, duration_seconds,
|
||||||
release_id, file_path, audio_format, audio_bitrate, audio_sample_rate,
|
release_id, file_path, audio_format, audio_bitrate, audio_sample_rate,
|
||||||
audio_bit_depth, file_size_bytes)
|
audio_bit_depth, file_size_bytes, content_id)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||||
params![
|
params![
|
||||||
import.title,
|
import.title,
|
||||||
import.track_number,
|
import.track_number,
|
||||||
@@ -317,6 +319,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
|||||||
import.audio_sample_rate,
|
import.audio_sample_rate,
|
||||||
import.audio_bit_depth,
|
import.audio_bit_depth,
|
||||||
import.file_size_bytes,
|
import.file_size_bytes,
|
||||||
|
content_id.as_deref(),
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
(tx.last_insert_rowid(), true)
|
(tx.last_insert_rowid(), true)
|
||||||
@@ -348,9 +351,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
|||||||
|row| row.get(0),
|
|row| row.get(0),
|
||||||
)
|
)
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
if !has_cover
|
if !has_cover && let Some(cover_path) = resolve_cover(library, release_id, import) {
|
||||||
&& let Some(cover_path) = resolve_cover(library, release_id, import)
|
|
||||||
{
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
|
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
|
||||||
params![release_id, cover_path],
|
params![release_id, cover_path],
|
||||||
@@ -381,7 +382,10 @@ fn resolve_cover(library: &Library, release_id: i64, import: &TrackImport) -> Op
|
|||||||
Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif")
|
Some("jpg" | "jpeg" | "png" | "webp" | "bmp" | "gif")
|
||||||
);
|
);
|
||||||
if is_image
|
if is_image
|
||||||
&& matches!(stem.as_deref(), Some("cover" | "folder" | "front" | "album"))
|
&& matches!(
|
||||||
|
stem.as_deref(),
|
||||||
|
Some("cover" | "folder" | "front" | "album")
|
||||||
|
)
|
||||||
{
|
{
|
||||||
return Some(path.to_string_lossy().into_owned());
|
return Some(path.to_string_lossy().into_owned());
|
||||||
}
|
}
|
||||||
|
|||||||
+194
-39
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS tracks (
|
|||||||
duration_seconds REAL NOT NULL DEFAULT 0,
|
duration_seconds REAL NOT NULL DEFAULT 0,
|
||||||
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
||||||
file_path TEXT NOT NULL UNIQUE,
|
file_path TEXT NOT NULL UNIQUE,
|
||||||
|
content_id TEXT,
|
||||||
audio_format TEXT,
|
audio_format TEXT,
|
||||||
audio_bitrate INTEGER,
|
audio_bitrate INTEGER,
|
||||||
audio_sample_rate INTEGER,
|
audio_sample_rate INTEGER,
|
||||||
@@ -86,8 +87,10 @@ CREATE TABLE IF NOT EXISTS fed_likes (
|
|||||||
owner TEXT NOT NULL,
|
owner TEXT NOT NULL,
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
artist_names TEXT NOT NULL DEFAULT '',
|
artist_names TEXT NOT NULL DEFAULT '',
|
||||||
|
featured_artist_names TEXT NOT NULL DEFAULT '',
|
||||||
year INTEGER,
|
year INTEGER,
|
||||||
duration_seconds REAL,
|
duration_seconds REAL,
|
||||||
|
content_id TEXT,
|
||||||
release_title TEXT,
|
release_title TEXT,
|
||||||
track_number INTEGER,
|
track_number INTEGER,
|
||||||
disc_number INTEGER,
|
disc_number INTEGER,
|
||||||
@@ -115,6 +118,7 @@ const TRACK_COLUMNS: &str = "
|
|||||||
t.release_id, r.title, r.year, r.cover_path,
|
t.release_id, r.title, r.year, r.cover_path,
|
||||||
t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate,
|
t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate,
|
||||||
t.audio_bit_depth, t.file_size_bytes,
|
t.audio_bit_depth, t.file_size_bytes,
|
||||||
|
t.content_id,
|
||||||
(SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1)
|
(SELECT COUNT(*) FROM history h WHERE h.track_id = t.id AND h.completed = 1)
|
||||||
";
|
";
|
||||||
|
|
||||||
@@ -143,6 +147,12 @@ pub struct ExportTrack {
|
|||||||
pub year: Option<i32>,
|
pub year: Option<i32>,
|
||||||
pub duration_seconds: f64,
|
pub duration_seconds: f64,
|
||||||
pub artist_names: Vec<String>,
|
pub artist_names: Vec<String>,
|
||||||
|
pub featured_artist_names: Vec<String>,
|
||||||
|
pub release_title: String,
|
||||||
|
pub release_type: String,
|
||||||
|
pub track_number: Option<i32>,
|
||||||
|
pub disc_number: Option<i32>,
|
||||||
|
pub content_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Library {
|
pub struct Library {
|
||||||
@@ -169,6 +179,7 @@ impl Library {
|
|||||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||||
register_norm_function(&conn)?;
|
register_norm_function(&conn)?;
|
||||||
conn.execute_batch(SCHEMA).context("applying schema")?;
|
conn.execute_batch(SCHEMA).context("applying schema")?;
|
||||||
|
ensure_schema_migrations(&conn)?;
|
||||||
let covers_dir = db_path
|
let covers_dir = db_path
|
||||||
.parent()
|
.parent()
|
||||||
.map(|dir| dir.join("covers"))
|
.map(|dir| dir.join("covers"))
|
||||||
@@ -254,7 +265,7 @@ impl Library {
|
|||||||
JOIN releases r ON r.id = t.release_id
|
JOIN releases r ON r.id = t.release_id
|
||||||
JOIN track_artists ta ON ta.track_id = t.id
|
JOIN track_artists ta ON ta.track_id = t.id
|
||||||
WHERE ta.artist_id = ?1 AND ta.role = 'main'
|
WHERE ta.artist_id = ?1 AND ta.role = 'main'
|
||||||
ORDER BY 16 DESC, t.title COLLATE NOCASE
|
ORDER BY 17 DESC, t.title COLLATE NOCASE
|
||||||
LIMIT 10"
|
LIMIT 10"
|
||||||
),
|
),
|
||||||
params![id],
|
params![id],
|
||||||
@@ -395,11 +406,13 @@ impl Library {
|
|||||||
),
|
),
|
||||||
params![pattern, limit],
|
params![pattern, limit],
|
||||||
)?;
|
)?;
|
||||||
Ok(SearchResults {
|
let mut results = SearchResults {
|
||||||
artists,
|
artists,
|
||||||
releases,
|
releases,
|
||||||
tracks,
|
tracks,
|
||||||
})
|
};
|
||||||
|
rank_search_results(&mut results, &pattern);
|
||||||
|
Ok(results)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Everything the federation publishes into the DHT: plain rows, so the
|
/// Everything the federation publishes into the DHT: plain rows, so the
|
||||||
@@ -417,12 +430,13 @@ impl Library {
|
|||||||
ORDER BY ra.release_id, ra.position",
|
ORDER BY ra.release_id, ra.position",
|
||||||
)?;
|
)?;
|
||||||
let mut release_artists: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
let mut release_artists: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
for row in statement.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))? {
|
for row in statement.query_map([], |row| {
|
||||||
|
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||||
|
})? {
|
||||||
let (id, name) = row?;
|
let (id, name) = row?;
|
||||||
release_artists.entry(id).or_default().push(name);
|
release_artists.entry(id).or_default().push(name);
|
||||||
}
|
}
|
||||||
let mut statement =
|
let mut statement = conn.prepare("SELECT id, title, year, release_type FROM releases")?;
|
||||||
conn.prepare("SELECT id, title, year, release_type FROM releases")?;
|
|
||||||
let releases = statement
|
let releases = statement
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
Ok(ExportRelease {
|
Ok(ExportRelease {
|
||||||
@@ -442,36 +456,76 @@ impl Library {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT ta.track_id, a.name FROM track_artists ta
|
"SELECT ta.track_id, a.name, ta.role FROM track_artists ta
|
||||||
JOIN artists a ON a.id = ta.artist_id
|
JOIN artists a ON a.id = ta.artist_id
|
||||||
ORDER BY ta.track_id, ta.position",
|
WHERE ta.role IN ('main', 'featured')
|
||||||
|
ORDER BY ta.track_id,
|
||||||
|
CASE ta.role WHEN 'main' THEN 0 ELSE 1 END,
|
||||||
|
ta.position",
|
||||||
)?;
|
)?;
|
||||||
let mut track_artists: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
let mut track_artists: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
for row in statement.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))? {
|
let mut featured_artists: std::collections::HashMap<i64, Vec<String>> = Default::default();
|
||||||
let (id, name) = row?;
|
for row in statement.query_map([], |row| {
|
||||||
track_artists.entry(id).or_default().push(name);
|
Ok((
|
||||||
|
row.get::<_, i64>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
))
|
||||||
|
})? {
|
||||||
|
let (id, name, role) = row?;
|
||||||
|
if role == "featured" {
|
||||||
|
featured_artists.entry(id).or_default().push(name);
|
||||||
|
} else {
|
||||||
|
track_artists.entry(id).or_default().push(name);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT t.id, t.title, r.year, t.duration_seconds
|
"SELECT t.id, t.title, r.year, t.duration_seconds,
|
||||||
|
r.title, r.release_type, t.track_number, t.disc_number,
|
||||||
|
t.content_id, t.file_path
|
||||||
FROM tracks t JOIN releases r ON r.id = t.release_id",
|
FROM tracks t JOIN releases r ON r.id = t.release_id",
|
||||||
)?;
|
)?;
|
||||||
let tracks = statement
|
let track_rows = statement
|
||||||
.query_map([], |row| {
|
.query_map([], |row| {
|
||||||
Ok(ExportTrack {
|
Ok((
|
||||||
id: row.get(0)?,
|
ExportTrack {
|
||||||
title: row.get(1)?,
|
id: row.get(0)?,
|
||||||
year: row.get(2)?,
|
title: row.get(1)?,
|
||||||
duration_seconds: row.get(3)?,
|
year: row.get(2)?,
|
||||||
artist_names: Vec::new(),
|
duration_seconds: row.get(3)?,
|
||||||
})
|
artist_names: Vec::new(),
|
||||||
|
featured_artist_names: Vec::new(),
|
||||||
|
release_title: row.get(4)?,
|
||||||
|
release_type: row.get(5)?,
|
||||||
|
track_number: row.get(6)?,
|
||||||
|
disc_number: row.get(7)?,
|
||||||
|
content_id: None,
|
||||||
|
},
|
||||||
|
row.get::<_, Option<String>>(8)?,
|
||||||
|
row.get::<_, String>(9)?,
|
||||||
|
))
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
.into_iter()
|
drop(statement);
|
||||||
.map(|mut track| {
|
let mut tracks = Vec::with_capacity(track_rows.len());
|
||||||
track.artist_names = track_artists.remove(&track.id).unwrap_or_default();
|
for (mut track, raw_content_id, file_path) in track_rows {
|
||||||
track
|
let mut content_id = raw_content_id
|
||||||
})
|
.as_deref()
|
||||||
.collect();
|
.and_then(music_dht::normalize_content_id);
|
||||||
|
if content_id.is_none() {
|
||||||
|
content_id = audio_content_id(&file_path);
|
||||||
|
}
|
||||||
|
if content_id != raw_content_id {
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE tracks SET content_id = ?2 WHERE id = ?1",
|
||||||
|
params![track.id, content_id.as_deref()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
track.artist_names = track_artists.remove(&track.id).unwrap_or_default();
|
||||||
|
track.featured_artist_names = featured_artists.remove(&track.id).unwrap_or_default();
|
||||||
|
track.content_id = content_id;
|
||||||
|
tracks.push(track);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(FederationExport {
|
Ok(FederationExport {
|
||||||
artists,
|
artists,
|
||||||
@@ -649,8 +703,8 @@ impl Library {
|
|||||||
pub fn fed_likes(&self) -> Result<Vec<crate::federation::FedTrack>> {
|
pub fn fed_likes(&self) -> Result<Vec<crate::federation::FedTrack>> {
|
||||||
let conn = self.lock();
|
let conn = self.lock();
|
||||||
let mut statement = conn.prepare(
|
let mut statement = conn.prepare(
|
||||||
"SELECT item_id, owner, title, artist_names, year, duration_seconds,
|
"SELECT item_id, owner, title, artist_names, featured_artist_names,
|
||||||
release_title, track_number, disc_number
|
year, duration_seconds, content_id, release_title, track_number, disc_number
|
||||||
FROM fed_likes ORDER BY liked_at DESC",
|
FROM fed_likes ORDER BY liked_at DESC",
|
||||||
)?;
|
)?;
|
||||||
let rows = statement
|
let rows = statement
|
||||||
@@ -666,11 +720,18 @@ impl Library {
|
|||||||
.filter(|name| !name.is_empty())
|
.filter(|name| !name.is_empty())
|
||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.collect(),
|
.collect(),
|
||||||
year: row.get(4)?,
|
featured_artist_names: row
|
||||||
duration_seconds: row.get::<_, Option<f64>>(5)?.map(|d| d.round() as i64),
|
.get::<_, String>(4)?
|
||||||
release_title: row.get(6)?,
|
.split("; ")
|
||||||
track_number: row.get(7)?,
|
.filter(|name| !name.is_empty())
|
||||||
disc_number: row.get(8)?,
|
.map(str::to_string)
|
||||||
|
.collect(),
|
||||||
|
year: row.get(5)?,
|
||||||
|
duration_seconds: row.get::<_, Option<f64>>(6)?.map(|d| d.round() as i64),
|
||||||
|
content_id: row.get(7)?,
|
||||||
|
release_title: row.get(8)?,
|
||||||
|
track_number: row.get(9)?,
|
||||||
|
disc_number: row.get(10)?,
|
||||||
})
|
})
|
||||||
})?
|
})?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
@@ -695,16 +756,19 @@ impl Library {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO fed_likes (item_id, owner, title, artist_names, year,
|
"INSERT INTO fed_likes (item_id, owner, title, artist_names,
|
||||||
duration_seconds, release_title, track_number, disc_number)
|
featured_artist_names, year, duration_seconds, content_id,
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
release_title, track_number, disc_number)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||||
params![
|
params![
|
||||||
fed.item_id,
|
fed.item_id,
|
||||||
fed.owner,
|
fed.owner,
|
||||||
fed.title,
|
fed.title,
|
||||||
fed.artist_names.join("; "),
|
fed.artist_names.join("; "),
|
||||||
|
fed.featured_artist_names.join("; "),
|
||||||
fed.year,
|
fed.year,
|
||||||
fed.duration_seconds.map(|d| d as f64),
|
fed.duration_seconds.map(|d| d as f64),
|
||||||
|
fed.content_id,
|
||||||
fed.release_title,
|
fed.release_title,
|
||||||
fed.track_number,
|
fed.track_number,
|
||||||
fed.disc_number,
|
fed.disc_number,
|
||||||
@@ -985,6 +1049,44 @@ fn release_card_from_row(row: &rusqlite::Row) -> rusqlite::Result<ReleaseCard> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn rank_search_results(results: &mut SearchResults, normalized_query: &str) {
|
||||||
|
results
|
||||||
|
.artists
|
||||||
|
.sort_by_key(|artist| exact_match_rank(&artist.name, normalized_query));
|
||||||
|
results
|
||||||
|
.releases
|
||||||
|
.sort_by_key(|release| exact_match_rank(&release.title, normalized_query));
|
||||||
|
results
|
||||||
|
.tracks
|
||||||
|
.sort_by_key(|track| track_match_rank(track, normalized_query));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn exact_match_rank(value: &str, normalized_query: &str) -> u8 {
|
||||||
|
if music_dht::normalize_name(value) == normalized_query {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_match_rank(track: &TrackItem, normalized_query: &str) -> u8 {
|
||||||
|
if music_dht::normalize_name(&track.title) == normalized_query {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if music_dht::normalize_name(&track.release_title) == normalized_query {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if track
|
||||||
|
.artists
|
||||||
|
.iter()
|
||||||
|
.chain(track.featured_artists.iter())
|
||||||
|
.any(|artist| music_dht::normalize_name(&artist.name) == normalized_query)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
3
|
||||||
|
}
|
||||||
|
|
||||||
/// Run a track query built on TRACK_COLUMNS and attach artist lists.
|
/// Run a track query built on TRACK_COLUMNS and attach artist lists.
|
||||||
fn query_tracks(
|
fn query_tracks(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
@@ -1010,7 +1112,11 @@ fn query_tracks(
|
|||||||
audio_sample_rate: row.get(12)?,
|
audio_sample_rate: row.get(12)?,
|
||||||
audio_bit_depth: row.get(13)?,
|
audio_bit_depth: row.get(13)?,
|
||||||
file_size_bytes: row.get(14)?,
|
file_size_bytes: row.get(14)?,
|
||||||
play_count: row.get(15)?,
|
content_id: row
|
||||||
|
.get::<_, Option<String>>(15)?
|
||||||
|
.as_deref()
|
||||||
|
.and_then(music_dht::normalize_content_id),
|
||||||
|
play_count: row.get(16)?,
|
||||||
artists: Vec::new(),
|
artists: Vec::new(),
|
||||||
featured_artists: Vec::new(),
|
featured_artists: Vec::new(),
|
||||||
fed: None,
|
fed: None,
|
||||||
@@ -1064,6 +1170,42 @@ fn register_norm_function(conn: &Connection) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn audio_content_id(path: &str) -> Option<String> {
|
||||||
|
let mut file = std::fs::File::open(path).ok()?;
|
||||||
|
let mut hasher = blake3::Hasher::new();
|
||||||
|
std::io::copy(&mut file, &mut hasher).ok()?;
|
||||||
|
Some(format!("b3:{}", hasher.finalize().to_hex()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ensure_schema_migrations(conn: &Connection) -> Result<()> {
|
||||||
|
let fed_like_columns = table_columns(conn, "fed_likes")?;
|
||||||
|
if !fed_like_columns
|
||||||
|
.iter()
|
||||||
|
.any(|column| column == "featured_artist_names")
|
||||||
|
{
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE fed_likes
|
||||||
|
ADD COLUMN featured_artist_names TEXT NOT NULL DEFAULT ''",
|
||||||
|
[],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
if !fed_like_columns.iter().any(|column| column == "content_id") {
|
||||||
|
conn.execute("ALTER TABLE fed_likes ADD COLUMN content_id TEXT", [])?;
|
||||||
|
}
|
||||||
|
let track_columns = table_columns(conn, "tracks")?;
|
||||||
|
if !track_columns.iter().any(|column| column == "content_id") {
|
||||||
|
conn.execute("ALTER TABLE tracks ADD COLUMN content_id TEXT", [])?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn table_columns(conn: &Connection, table: &str) -> Result<Vec<String>> {
|
||||||
|
let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?;
|
||||||
|
Ok(statement
|
||||||
|
.query_map([], |row| row.get::<_, String>(1))?
|
||||||
|
.collect::<rusqlite::Result<Vec<_>>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1143,6 +1285,18 @@ mod tests {
|
|||||||
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
|
assert_eq!(lib.search("%", 10).unwrap().len(), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn search_ranks_exact_names_first() {
|
||||||
|
let lib = test_library();
|
||||||
|
add_track(&lib, "A Needle", "A Needle Artist", "A Needle Album");
|
||||||
|
add_track(&lib, "Needle", "Needle", "Needle");
|
||||||
|
|
||||||
|
let results = lib.search("needle", 10).unwrap();
|
||||||
|
assert_eq!(results.artists[0].name, "Needle");
|
||||||
|
assert_eq!(results.releases[0].title, "Needle");
|
||||||
|
assert_eq!(results.tracks[0].title, "Needle");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn search_folds_case_beyond_ascii() {
|
fn search_folds_case_beyond_ascii() {
|
||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
@@ -1159,7 +1313,8 @@ mod tests {
|
|||||||
let lib = test_library();
|
let lib = test_library();
|
||||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||||
let playlist = lib.create_playlist("Mix").unwrap();
|
let playlist = lib.create_playlist("Mix").unwrap();
|
||||||
lib.add_tracks_to_playlist(playlist.id, &[track_id]).unwrap();
|
lib.add_tracks_to_playlist(playlist.id, &[track_id])
|
||||||
|
.unwrap();
|
||||||
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
|
assert_eq!(lib.playlist(playlist.id).unwrap().tracks.len(), 1);
|
||||||
|
|
||||||
assert!(lib.toggle_like(track_id).unwrap());
|
assert!(lib.toggle_like(track_id).unwrap());
|
||||||
|
|||||||
+59
-5
@@ -31,6 +31,8 @@ pub struct TrackItem {
|
|||||||
pub release_year: Option<i32>,
|
pub release_year: Option<i32>,
|
||||||
/// Absolute path to the local audio file.
|
/// Absolute path to the local audio file.
|
||||||
pub file_path: String,
|
pub file_path: String,
|
||||||
|
/// Stable audio content id (`b3:<64 hex>`) when known.
|
||||||
|
pub content_id: Option<String>,
|
||||||
/// Path to a local cover image (the release cover).
|
/// Path to a local cover image (the release cover).
|
||||||
pub cover_path: Option<String>,
|
pub cover_path: Option<String>,
|
||||||
pub audio_format: Option<String>,
|
pub audio_format: Option<String>,
|
||||||
@@ -53,12 +55,24 @@ impl TrackItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn artist_line(&self) -> String {
|
pub fn artist_line(&self) -> String {
|
||||||
let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect();
|
let artists = self
|
||||||
if !self.featured_artists.is_empty() {
|
.artists
|
||||||
names.push("feat.");
|
.iter()
|
||||||
names.extend(self.featured_artists.iter().map(|a| a.name.as_str()));
|
.map(|a| a.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let featured = self
|
||||||
|
.featured_artists
|
||||||
|
.iter()
|
||||||
|
.map(|a| a.name.as_str())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
match (artists.is_empty(), featured.is_empty()) {
|
||||||
|
(false, false) => format!("{artists} feat. {featured}"),
|
||||||
|
(false, true) => artists,
|
||||||
|
(true, false) => format!("feat. {featured}"),
|
||||||
|
(true, true) => String::new(),
|
||||||
}
|
}
|
||||||
names.join(", ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn duration_label(&self) -> String {
|
pub fn duration_label(&self) -> String {
|
||||||
@@ -183,3 +197,43 @@ pub struct ReleaseEdit {
|
|||||||
pub year: Option<i32>,
|
pub year: Option<i32>,
|
||||||
pub artists: Vec<String>,
|
pub artists: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn artist(name: &str) -> ArtistRef {
|
||||||
|
ArtistRef {
|
||||||
|
id: 1,
|
||||||
|
name: name.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn artist_line_formats_featured_artists() {
|
||||||
|
let track = TrackItem {
|
||||||
|
id: 1,
|
||||||
|
title: "Track".into(),
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
|
duration_seconds: 1.0,
|
||||||
|
artists: vec![artist("Main")],
|
||||||
|
featured_artists: vec![artist("Guest"), artist("Other")],
|
||||||
|
release_id: 1,
|
||||||
|
release_title: "Release".into(),
|
||||||
|
release_year: None,
|
||||||
|
file_path: "/tmp/track.mp3".into(),
|
||||||
|
content_id: None,
|
||||||
|
cover_path: None,
|
||||||
|
audio_format: None,
|
||||||
|
audio_bitrate: None,
|
||||||
|
audio_sample_rate: None,
|
||||||
|
audio_bit_depth: None,
|
||||||
|
file_size_bytes: None,
|
||||||
|
play_count: 0,
|
||||||
|
fed: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(track.artist_line(), "Main feat. Guest, Other");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ mod federation;
|
|||||||
mod library;
|
mod library;
|
||||||
mod media;
|
mod media;
|
||||||
mod player;
|
mod player;
|
||||||
|
mod share;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|||||||
+4
-3
@@ -258,9 +258,10 @@ fn handle(
|
|||||||
}
|
}
|
||||||
Command::Seek(position) => {
|
Command::Seek(position) => {
|
||||||
if let Some(out) = output
|
if let Some(out) = output
|
||||||
&& let Err(err) = out.player.try_seek(position) {
|
&& let Err(err) = out.player.try_seek(position)
|
||||||
tracing::warn!(%err, "seek failed");
|
{
|
||||||
}
|
tracing::warn!(%err, "seek failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Command::SetVolume(volume) => {
|
Command::SetVolume(volume) => {
|
||||||
if let Some(out) = output {
|
if let Some(out) = output {
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
use crate::library::models::TrackItem;
|
||||||
|
|
||||||
|
pub fn track_content_id(track: &TrackItem) -> Option<String> {
|
||||||
|
track
|
||||||
|
.fed
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|fed| fed.content_id.as_deref())
|
||||||
|
.or(track.content_id.as_deref())
|
||||||
|
.and_then(music_dht::normalize_content_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_can_share(track: &TrackItem) -> bool {
|
||||||
|
track_content_id(track).is_some() || !track.file_path.trim().is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_share_link(track: &TrackItem) -> Option<String> {
|
||||||
|
let content_id = track_content_id(track).or_else(|| {
|
||||||
|
(!track.file_path.trim().is_empty())
|
||||||
|
.then(|| crate::library::audio_content_id(&track.file_path))
|
||||||
|
.flatten()
|
||||||
|
})?;
|
||||||
|
Some(track_share_link_for_content_id(track, &content_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_frid_content_id(value: &str) -> Option<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
let rest = value.strip_prefix("frid://")?;
|
||||||
|
let content_id = rest
|
||||||
|
.split(['?', '#'])
|
||||||
|
.next()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim_end_matches('/');
|
||||||
|
music_dht::normalize_content_id(content_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cached_track_share_link(track: &TrackItem) -> Option<String> {
|
||||||
|
let content_id = track_content_id(track)?;
|
||||||
|
Some(track_share_link_for_content_id(track, &content_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_share_link_for_content_id(track: &TrackItem, content_id: &str) -> String {
|
||||||
|
let label = track_share_label(track);
|
||||||
|
if label.is_empty() {
|
||||||
|
format!("frid://{content_id}")
|
||||||
|
} else {
|
||||||
|
format!("frid://{content_id}?t={}", percent_encode(&label))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn track_share_label(track: &TrackItem) -> String {
|
||||||
|
let artists = track.artist_line();
|
||||||
|
let title = track.title.trim();
|
||||||
|
match (artists.trim().is_empty(), title.is_empty()) {
|
||||||
|
(false, false) => format!("{}-{title}", artists.trim()),
|
||||||
|
(true, false) => title.to_string(),
|
||||||
|
(false, true) => artists.trim().to_string(),
|
||||||
|
(true, true) => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn percent_encode(value: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for byte in value.as_bytes() {
|
||||||
|
match *byte {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||||
|
out.push(char::from(*byte))
|
||||||
|
}
|
||||||
|
byte => out.push_str(&format!("%{byte:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::library::models::ArtistRef;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn share_link_uses_content_id_and_readable_label() {
|
||||||
|
let track = TrackItem {
|
||||||
|
id: 1,
|
||||||
|
title: "Трек".into(),
|
||||||
|
track_number: None,
|
||||||
|
disc_number: None,
|
||||||
|
duration_seconds: 1.0,
|
||||||
|
artists: vec![ArtistRef {
|
||||||
|
id: 1,
|
||||||
|
name: "Артист".into(),
|
||||||
|
}],
|
||||||
|
featured_artists: Vec::new(),
|
||||||
|
release_id: 1,
|
||||||
|
release_title: "Release".into(),
|
||||||
|
release_year: None,
|
||||||
|
file_path: String::new(),
|
||||||
|
content_id: Some(
|
||||||
|
"b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(),
|
||||||
|
),
|
||||||
|
cover_path: None,
|
||||||
|
audio_format: None,
|
||||||
|
audio_bitrate: None,
|
||||||
|
audio_sample_rate: None,
|
||||||
|
audio_bit_depth: None,
|
||||||
|
file_size_bytes: None,
|
||||||
|
play_count: 0,
|
||||||
|
fed: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
track_share_link(&track).as_deref(),
|
||||||
|
Some(
|
||||||
|
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=%D0%90%D1%80%D1%82%D0%B8%D1%81%D1%82-%D0%A2%D1%80%D0%B5%D0%BA"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_frid_content_link() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_frid_content_id(
|
||||||
|
"frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=Artist-Track"
|
||||||
|
)
|
||||||
|
.as_deref(),
|
||||||
|
Some("b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||||
|
);
|
||||||
|
assert!(parse_frid_content_id("https://example.com").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,11 +59,14 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
let label_width = 48usize;
|
let label_width = 48usize;
|
||||||
let line = Line::from(vec![
|
let line = Line::from(vec![
|
||||||
Span::styled(marker, theme::accent()),
|
Span::styled(marker, theme::accent()),
|
||||||
Span::styled(format!("{label:<label_width$}"), if selected {
|
Span::styled(
|
||||||
theme::accent()
|
format!("{label:<label_width$}"),
|
||||||
} else {
|
if selected {
|
||||||
ratatui::style::Style::default()
|
theme::accent()
|
||||||
}),
|
} else {
|
||||||
|
ratatui::style::Style::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
Span::styled(value, theme::dim()),
|
Span::styled(value, theme::dim()),
|
||||||
]);
|
]);
|
||||||
frame.render_widget(Paragraph::new(line), rect);
|
frame.render_widget(Paragraph::new(line), rect);
|
||||||
|
|||||||
+202
-49
@@ -6,12 +6,13 @@ use ratatui::widgets::{Block, Paragraph, Row, Table};
|
|||||||
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
|
||||||
|
|
||||||
use super::{art, theme};
|
use super::{art, theme};
|
||||||
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
|
|
||||||
use crate::app::state::{
|
use crate::app::state::{
|
||||||
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
|
ART_CELL_HEIGHT, ART_CELL_WIDTH, ART_HEADER_HEIGHT, ART_HEADER_WIDTH, AppState, ArtState,
|
||||||
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, release_groups,
|
GlobalView, Loadable, TILE_HEIGHT, TILE_WIDTH, ViewMode, fed_release_display_order,
|
||||||
|
fed_release_groups, release_groups,
|
||||||
};
|
};
|
||||||
use crate::art::cache_key;
|
use crate::art::cache_key;
|
||||||
|
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
|
||||||
|
|
||||||
const TILE_MARQUEE_STEP_MS: u128 = 250;
|
const TILE_MARQUEE_STEP_MS: u128 = 250;
|
||||||
const TILE_MARQUEE_PAUSE_STEPS: u128 = 4;
|
const TILE_MARQUEE_PAUSE_STEPS: u128 = 4;
|
||||||
@@ -956,68 +957,217 @@ fn draw_fed_artist(frame: &mut Frame, area: Rect, state: &AppState, cursor: usiz
|
|||||||
},
|
},
|
||||||
header_art(state, card.image_path.as_ref()),
|
header_art(state, card.image_path.as_ref()),
|
||||||
);
|
);
|
||||||
let tracks_total: usize = card.releases.iter().map(|r| r.tracks.len()).sum();
|
let release_tracks_total: usize = card.releases.iter().map(|r| r.tracks.len()).sum();
|
||||||
|
let appears_on_len = card.appears_on.len();
|
||||||
|
let mut stats = format!(
|
||||||
|
"{} releases · {} tracks",
|
||||||
|
card.releases.len(),
|
||||||
|
release_tracks_total
|
||||||
|
);
|
||||||
|
if appears_on_len > 0 {
|
||||||
|
stats.push_str(&format!(" · appears on {appears_on_len}"));
|
||||||
|
}
|
||||||
|
stats.push_str(&format!(" · from {} peers", card.peers));
|
||||||
let info = vec![
|
let info = vec![
|
||||||
Line::default(),
|
Line::default(),
|
||||||
Line::styled(name.clone(), theme::header()),
|
Line::styled(name.clone(), theme::header()),
|
||||||
Line::default(),
|
Line::default(),
|
||||||
Line::styled(
|
Line::styled(stats, theme::dim()),
|
||||||
format!(
|
Line::styled("enter: open release / play track · esc: back", theme::dim()),
|
||||||
"{} releases · {} tracks · from {} peers",
|
|
||||||
card.releases.len(),
|
|
||||||
tracks_total,
|
|
||||||
card.peers
|
|
||||||
),
|
|
||||||
theme::dim(),
|
|
||||||
),
|
|
||||||
Line::styled("enter: open a release · esc: back", theme::dim()),
|
|
||||||
];
|
];
|
||||||
frame.render_widget(Paragraph::new(info), info_area);
|
frame.render_widget(Paragraph::new(info), info_area);
|
||||||
|
|
||||||
if card.releases.is_empty() {
|
if card.releases.is_empty() && card.appears_on.is_empty() {
|
||||||
return centered_line(
|
return centered_line(
|
||||||
frame,
|
frame,
|
||||||
content_area,
|
content_area,
|
||||||
Line::styled("the peers returned no releases", theme::dim()),
|
Line::styled(
|
||||||
|
"the peers returned no releases or appearances",
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release tiles: a flat grid ordered by year, scrolled to the cursor.
|
// Release tiles grouped by type, then featured appearances as tracks.
|
||||||
let columns = usize::from((content_area.width / TILE_WIDTH).max(1));
|
let columns = usize::from((content_area.width / TILE_WIDTH).max(1));
|
||||||
let visible_rows = usize::from((content_area.height / TILE_HEIGHT).max(1));
|
let releases_len = card.releases.len();
|
||||||
let cursor_row = cursor / columns;
|
let mut items = Vec::new();
|
||||||
let total_rows = card.releases.len().div_ceil(columns);
|
let mut cursor_item = None;
|
||||||
let first_row = cursor_row
|
let mut position = 0;
|
||||||
.saturating_sub(visible_rows / 2)
|
for (label, group) in fed_release_groups(&card.releases) {
|
||||||
.min(total_rows.saturating_sub(visible_rows));
|
items.push(PlanItem::Header(format!("{label} ({})", group.len())));
|
||||||
for (offset, row) in (first_row..total_rows).take(visible_rows).enumerate() {
|
for chunk in group.chunks(columns) {
|
||||||
for column in 0..columns {
|
let row: Vec<usize> = (position..position + chunk.len()).collect();
|
||||||
let index = row * columns + column;
|
if row.contains(&cursor) {
|
||||||
let Some(release) = card.releases.get(index) else {
|
cursor_item = Some(items.len());
|
||||||
break;
|
|
||||||
};
|
|
||||||
let tile = Rect {
|
|
||||||
x: content_area.x + (column as u16) * TILE_WIDTH,
|
|
||||||
y: content_area.y + (offset as u16) * TILE_HEIGHT,
|
|
||||||
width: TILE_WIDTH,
|
|
||||||
height: TILE_HEIGHT.min(content_area.height.saturating_sub((offset as u16) * TILE_HEIGHT)),
|
|
||||||
};
|
|
||||||
if tile.height < 3 {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let mut meta = release.release_type.clone();
|
items.push(PlanItem::TileRow(row));
|
||||||
if let Some(year) = release.year {
|
position += chunk.len();
|
||||||
meta = format!("{meta} · {year}");
|
|
||||||
}
|
|
||||||
draw_tile(
|
|
||||||
frame,
|
|
||||||
tile,
|
|
||||||
tile_art(state, release.cover_path.as_ref()),
|
|
||||||
&release.title,
|
|
||||||
&meta,
|
|
||||||
index == cursor,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
items.push(PlanItem::Gap);
|
||||||
|
}
|
||||||
|
if appears_on_len > 0 {
|
||||||
|
items.push(PlanItem::Header(format!("Appears on ({appears_on_len})")));
|
||||||
|
for index in 0..appears_on_len {
|
||||||
|
let flat = releases_len + index;
|
||||||
|
if cursor == flat {
|
||||||
|
cursor_item = Some(items.len());
|
||||||
|
}
|
||||||
|
items.push(PlanItem::Track { cursor_index: flat });
|
||||||
|
}
|
||||||
|
items.push(PlanItem::Gap);
|
||||||
|
}
|
||||||
|
|
||||||
|
let display_order = fed_release_display_order(&card.releases);
|
||||||
|
let appears_scope = crate::app::state::TrackSelectionScope::FedAppearsOn;
|
||||||
|
render_plan(
|
||||||
|
frame,
|
||||||
|
content_area,
|
||||||
|
state,
|
||||||
|
&items,
|
||||||
|
cursor_item,
|
||||||
|
&mut |frame, rect, item| match item {
|
||||||
|
PlanItem::TileRow(row) => {
|
||||||
|
for (column, position) in row.iter().enumerate() {
|
||||||
|
let release = &card.releases[display_order[*position]];
|
||||||
|
let tile = Rect {
|
||||||
|
x: rect.x + column as u16 * TILE_WIDTH,
|
||||||
|
y: rect.y,
|
||||||
|
width: TILE_WIDTH
|
||||||
|
.min(rect.width.saturating_sub(column as u16 * TILE_WIDTH)),
|
||||||
|
height: rect.height,
|
||||||
|
};
|
||||||
|
if tile.width < 3 || tile.height < 3 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let mut meta = release.release_type.clone();
|
||||||
|
if let Some(year) = release.year {
|
||||||
|
meta = format!("{meta} · {year}");
|
||||||
|
}
|
||||||
|
draw_tile(
|
||||||
|
frame,
|
||||||
|
tile,
|
||||||
|
tile_art(state, release.cover_path.as_ref()),
|
||||||
|
&release.title,
|
||||||
|
&meta,
|
||||||
|
cursor == *position,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlanItem::Track { cursor_index } => {
|
||||||
|
let index = cursor_index - releases_len;
|
||||||
|
let Some(appearance) = card.appears_on.get(index) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
draw_fed_appearance_row(
|
||||||
|
frame,
|
||||||
|
rect,
|
||||||
|
state,
|
||||||
|
appearance,
|
||||||
|
index + 1,
|
||||||
|
cursor == *cursor_index,
|
||||||
|
state.track_selection.contains(&appears_scope, index),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => unreachable!("headers and gaps are rendered by render_plan"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_fed_appearance_row(
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
state: &AppState,
|
||||||
|
appearance: &crate::federation::FedAppearsOn,
|
||||||
|
number: usize,
|
||||||
|
selected: bool,
|
||||||
|
visual_selected: bool,
|
||||||
|
) {
|
||||||
|
let track = &appearance.track;
|
||||||
|
let liked = track
|
||||||
|
.sources
|
||||||
|
.iter()
|
||||||
|
.any(|(_, item_id)| state.fed_likes.contains(item_id));
|
||||||
|
let heart = if liked {
|
||||||
|
Span::styled("♥ ", theme::accent())
|
||||||
|
} else {
|
||||||
|
Span::raw(" ")
|
||||||
|
};
|
||||||
|
let mut context = fed_card_track_artist_line(track);
|
||||||
|
if !appearance.release_title.is_empty() {
|
||||||
|
if !context.is_empty() {
|
||||||
|
context.push_str(" · ");
|
||||||
|
}
|
||||||
|
context.push_str(&appearance.release_title);
|
||||||
|
}
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::styled(format!("{number:>3} "), theme::dim()),
|
||||||
|
heart,
|
||||||
|
Span::styled("⇅ ", theme::accent()),
|
||||||
|
Span::raw(track.title.clone()),
|
||||||
|
Span::styled(format!(" {context}"), theme::dim()),
|
||||||
|
]);
|
||||||
|
let mut meta = track
|
||||||
|
.duration_seconds
|
||||||
|
.map(|duration| {
|
||||||
|
let total = duration.round() as i64;
|
||||||
|
format!("{}:{:02}", total / 60, total % 60)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(year) = appearance.year {
|
||||||
|
if !meta.is_empty() {
|
||||||
|
meta.push_str(" · ");
|
||||||
|
}
|
||||||
|
meta.push_str(&year.to_string());
|
||||||
|
}
|
||||||
|
if track.sources.len() > 1 {
|
||||||
|
if !meta.is_empty() {
|
||||||
|
meta.push_str(" · ");
|
||||||
|
}
|
||||||
|
meta.push_str(&format!("{} peers", track.sources.len()));
|
||||||
|
}
|
||||||
|
if visual_selected && !selected {
|
||||||
|
frame.buffer_mut().set_style(area, theme::selection());
|
||||||
|
}
|
||||||
|
draw_row(
|
||||||
|
frame,
|
||||||
|
area,
|
||||||
|
line,
|
||||||
|
(!meta.is_empty()).then_some(meta),
|
||||||
|
selected,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fed_card_track_artist_line(track: &crate::federation::FedCardTrack) -> String {
|
||||||
|
let mut main = Vec::new();
|
||||||
|
for artist in &track.artists {
|
||||||
|
push_display_artist(&mut main, artist);
|
||||||
|
}
|
||||||
|
let mut featured_names = Vec::new();
|
||||||
|
for artist in &track.featured_artists {
|
||||||
|
if !main
|
||||||
|
.iter()
|
||||||
|
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
|
||||||
|
{
|
||||||
|
push_display_artist(&mut featured_names, artist);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let artists = main.join(", ");
|
||||||
|
let featured = featured_names.join(", ");
|
||||||
|
match (artists.is_empty(), featured.is_empty()) {
|
||||||
|
(false, false) => format!("{artists} feat. {featured}"),
|
||||||
|
(false, true) => artists,
|
||||||
|
(true, false) => format!("feat. {featured}"),
|
||||||
|
(true, true) => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_display_artist(names: &mut Vec<String>, artist: &str) {
|
||||||
|
if !names
|
||||||
|
.iter()
|
||||||
|
.any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist))
|
||||||
|
{
|
||||||
|
names.push(artist.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,7 +1225,10 @@ fn draw_fed_release(frame: &mut Frame, area: Rect, state: &AppState, index: usiz
|
|||||||
format!(" ⤓ Download the whole release ({}) ", release.tracks.len()),
|
format!(" ⤓ Download the whole release ({}) ", release.tracks.len()),
|
||||||
button_style,
|
button_style,
|
||||||
),
|
),
|
||||||
Line::styled("shift+v: select · y: download · p: add to playlist", theme::dim()),
|
Line::styled(
|
||||||
|
"shift+v: select · y: download · p: add to playlist",
|
||||||
|
theme::dim(),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
frame.render_widget(Paragraph::new(info), info_area);
|
frame.render_widget(Paragraph::new(info), info_area);
|
||||||
|
|
||||||
|
|||||||
+28
-29
@@ -210,31 +210,32 @@ fn format_secs(secs: f64) -> String {
|
|||||||
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
|
fn player_right_line(player: &crate::app::state::PlayerBar, width: u16) -> Line<'static> {
|
||||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||||
if let Some(track) = &player.current
|
if let Some(track) = &player.current
|
||||||
&& player.playing {
|
&& player.playing
|
||||||
let bar_width: usize = match width {
|
{
|
||||||
0..=59 => 0,
|
let bar_width: usize = match width {
|
||||||
60..=79 => 8,
|
0..=59 => 0,
|
||||||
80..=109 => 14,
|
60..=79 => 8,
|
||||||
_ => 22,
|
80..=109 => 14,
|
||||||
};
|
_ => 22,
|
||||||
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
|
};
|
||||||
if bar_width > 0 && track.duration_seconds > 0.0 {
|
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
|
||||||
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
|
if bar_width > 0 && track.duration_seconds > 0.0 {
|
||||||
let filled = (ratio * bar_width as f64).round() as usize;
|
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
|
||||||
spans.push(Span::styled("━".repeat(filled), theme::accent()));
|
let filled = (ratio * bar_width as f64).round() as usize;
|
||||||
spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim()));
|
spans.push(Span::styled("━".repeat(filled), theme::accent()));
|
||||||
spans.push(Span::raw(" "));
|
spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim()));
|
||||||
} else {
|
spans.push(Span::raw(" "));
|
||||||
spans.push(Span::styled("/ ", theme::dim()));
|
} else {
|
||||||
}
|
spans.push(Span::styled("/ ", theme::dim()));
|
||||||
spans.push(Span::raw(track.duration_label()));
|
|
||||||
if !player.queue.is_empty() && width >= 70 {
|
|
||||||
spans.push(Span::styled(
|
|
||||||
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
|
|
||||||
theme::dim(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
spans.push(Span::raw(track.duration_label()));
|
||||||
|
if !player.queue.is_empty() && width >= 70 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!(" [{}/{}]", player.queue_pos + 1, player.queue.len()),
|
||||||
|
theme::dim(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
if width >= 80 {
|
if width >= 80 {
|
||||||
let volume_cells = usize::from(player.volume / 10);
|
let volume_cells = usize::from(player.volume / 10);
|
||||||
spans.extend([
|
spans.extend([
|
||||||
@@ -277,11 +278,9 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
// truncates into whatever is left.
|
// truncates into whatever is left.
|
||||||
let center = player_right_line(player, area.width);
|
let center = player_right_line(player, area.width);
|
||||||
let center_width = (center.width() as u16).min(area.width);
|
let center_width = (center.width() as u16).min(area.width);
|
||||||
let [title_area, right_area] = Layout::horizontal([
|
let [title_area, right_area] =
|
||||||
Constraint::Min(8),
|
Layout::horizontal([Constraint::Min(8), Constraint::Length(center_width)])
|
||||||
Constraint::Length(center_width),
|
.areas(player_row);
|
||||||
])
|
|
||||||
.areas(player_row);
|
|
||||||
|
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
match &player.current {
|
match &player.current {
|
||||||
|
|||||||
+51
-12
@@ -59,7 +59,11 @@ fn draw_fed_text(frame: &mut Frame, title: &str, text: &str) {
|
|||||||
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
let width = frame.area().width.saturating_sub(8).clamp(24, 90);
|
||||||
let text_width = usize::from(width.saturating_sub(2));
|
let text_width = usize::from(width.saturating_sub(2));
|
||||||
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
|
let lines_needed = (text.chars().count() / text_width.max(1) + 3) as u16;
|
||||||
let area = centered(frame.area(), width, lines_needed.clamp(5, frame.area().height));
|
let area = centered(
|
||||||
|
frame.area(),
|
||||||
|
width,
|
||||||
|
lines_needed.clamp(5, frame.area().height),
|
||||||
|
);
|
||||||
let block = Block::bordered()
|
let block = Block::bordered()
|
||||||
.title(format!(" {title} "))
|
.title(format!(" {title} "))
|
||||||
.title_style(theme::header())
|
.title_style(theme::header())
|
||||||
@@ -99,11 +103,13 @@ fn draw_edit(
|
|||||||
|
|
||||||
for (index, field) in fields.iter().enumerate() {
|
for (index, field) in fields.iter().enumerate() {
|
||||||
let focused = index == focus;
|
let focused = index == focus;
|
||||||
let field_block = Block::bordered().title(field.label).border_style(if focused {
|
let field_block = Block::bordered()
|
||||||
theme::accent()
|
.title(field.label)
|
||||||
} else {
|
.border_style(if focused {
|
||||||
theme::dim()
|
theme::accent()
|
||||||
});
|
} else {
|
||||||
|
theme::dim()
|
||||||
|
});
|
||||||
let field_inner = field_block.inner(areas[index]);
|
let field_inner = field_block.inner(areas[index]);
|
||||||
frame.render_widget(field_block, areas[index]);
|
frame.render_widget(field_block, areas[index]);
|
||||||
let width = usize::from(field_inner.width);
|
let width = usize::from(field_inner.width);
|
||||||
@@ -218,8 +224,13 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol
|
|||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
|
||||||
let hint = if tracks.len() > 1 {
|
let can_share = crate::share::track_can_share(track);
|
||||||
|
let hint = if tracks.len() > 1 && can_share {
|
||||||
|
"j/k scroll · h/left previous · l/right next · c copy frid link · esc close"
|
||||||
|
} else if tracks.len() > 1 {
|
||||||
"j/k scroll · h/left previous · l/right next · esc close"
|
"j/k scroll · h/left previous · l/right next · esc close"
|
||||||
|
} else if can_share {
|
||||||
|
"j/k scroll · c copy frid link · esc close"
|
||||||
} else {
|
} else {
|
||||||
"j/k scroll · esc close"
|
"j/k scroll · esc close"
|
||||||
};
|
};
|
||||||
@@ -230,13 +241,13 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
||||||
vec![
|
let mut lines = vec![
|
||||||
field("ID", track.id.to_string()),
|
field("ID", row_id(track.id)),
|
||||||
field("Title", track.title.clone()),
|
field("Title", track.title.clone()),
|
||||||
field("Artists", artist_refs(&track.artists)),
|
field("Artists", artist_refs(&track.artists)),
|
||||||
field("Featured artists", artist_refs(&track.featured_artists)),
|
field("Featured artists", artist_refs(&track.featured_artists)),
|
||||||
field("Release", release_label(track)),
|
field("Release", release_label(track)),
|
||||||
field("Release ID", track.release_id.to_string()),
|
field("Release ID", row_id(track.release_id)),
|
||||||
field("Disc", opt_display(track.disc_number)),
|
field("Disc", opt_display(track.disc_number)),
|
||||||
field("Track number", opt_display(track.track_number)),
|
field("Track number", opt_display(track.track_number)),
|
||||||
field(
|
field(
|
||||||
@@ -264,9 +275,23 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
|||||||
),
|
),
|
||||||
field("File size", file_size(track.file_size_bytes)),
|
field("File size", file_size(track.file_size_bytes)),
|
||||||
field("Plays", track.play_count.to_string()),
|
field("Plays", track.play_count.to_string()),
|
||||||
|
field(
|
||||||
|
"Content ID",
|
||||||
|
crate::share::track_content_id(track).unwrap_or_else(|| {
|
||||||
|
if !track.file_path.trim().is_empty() {
|
||||||
|
"press c to compute".to_string()
|
||||||
|
} else {
|
||||||
|
"—".to_string()
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
field("File path", empty_dash(&track.file_path)),
|
field("File path", empty_dash(&track.file_path)),
|
||||||
field("Cover path", opt_string(track.cover_path.clone())),
|
field("Cover path", opt_string(track.cover_path.clone())),
|
||||||
]
|
];
|
||||||
|
if let Some(link) = crate::share::cached_track_share_link(track) {
|
||||||
|
lines.push(field("Share link", link));
|
||||||
|
}
|
||||||
|
lines
|
||||||
}
|
}
|
||||||
|
|
||||||
fn field(label: &'static str, value: String) -> Line<'static> {
|
fn field(label: &'static str, value: String) -> Line<'static> {
|
||||||
@@ -282,11 +307,25 @@ fn artist_refs(items: &[ArtistRef]) -> String {
|
|||||||
}
|
}
|
||||||
items
|
items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|artist| format!("{} ({})", artist.name, artist.id))
|
.map(|artist| {
|
||||||
|
if artist.id >= 0 {
|
||||||
|
format!("{} ({})", artist.name, artist.id)
|
||||||
|
} else {
|
||||||
|
artist.name.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join(", ")
|
.join(", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn row_id(id: i64) -> String {
|
||||||
|
if id >= 0 {
|
||||||
|
id.to_string()
|
||||||
|
} else {
|
||||||
|
"—".to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn release_label(track: &TrackItem) -> String {
|
fn release_label(track: &TrackItem) -> String {
|
||||||
let mut label = empty_dash(&track.release_title);
|
let mut label = empty_dash(&track.release_title);
|
||||||
if let Some(year) = track.release_year {
|
if let Some(year) = track.release_year {
|
||||||
|
|||||||
Reference in New Issue
Block a user