fed fixes
This commit is contained in:
Generated
+101
-88
@@ -103,9 +103,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.103"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "approx"
|
||||
@@ -167,7 +167,7 @@ checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand 2.4.1",
|
||||
"fastrand 2.5.0",
|
||||
"futures-lite 2.6.1",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
@@ -297,13 +297,13 @@ checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
version = "0.1.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
|
||||
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -365,7 +365,7 @@ version = "1.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef"
|
||||
dependencies = [
|
||||
"fastrand 2.4.1",
|
||||
"fastrand 2.5.0",
|
||||
"gloo-timers",
|
||||
"tokio",
|
||||
]
|
||||
@@ -489,9 +489,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.1"
|
||||
version = "1.25.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424"
|
||||
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder"
|
||||
@@ -522,9 +522,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.67"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17dd265a7d0f31ef544e1b20e03add05d3b45b491b633b10d67145d2acc1a38"
|
||||
checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
@@ -593,7 +593,7 @@ version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
|
||||
dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1477,9 +1477,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
@@ -1493,7 +1493,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "federation-net"
|
||||
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 = [
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
@@ -1505,7 +1505,7 @@ dependencies = [
|
||||
"postcard",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1607,6 +1607,7 @@ name = "furumi_tui"
|
||||
version = "0.1.5"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blake3",
|
||||
"core-foundation 0.10.1",
|
||||
"crokey",
|
||||
"crossterm",
|
||||
@@ -1622,7 +1623,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"souvlaki",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"toml",
|
||||
"tracing",
|
||||
@@ -1633,9 +1634,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
||||
checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1661,9 +1662,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
|
||||
checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -1671,15 +1672,15 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
|
||||
|
||||
[[package]]
|
||||
name = "futures-executor"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
|
||||
checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
@@ -1688,9 +1689,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-io"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
|
||||
checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
@@ -1713,7 +1714,7 @@ version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand 2.4.1",
|
||||
"fastrand 2.5.0",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
@@ -1722,9 +1723,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1733,21 +1734,21 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
|
||||
checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
|
||||
checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.32"
|
||||
version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -1983,7 +1984,7 @@ dependencies = [
|
||||
"jni 0.22.4",
|
||||
"rand 0.10.2",
|
||||
"rustls",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tinyvec",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -2005,7 +2006,7 @@ dependencies = [
|
||||
"prefix-trie",
|
||||
"rand 0.10.2",
|
||||
"ring",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"url",
|
||||
@@ -2033,7 +2034,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"smallvec",
|
||||
"system-configuration",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tracing",
|
||||
@@ -2095,9 +2096,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.10.1"
|
||||
version = "1.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -2647,7 +2648,7 @@ dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"walkdir",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -2712,7 +2713,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
|
||||
dependencies = [
|
||||
"hashbrown 0.16.1",
|
||||
"portable-atomic",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2916,7 +2917,7 @@ dependencies = [
|
||||
"serde_bencode",
|
||||
"serde_bytes",
|
||||
"sha1_smol",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
@@ -3026,8 +3027,9 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "music-dht"
|
||||
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 = [
|
||||
"async-trait",
|
||||
"blake3",
|
||||
"data-encoding",
|
||||
"federation-net",
|
||||
@@ -3036,7 +3038,7 @@ dependencies = [
|
||||
"rand 0.9.5",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"unicode-normalization",
|
||||
@@ -3183,7 +3185,7 @@ dependencies = [
|
||||
"log",
|
||||
"netlink-packet-core",
|
||||
"netlink-sys",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3286,7 +3288,7 @@ dependencies = [
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.5",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
@@ -3314,7 +3316,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"sorted-index-buffer",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -3853,7 +3855,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand 2.4.1",
|
||||
"fastrand 2.5.0",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
@@ -3949,9 +3951,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
version = "1.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
@@ -4066,9 +4068,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -4096,9 +4098,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -4232,7 +4234,7 @@ dependencies = [
|
||||
"palette",
|
||||
"serde",
|
||||
"strum",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"unicode-segmentation",
|
||||
"unicode-truncate",
|
||||
"unicode-width",
|
||||
@@ -4318,7 +4320,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4417,7 +4419,7 @@ dependencies = [
|
||||
"dasp_sample",
|
||||
"num-rational",
|
||||
"symphonia",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4653,9 +4655,9 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
@@ -4683,29 +4685,29 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.228"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.150"
|
||||
version = "1.0.151"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
|
||||
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -4716,13 +4718,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5196,6 +5198,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -5249,7 +5262,7 @@ version = "3.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand 2.4.1",
|
||||
"fastrand 2.5.0",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix 1.1.4",
|
||||
@@ -5343,11 +5356,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
|
||||
dependencies = [
|
||||
"thiserror-impl 2.0.18",
|
||||
"thiserror-impl 2.0.19",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5363,13 +5376,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "2.0.18"
|
||||
version = "2.0.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
"syn 3.0.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5383,9 +5396,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.53"
|
||||
version = "0.3.54"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50"
|
||||
checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
|
||||
dependencies = [
|
||||
"deranged",
|
||||
"js-sys",
|
||||
@@ -5406,9 +5419,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
|
||||
|
||||
[[package]]
|
||||
name = "time-macros"
|
||||
version = "0.2.31"
|
||||
version = "0.2.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f"
|
||||
checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
|
||||
dependencies = [
|
||||
"num-conv",
|
||||
"time-core",
|
||||
@@ -6020,18 +6033,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -6538,7 +6551,7 @@ dependencies = [
|
||||
"futures",
|
||||
"log",
|
||||
"serde",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"windows 0.62.2",
|
||||
"windows-core",
|
||||
]
|
||||
@@ -6562,7 +6575,7 @@ dependencies = [
|
||||
"pharos",
|
||||
"rustc_version",
|
||||
"send_wrapper",
|
||||
"thiserror 2.0.18",
|
||||
"thiserror 2.0.19",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
|
||||
@@ -9,6 +9,7 @@ path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
blake3 = "1"
|
||||
crokey = "1.4.0"
|
||||
crossterm = { version = "0.29.0", features = ["event-stream"] }
|
||||
directories = "6.0.0"
|
||||
|
||||
+6
-2
@@ -21,8 +21,12 @@ pub enum Action {
|
||||
PlayPause,
|
||||
NextTrack,
|
||||
PrevTrack,
|
||||
SeekForward { seconds: u32 },
|
||||
SeekBackward { seconds: u32 },
|
||||
SeekForward {
|
||||
seconds: u32,
|
||||
},
|
||||
SeekBackward {
|
||||
seconds: u32,
|
||||
},
|
||||
VolumeUp,
|
||||
VolumeDown,
|
||||
ToggleShuffle,
|
||||
|
||||
@@ -49,6 +49,7 @@ fn apply_live(state: &mut AppState, runtime: &Runtime, command: Command) {
|
||||
// One-shot commands have no live effect.
|
||||
Command::Quit
|
||||
| Command::Import(_)
|
||||
| Command::Open(_)
|
||||
| Command::Volume(_)
|
||||
| Command::Seek(_)
|
||||
| Command::SeekTo(_)
|
||||
@@ -165,6 +166,7 @@ fn execute(state: &mut AppState, runtime: &mut Runtime, command: Command) {
|
||||
Command::Search(_) => {}
|
||||
Command::Quit => state.should_quit = true,
|
||||
Command::Import(path) => super::spawn_import(state, runtime, &path),
|
||||
Command::Open(link) => open_frid_link(state, runtime, link),
|
||||
Command::Volume(value) => {
|
||||
state.player.volume = 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.
|
||||
fn cancel(state: &mut AppState) {
|
||||
retract_live(state);
|
||||
|
||||
@@ -25,6 +25,8 @@ pub enum Command {
|
||||
/// `:import <path>` — import an audio file or a directory into the
|
||||
/// library.
|
||||
Import(String),
|
||||
/// `:open frid://...` — open a shared federation content link.
|
||||
Open(String),
|
||||
/// `:volume 40` (also `:vol`) — set the volume precisely.
|
||||
Volume(u8),
|
||||
/// `: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()),
|
||||
}
|
||||
}
|
||||
"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()) {
|
||||
Some(value) if value <= 100 => Parsed::Command(Command::Volume(value)),
|
||||
_ => Parsed::Invalid("usage: :volume 0-100".to_string()),
|
||||
@@ -162,7 +171,17 @@ mod tests {
|
||||
parse("import ~/Music/My Album"),
|
||||
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("open"), Parsed::Invalid(_)));
|
||||
assert_eq!(parse("volume 40"), Parsed::Command(Command::Volume(40)));
|
||||
assert_eq!(parse("vol 0"), Parsed::Command(Command::Volume(0)));
|
||||
assert_eq!(parse("shuffle"), Parsed::Command(Command::Shuffle));
|
||||
|
||||
@@ -119,6 +119,13 @@ pub enum AppEvent {
|
||||
placeholder_id: i64,
|
||||
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.
|
||||
FedTicket(Result<String, String>),
|
||||
}
|
||||
|
||||
+64
-25
@@ -1,8 +1,8 @@
|
||||
pub mod action;
|
||||
mod cmdline;
|
||||
pub mod input;
|
||||
pub mod command;
|
||||
pub mod event;
|
||||
pub mod input;
|
||||
mod popup;
|
||||
pub mod state;
|
||||
pub mod update;
|
||||
@@ -290,9 +290,10 @@ fn maintenance(state: &mut AppState, runtime: &mut Runtime) {
|
||||
}
|
||||
for detail in state.release_views.values() {
|
||||
if let state::Loadable::Ready(detail) = detail
|
||||
&& let Some(path) = &detail.cover_path {
|
||||
wanted.push((path.clone(), header.0, header.1));
|
||||
}
|
||||
&& let Some(path) = &detail.cover_path
|
||||
{
|
||||
wanted.push((path.clone(), header.0, header.1));
|
||||
}
|
||||
}
|
||||
if let Some((_, state::Loadable::Ready(card))) = &state.fed_artist_view {
|
||||
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::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 {
|
||||
restart_paused,
|
||||
stop,
|
||||
@@ -900,9 +921,10 @@ pub(super) fn spawn_import(state: &mut AppState, runtime: &Runtime, path: &str)
|
||||
|
||||
fn expand_tilde(path: &str) -> PathBuf {
|
||||
if let Some(rest) = path.strip_prefix("~/")
|
||||
&& let Some(home) = std::env::home_dir() {
|
||||
return home.join(rest);
|
||||
}
|
||||
&& let Some(home) = std::env::home_dir()
|
||||
{
|
||||
return home.join(rest);
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
@@ -1071,11 +1093,7 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
state.federation.status = Some(status);
|
||||
}
|
||||
AppEvent::FedSearchLoaded { seq, result } => {
|
||||
if runtime
|
||||
.search_seq
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
!= seq
|
||||
{
|
||||
if runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) != seq {
|
||||
return;
|
||||
}
|
||||
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 } => {
|
||||
if let Some((current, data)) = &mut state.fed_artist_view
|
||||
&& *current == name
|
||||
@@ -1250,21 +1287,23 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
|
||||
state.release_views.insert(id, entry);
|
||||
// A Shift-J jump was waiting for this release: focus its track.
|
||||
if let Some((release_id, track_id)) = state.pending_release_focus
|
||||
&& release_id == id {
|
||||
state.pending_release_focus = None;
|
||||
if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) {
|
||||
let position = detail
|
||||
.tracks
|
||||
.iter()
|
||||
.position(|t| t.id == track_id)
|
||||
.unwrap_or(0);
|
||||
if let Some(state::GlobalView::Release { id: top, cursor }) =
|
||||
state.global.stack.last_mut()
|
||||
&& *top == release_id {
|
||||
*cursor = position;
|
||||
}
|
||||
&& release_id == id
|
||||
{
|
||||
state.pending_release_focus = None;
|
||||
if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) {
|
||||
let position = detail
|
||||
.tracks
|
||||
.iter()
|
||||
.position(|t| t.id == track_id)
|
||||
.unwrap_or(0);
|
||||
if let Some(state::GlobalView::Release { id: top, cursor }) =
|
||||
state.global.stack.last_mut()
|
||||
&& *top == release_id
|
||||
{
|
||||
*cursor = position;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SearchLoaded { seq, result } => {
|
||||
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
|
||||
//! closed it.
|
||||
|
||||
use std::io::Write as _;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::Arc;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
@@ -111,7 +113,10 @@ pub fn handle_paste(state: &mut AppState, pasted: &str) {
|
||||
// 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(
|
||||
state: &mut AppState,
|
||||
runtime: &Runtime,
|
||||
@@ -342,6 +347,22 @@ fn handle_track_info(
|
||||
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 {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+45
-7
@@ -120,24 +120,31 @@ impl Default for GlobalTab {
|
||||
}
|
||||
}
|
||||
|
||||
/// Releases of an artist in display order: grouped by type (albums, EPs,
|
||||
/// singles, compilations, then anything else), keeping server order within a
|
||||
/// Releases of an artist in display order: grouped by type (albums, singles,
|
||||
/// EPs, compilations, then anything else), keeping server order within a
|
||||
/// group. Returns (group label, indices into the original slice). Cursor
|
||||
/// positions use this flattened order, so update() and ui must both go
|
||||
/// through here.
|
||||
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] = [
|
||||
("album", "Albums"),
|
||||
("ep", "EPs"),
|
||||
("single", "Singles"),
|
||||
("ep", "EPs"),
|
||||
("compilation", "Compilations"),
|
||||
];
|
||||
let mut groups: Vec<(&'static str, Vec<usize>)> = Vec::new();
|
||||
for (kind, label) in GROUPS {
|
||||
let indices: Vec<usize> = releases
|
||||
let indices: Vec<usize> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, r)| r.release_type.eq_ignore_ascii_case(kind))
|
||||
.filter(|(_, item)| release_type(item).eq_ignore_ascii_case(kind))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
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 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() {
|
||||
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.
|
||||
/// Vertical cursor movement must follow these rows to match the rendering.
|
||||
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 mut rows = Vec::new();
|
||||
let mut position = 0;
|
||||
for (_, group) in release_groups(releases) {
|
||||
for (_, group) in groups {
|
||||
for chunk in group.chunks(columns) {
|
||||
rows.push((position..position + chunk.len()).collect());
|
||||
position += chunk.len();
|
||||
@@ -212,6 +248,8 @@ pub enum TrackSelectionScope {
|
||||
FedSearch,
|
||||
/// The tracklist of the open federated release view.
|
||||
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
|
||||
|
||||
+274
-59
@@ -5,7 +5,8 @@ use crate::library::models::TrackItem;
|
||||
|
||||
use super::state::{
|
||||
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);
|
||||
@@ -54,6 +55,10 @@ pub enum Effect {
|
||||
FedDownload {
|
||||
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> {
|
||||
@@ -280,14 +285,25 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
|
||||
let tracks = selected_tracks(state);
|
||||
if tracks.is_empty() {
|
||||
state.status_message = Some("no track selected".into());
|
||||
None
|
||||
} 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 {
|
||||
tracks,
|
||||
cursor: 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::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 —
|
||||
/// an artist tile, a release, a track or a playlist.
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
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());
|
||||
}
|
||||
|
||||
fn artist_edit_popup(
|
||||
id: i64,
|
||||
name: &str,
|
||||
image_path: Option<String>,
|
||||
) -> super::state::Popup {
|
||||
fn artist_edit_popup(id: i64, name: &str, image_path: Option<String>) -> super::state::Popup {
|
||||
use super::state::{EditField, EditTarget, Popup};
|
||||
Popup::Edit {
|
||||
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(
|
||||
"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(
|
||||
"Disc #",
|
||||
@@ -527,8 +561,10 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
||||
fed_tracks: vec![],
|
||||
});
|
||||
}
|
||||
state.status_message =
|
||||
Some(format!("removing {} track(s) from playlist", track_ids.len()));
|
||||
state.status_message = Some(format!(
|
||||
"removing {} track(s) from playlist",
|
||||
track_ids.len()
|
||||
));
|
||||
return Some(Effect::RemoveFromPlaylist {
|
||||
playlist_id: opened.id,
|
||||
track_ids,
|
||||
@@ -543,14 +579,20 @@ fn delete_selected(state: &mut AppState) -> Option<Effect> {
|
||||
let artist = state.global.artists.get(state.global.selected).cloned()?;
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
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;
|
||||
}
|
||||
if let Some(artist) = selected_search_artist(state) {
|
||||
state.popup = Some(Popup::ConfirmDelete {
|
||||
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;
|
||||
}
|
||||
@@ -604,7 +646,9 @@ fn selected_release_card(state: &AppState) -> Option<crate::library::models::Rel
|
||||
let offset = cursor.checked_sub(results.artists.len())?;
|
||||
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) => {
|
||||
if let Some(opened) = &mut state.playlists.opened
|
||||
&& opened.id == *id {
|
||||
opened.cursor = value;
|
||||
}
|
||||
&& opened.id == *id
|
||||
{
|
||||
opened.cursor = value;
|
||||
}
|
||||
}
|
||||
TrackSelectionScope::Queue => {
|
||||
state.queue_tab.cursor = value;
|
||||
@@ -694,6 +739,13 @@ fn set_track_scope_cursor(state: &mut AppState, scope: &TrackSelectionScope, val
|
||||
TrackSelectionScope::FedRelease(_) => {
|
||||
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 } => {
|
||||
let len = fed_card_release(state, *index)?.tracks.len();
|
||||
let relative = cursor.checked_sub(1)?;
|
||||
(relative < len).then_some((
|
||||
TrackSelectionScope::FedRelease(*index),
|
||||
(relative < len).then_some((TrackSelectionScope::FedRelease(*index), relative, len))
|
||||
}
|
||||
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,
|
||||
len,
|
||||
card.appears_on.len(),
|
||||
))
|
||||
}
|
||||
_ => None,
|
||||
},
|
||||
Tab::Playlists => {
|
||||
let opened = state.playlists.opened.as_ref()?;
|
||||
@@ -990,17 +1048,24 @@ pub fn selected_track(state: &AppState) -> Option<TrackItem> {
|
||||
}
|
||||
}
|
||||
}
|
||||
GlobalView::FedArtist { .. } => None,
|
||||
GlobalView::FedRelease { index, cursor } => {
|
||||
let name = state.fed_artist_view.as_ref().map(|(name, _)| name.clone())?;
|
||||
let release = fed_card_release(state, *index)?;
|
||||
release
|
||||
.tracks
|
||||
.get(cursor.checked_sub(1)?)
|
||||
.and_then(|track| fed_track_from_card(&name, release, track))
|
||||
GlobalView::FedArtist { cursor } => {
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return None;
|
||||
};
|
||||
let index = cursor.checked_sub(card.releases.len())?;
|
||||
card.appears_on
|
||||
.get(index)
|
||||
.and_then(|appearance| {
|
||||
fed_track_from_appearance(appearance, card.own_owner.as_deref())
|
||||
})
|
||||
.as_ref()
|
||||
.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 => {
|
||||
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())?;
|
||||
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 title = tracks[0].title.clone();
|
||||
enqueue_tracks(state, tracks, next);
|
||||
state.track_selection.clear();
|
||||
state.status_message = Some(if count == 1 && next {
|
||||
format!("queued next: {title}")
|
||||
} 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);
|
||||
}
|
||||
if let Some(prefetched) = &mut player.prefetched_pos
|
||||
&& insert_at <= *prefetched {
|
||||
*prefetched += count;
|
||||
}
|
||||
&& insert_at <= *prefetched
|
||||
{
|
||||
*prefetched += count;
|
||||
}
|
||||
if insert_at <= player.queue_pos && player.current.is_some() {
|
||||
player.queue_pos += count;
|
||||
}
|
||||
@@ -1310,8 +1379,14 @@ fn page_step(state: &AppState) -> isize {
|
||||
}
|
||||
Some(GlobalView::Release { .. })
|
||||
| Some(GlobalView::Search { .. })
|
||||
| Some(GlobalView::FedArtist { .. })
|
||||
| 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();
|
||||
}
|
||||
Some(GlobalView::FedArtist { cursor }) => {
|
||||
// A flat tile grid of releases.
|
||||
let total = fed_card_len(state) as isize;
|
||||
let Some((_, Loadable::Ready(card))) = &state.fed_artist_view else {
|
||||
return;
|
||||
};
|
||||
let releases = card.releases.len();
|
||||
let appears_on = card.appears_on.len();
|
||||
let total = (releases + appears_on) as isize;
|
||||
if total == 0 {
|
||||
return;
|
||||
}
|
||||
let columns = grid_columns() as isize;
|
||||
let step = if dx != 0 { dx } else { dy * columns };
|
||||
let next = (cursor as isize + step).clamp(0, total - 1);
|
||||
let in_release_tiles = cursor < releases;
|
||||
let next = if !in_release_tiles {
|
||||
(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);
|
||||
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 {
|
||||
match &state.fed_artist_view {
|
||||
Some((_, Loadable::Ready(card))) => card.releases.len(),
|
||||
Some((_, Loadable::Ready(card))) => card.releases.len() + card.appears_on.len(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
@@ -1607,10 +1707,11 @@ fn jump_selection(state: &mut AppState, first: bool) {
|
||||
if first {
|
||||
let level = super::state::LOG_LEVELS[state.logs.level_index];
|
||||
if let Some(buffer) = crate::config::logging::buffer()
|
||||
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN) {
|
||||
state.logs.selected_seq = Some(seq);
|
||||
state.logs.follow = false;
|
||||
}
|
||||
&& let Some((seq, _)) = buffer.move_selection(level, None, isize::MIN)
|
||||
{
|
||||
state.logs.selected_seq = Some(seq);
|
||||
state.logs.follow = false;
|
||||
}
|
||||
} else {
|
||||
state.logs.follow = true;
|
||||
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((_, 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
|
||||
// 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
|
||||
} else {
|
||||
1
|
||||
};
|
||||
Outcome::Push(GlobalView::FedRelease {
|
||||
index: cursor,
|
||||
index: release_index,
|
||||
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,
|
||||
},
|
||||
Some(GlobalView::FedRelease { index, cursor }) => match fed_card_release(state, index) {
|
||||
@@ -1882,7 +2007,17 @@ pub(crate) fn fed_release_tracks(
|
||||
release
|
||||
.tracks
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -1904,7 +2039,19 @@ pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::Fe
|
||||
TrackSelectionScope::FedRelease(index) => {
|
||||
let all = fed_release_tracks(state, index);
|
||||
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))
|
||||
.into_iter()
|
||||
.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(),
|
||||
}
|
||||
}
|
||||
@@ -1970,22 +2127,73 @@ fn fed_track_from_card(
|
||||
artist: &str,
|
||||
release: &crate::federation::FedRelease,
|
||||
track: &crate::federation::FedCardTrack,
|
||||
own_owner: Option<&str>,
|
||||
) -> Option<crate::federation::FedTrack> {
|
||||
let (owner, item_id) = track.sources.first()?.clone();
|
||||
let own = own_owner == Some(owner.as_str());
|
||||
Some(crate::federation::FedTrack {
|
||||
item_id,
|
||||
owner,
|
||||
own: false,
|
||||
own,
|
||||
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,
|
||||
duration_seconds: track.duration_seconds.map(|d| d.round() as i64),
|
||||
content_id: track.content_id.clone(),
|
||||
release_title: Some(release.title.clone()),
|
||||
track_number: track.track_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
|
||||
/// one-shot operations. The heavy lifting happens in perform_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
|
||||
// that tab, not down the Global stack.
|
||||
if let Some((origin, depth)) = state.jump_origin
|
||||
&& state.global.stack.len() == depth + 1 {
|
||||
state.global.stack.pop();
|
||||
state.jump_origin = None;
|
||||
state.active_tab = origin;
|
||||
return;
|
||||
}
|
||||
&& state.global.stack.len() == depth + 1
|
||||
{
|
||||
state.global.stack.pop();
|
||||
state.jump_origin = None;
|
||||
state.active_tab = origin;
|
||||
return;
|
||||
}
|
||||
if let Some(popped) = state.global.stack.pop() {
|
||||
if matches!(popped, GlobalView::Search { .. }) {
|
||||
state.search = SearchState::default();
|
||||
@@ -2161,6 +2370,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
@@ -2370,8 +2580,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn queue_advances_and_respects_repeat() {
|
||||
use crate::library::models::TrackItem;
|
||||
use crate::app::state::RepeatMode;
|
||||
use crate::library::models::TrackItem;
|
||||
|
||||
let track = |id: i64| TrackItem {
|
||||
id,
|
||||
@@ -2386,6 +2596,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/api/player/stream/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
@@ -2450,6 +2661,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
@@ -2541,6 +2753,7 @@ mod tests {
|
||||
assert_eq!(update(&mut state, Action::QueueAddLast), None,);
|
||||
let queued: Vec<i64> = state.player.queue.iter().map(|track| track.id).collect();
|
||||
assert_eq!(queued, vec![1, 2]);
|
||||
assert!(!state.track_selection.is_active());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2615,6 +2828,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
@@ -2661,6 +2875,7 @@ mod tests {
|
||||
release_year: None,
|
||||
cover_path: None,
|
||||
file_path: format!("/s/{id}"),
|
||||
content_id: None,
|
||||
audio_format: None,
|
||||
audio_bitrate: None,
|
||||
audio_sample_rate: None,
|
||||
|
||||
+27
-23
@@ -82,20 +82,21 @@ impl Keymap {
|
||||
parse_bindings(DEFAULT_KEYMAP).expect("embedded default keymap must parse");
|
||||
let mut warning = None;
|
||||
if let Some(path) = user_keymap_path()
|
||||
&& path.exists() {
|
||||
match fs::read_to_string(&path)
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|text| parse_bindings(&text))
|
||||
{
|
||||
Ok(user) => merge(&mut bindings, user),
|
||||
Err(err) => {
|
||||
warning = Some(format!(
|
||||
"{} ignored: {err:#}; using default keybindings",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
&& path.exists()
|
||||
{
|
||||
match fs::read_to_string(&path)
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|text| parse_bindings(&text))
|
||||
{
|
||||
Ok(user) => merge(&mut bindings, user),
|
||||
Err(err) => {
|
||||
warning = Some(format!(
|
||||
"{} ignored: {err:#}; using default keybindings",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
let keymap = Self {
|
||||
bindings,
|
||||
pending: Vec::new(),
|
||||
@@ -219,9 +220,11 @@ enum Lookup {
|
||||
/// symbols drop it so a "?" binding matches everywhere.
|
||||
fn normalize(key: KeyCombination) -> KeyCombination {
|
||||
if let crokey::OneToThree::One(KeyCode::Char(c)) = key.codes
|
||||
&& !c.is_alphabetic() && key.modifiers.contains(KeyModifiers::SHIFT) {
|
||||
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
|
||||
}
|
||||
&& !c.is_alphabetic()
|
||||
&& key.modifiers.contains(KeyModifiers::SHIFT)
|
||||
{
|
||||
return KeyCombination::new(KeyCode::Char(c), key.modifiers - KeyModifiers::SHIFT);
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
@@ -302,14 +305,15 @@ fn parse_chord(chord: &str) -> Result<KeyCombination> {
|
||||
// bindings) are built directly.
|
||||
let mut chars = chord.chars();
|
||||
if let (Some(c), None) = (chars.next(), chars.next())
|
||||
&& !c.is_ascii() {
|
||||
let modifiers = if c.is_uppercase() {
|
||||
KeyModifiers::SHIFT
|
||||
} else {
|
||||
KeyModifiers::NONE
|
||||
};
|
||||
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
|
||||
}
|
||||
&& !c.is_ascii()
|
||||
{
|
||||
let modifiers = if c.is_uppercase() {
|
||||
KeyModifiers::SHIFT
|
||||
} else {
|
||||
KeyModifiers::NONE
|
||||
};
|
||||
return Ok(KeyCombination::new(KeyCode::Char(c), modifiers));
|
||||
}
|
||||
KeyCombination::from_str(chord)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.map(normalize)
|
||||
|
||||
+101
-19
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! One byte stream per request: the requester sends one JSON line
|
||||
//! ([`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::sync::Arc;
|
||||
@@ -32,6 +33,11 @@ struct AudioRequest {
|
||||
/// older peers in both directions.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -115,6 +121,16 @@ pub struct TrackMetadata {
|
||||
pub track_number: Option<i32>,
|
||||
#[serde(default)]
|
||||
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 {
|
||||
@@ -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`].
|
||||
pub(super) async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
|
||||
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)?;
|
||||
line.push(b'\n');
|
||||
writer.write_all(&line).await?;
|
||||
@@ -208,6 +234,50 @@ pub struct Downloaded {
|
||||
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
|
||||
/// `dir/<stem>.<ext>`. An already complete cached audio file is reused;
|
||||
/// 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(),
|
||||
offset: 0,
|
||||
want_cover: true,
|
||||
metadata_only: false,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
@@ -243,22 +314,23 @@ pub async fn download_track(
|
||||
|
||||
// The image segments precede the audio bytes and are read regardless of
|
||||
// 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)>> {
|
||||
if size == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
size <= MAX_COVER_BYTES,
|
||||
"{what} of {size} bytes exceeds the {MAX_COVER_BYTES} byte limit"
|
||||
);
|
||||
let mut bytes = vec![0u8; size as usize];
|
||||
stream
|
||||
.recv
|
||||
.read_exact(&mut bytes)
|
||||
.await
|
||||
.with_context(|| format!("stream ended inside the {what} segment"))?;
|
||||
Ok(Some((bytes, image_extension(mime))))
|
||||
};
|
||||
let mut read_image =
|
||||
async |size: u64, mime: &str, what: &str| -> Result<Option<(Vec<u8>, &'static str)>> {
|
||||
if size == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
size <= MAX_COVER_BYTES,
|
||||
"{what} of {size} bytes exceeds the {MAX_COVER_BYTES} byte limit"
|
||||
);
|
||||
let mut bytes = vec![0u8; size as usize];
|
||||
stream
|
||||
.recv
|
||||
.read_exact(&mut bytes)
|
||||
.await
|
||||
.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 artist_image = read_image(
|
||||
header.artist_image_size,
|
||||
@@ -373,6 +445,11 @@ fn resolve_for_serving(
|
||||
year: track.release_year,
|
||||
track_number: track.track_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
|
||||
.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.
|
||||
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.artist_image_path.as_deref()).await,
|
||||
@@ -473,6 +550,11 @@ async fn serve_one(mut stream: ByteStream, library: Arc<Library>, own: EndpointI
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if request.metadata_only {
|
||||
stream.send.finish()?;
|
||||
let _ = stream.send.stopped().await;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((bytes, _)) = &cover {
|
||||
stream.send.write_all(bytes).await?;
|
||||
}
|
||||
|
||||
+244
-37
@@ -1,6 +1,6 @@
|
||||
//! The peer catalog protocol: one peer asks another for its library slice
|
||||
//! of a single artist (releases with full tracklists), used to assemble a
|
||||
//! federated artist card.
|
||||
//! of a single artist (releases with full tracklists plus featured
|
||||
//! appearances), used to assemble a federated artist card.
|
||||
//!
|
||||
//! Wire shape on the `furumi-fd/catalog/1` ALPN: the requester sends one
|
||||
//! JSON line ([`CatalogRequest`]) and finishes; the owner answers with one
|
||||
@@ -67,6 +67,10 @@ pub struct CatalogArtist {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
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)]
|
||||
@@ -81,16 +85,35 @@ pub struct CatalogRelease {
|
||||
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)]
|
||||
pub struct CatalogTrack {
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub artists: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub featured_artists: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub track_number: Option<i32>,
|
||||
#[serde(default)]
|
||||
pub disc_number: Option<i32>,
|
||||
#[serde(default)]
|
||||
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).
|
||||
#[serde(default)]
|
||||
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}'")),
|
||||
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()?;
|
||||
@@ -218,13 +244,29 @@ fn image_mime_by_path(path: &str) -> &'static str {
|
||||
|
||||
/// Builds this instance's library slice for `artist`.
|
||||
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 {
|
||||
ok: false,
|
||||
error: Some("artist not found in the library".to_string()),
|
||||
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 item_id_of = |track_id: i64| -> String {
|
||||
super::audio::hex_encode(
|
||||
@@ -242,25 +284,46 @@ fn build_catalog(library: &Library, own: EndpointId, artist: &str) -> Result<Cat
|
||||
tracks: release
|
||||
.tracks
|
||||
.iter()
|
||||
.map(|track| CatalogTrack {
|
||||
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),
|
||||
})
|
||||
.map(|track| catalog_track(track, item_id_of(track.id)))
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
Ok(CatalogResponse {
|
||||
ok: true,
|
||||
error: None,
|
||||
artist: Some(CatalogArtist {
|
||||
name: detail.name,
|
||||
releases,
|
||||
}),
|
||||
})
|
||||
let mut appears_on = Vec::new();
|
||||
for track in &detail.featured_tracks {
|
||||
let release = library.release(track.release_id)?;
|
||||
appears_on.push(CatalogAppearance {
|
||||
release_title: track.release_title.clone(),
|
||||
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 {
|
||||
#[allow(dead_code, reason = "the open card is keyed by name in AppState")]
|
||||
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.
|
||||
pub peers: usize,
|
||||
/// 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.
|
||||
pub image_path: Option<String>,
|
||||
pub releases: Vec<FedRelease>,
|
||||
pub appears_on: Vec<FedAppearsOn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
@@ -404,12 +471,23 @@ pub struct FedRelease {
|
||||
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)]
|
||||
pub struct FedCardTrack {
|
||||
pub title: String,
|
||||
pub artists: Vec<String>,
|
||||
pub featured_artists: Vec<String>,
|
||||
pub track_number: Option<i32>,
|
||||
pub disc_number: Option<i32>,
|
||||
pub duration_seconds: Option<f64>,
|
||||
pub content_id: Option<String>,
|
||||
/// Every peer that can serve this track: (owner hex, item id hex).
|
||||
/// Duplicates collapse into one row; all sources stay playable.
|
||||
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 mut releases: Vec<FedRelease> = Vec::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();
|
||||
for (owner_hex, catalog) in catalogs {
|
||||
@@ -463,31 +543,67 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed
|
||||
});
|
||||
match existing {
|
||||
Some(t) => {
|
||||
if t.track_number.is_none() {
|
||||
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));
|
||||
merge_card_track(t, &owner_hex, track);
|
||||
}
|
||||
None => merged.tracks.push(FedCardTrack {
|
||||
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)],
|
||||
}),
|
||||
None => merged.tracks.push(card_track(owner_hex.clone(), track)),
|
||||
}
|
||||
}
|
||||
}
|
||||
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 {
|
||||
release
|
||||
.tracks
|
||||
.sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(i32::MAX)));
|
||||
release.tracks.sort_by_key(|t| {
|
||||
(
|
||||
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| {
|
||||
a.year
|
||||
.unwrap_or(i32::MAX)
|
||||
@@ -497,10 +613,50 @@ pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> Fed
|
||||
|
||||
FedArtistCard {
|
||||
name: name.to_string(),
|
||||
own_owner: None,
|
||||
peers,
|
||||
owners: card_owners,
|
||||
image_path: None,
|
||||
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 {
|
||||
CatalogTrack {
|
||||
title: title.into(),
|
||||
artists: vec!["Metallica".into()],
|
||||
featured_artists: Vec::new(),
|
||||
track_number: Some(number),
|
||||
disc_number: None,
|
||||
duration_seconds: Some(100.0),
|
||||
content_id: None,
|
||||
item_id: item.into(),
|
||||
}
|
||||
}
|
||||
@@ -531,6 +690,7 @@ mod tests {
|
||||
track("Sad But True", 2, &format!("{item_prefix}2")),
|
||||
],
|
||||
}],
|
||||
appears_on: Vec::new(),
|
||||
};
|
||||
let card = merge_catalogs(
|
||||
"Metallica",
|
||||
@@ -547,4 +707,51 @@ mod tests {
|
||||
// Both peers stay as sources of the deduplicated track.
|
||||
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 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).
|
||||
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
|
||||
/// and whether a new row was created.
|
||||
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 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,
|
||||
duration_seconds = ?5, release_id = ?6, audio_format = ?7,
|
||||
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",
|
||||
params![
|
||||
id,
|
||||
@@ -294,6 +295,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
||||
import.audio_sample_rate,
|
||||
import.audio_bit_depth,
|
||||
import.file_size_bytes,
|
||||
content_id.as_deref(),
|
||||
],
|
||||
)?;
|
||||
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(
|
||||
"INSERT INTO tracks (title, track_number, disc_number, duration_seconds,
|
||||
release_id, file_path, audio_format, audio_bitrate, audio_sample_rate,
|
||||
audio_bit_depth, file_size_bytes)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
audio_bit_depth, file_size_bytes, content_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
import.title,
|
||||
import.track_number,
|
||||
@@ -317,6 +319,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
||||
import.audio_sample_rate,
|
||||
import.audio_bit_depth,
|
||||
import.file_size_bytes,
|
||||
content_id.as_deref(),
|
||||
],
|
||||
)?;
|
||||
(tx.last_insert_rowid(), true)
|
||||
@@ -348,9 +351,7 @@ pub fn upsert_track(library: &Library, import: &TrackImport) -> Result<(i64, boo
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if !has_cover
|
||||
&& let Some(cover_path) = resolve_cover(library, release_id, import)
|
||||
{
|
||||
if !has_cover && let Some(cover_path) = resolve_cover(library, release_id, import) {
|
||||
tx.execute(
|
||||
"UPDATE releases SET cover_path = ?2 WHERE id = ?1",
|
||||
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")
|
||||
);
|
||||
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());
|
||||
}
|
||||
|
||||
+194
-39
@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS tracks (
|
||||
duration_seconds REAL NOT NULL DEFAULT 0,
|
||||
release_id INTEGER NOT NULL REFERENCES releases(id) ON DELETE CASCADE,
|
||||
file_path TEXT NOT NULL UNIQUE,
|
||||
content_id TEXT,
|
||||
audio_format TEXT,
|
||||
audio_bitrate INTEGER,
|
||||
audio_sample_rate INTEGER,
|
||||
@@ -86,8 +87,10 @@ CREATE TABLE IF NOT EXISTS fed_likes (
|
||||
owner TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
artist_names TEXT NOT NULL DEFAULT '',
|
||||
featured_artist_names TEXT NOT NULL DEFAULT '',
|
||||
year INTEGER,
|
||||
duration_seconds REAL,
|
||||
content_id TEXT,
|
||||
release_title TEXT,
|
||||
track_number INTEGER,
|
||||
disc_number INTEGER,
|
||||
@@ -115,6 +118,7 @@ const TRACK_COLUMNS: &str = "
|
||||
t.release_id, r.title, r.year, r.cover_path,
|
||||
t.file_path, t.audio_format, t.audio_bitrate, t.audio_sample_rate,
|
||||
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)
|
||||
";
|
||||
|
||||
@@ -143,6 +147,12 @@ pub struct ExportTrack {
|
||||
pub year: Option<i32>,
|
||||
pub duration_seconds: f64,
|
||||
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 {
|
||||
@@ -169,6 +179,7 @@ impl Library {
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
register_norm_function(&conn)?;
|
||||
conn.execute_batch(SCHEMA).context("applying schema")?;
|
||||
ensure_schema_migrations(&conn)?;
|
||||
let covers_dir = db_path
|
||||
.parent()
|
||||
.map(|dir| dir.join("covers"))
|
||||
@@ -254,7 +265,7 @@ impl Library {
|
||||
JOIN releases r ON r.id = t.release_id
|
||||
JOIN track_artists ta ON ta.track_id = t.id
|
||||
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"
|
||||
),
|
||||
params![id],
|
||||
@@ -395,11 +406,13 @@ impl Library {
|
||||
),
|
||||
params![pattern, limit],
|
||||
)?;
|
||||
Ok(SearchResults {
|
||||
let mut results = SearchResults {
|
||||
artists,
|
||||
releases,
|
||||
tracks,
|
||||
})
|
||||
};
|
||||
rank_search_results(&mut results, &pattern);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Everything the federation publishes into the DHT: plain rows, so the
|
||||
@@ -417,12 +430,13 @@ impl Library {
|
||||
ORDER BY ra.release_id, ra.position",
|
||||
)?;
|
||||
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?;
|
||||
release_artists.entry(id).or_default().push(name);
|
||||
}
|
||||
let mut statement =
|
||||
conn.prepare("SELECT id, title, year, release_type FROM releases")?;
|
||||
let mut statement = conn.prepare("SELECT id, title, year, release_type FROM releases")?;
|
||||
let releases = statement
|
||||
.query_map([], |row| {
|
||||
Ok(ExportRelease {
|
||||
@@ -442,36 +456,76 @@ impl Library {
|
||||
.collect();
|
||||
|
||||
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
|
||||
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();
|
||||
for row in statement.query_map([], |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)))? {
|
||||
let (id, name) = row?;
|
||||
track_artists.entry(id).or_default().push(name);
|
||||
let mut featured_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)?,
|
||||
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(
|
||||
"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",
|
||||
)?;
|
||||
let tracks = statement
|
||||
let track_rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok(ExportTrack {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
year: row.get(2)?,
|
||||
duration_seconds: row.get(3)?,
|
||||
artist_names: Vec::new(),
|
||||
})
|
||||
Ok((
|
||||
ExportTrack {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
year: row.get(2)?,
|
||||
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<_>>>()?
|
||||
.into_iter()
|
||||
.map(|mut track| {
|
||||
track.artist_names = track_artists.remove(&track.id).unwrap_or_default();
|
||||
track
|
||||
})
|
||||
.collect();
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
drop(statement);
|
||||
let mut tracks = Vec::with_capacity(track_rows.len());
|
||||
for (mut track, raw_content_id, file_path) in track_rows {
|
||||
let mut content_id = raw_content_id
|
||||
.as_deref()
|
||||
.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 {
|
||||
artists,
|
||||
@@ -649,8 +703,8 @@ impl Library {
|
||||
pub fn fed_likes(&self) -> Result<Vec<crate::federation::FedTrack>> {
|
||||
let conn = self.lock();
|
||||
let mut statement = conn.prepare(
|
||||
"SELECT item_id, owner, title, artist_names, year, duration_seconds,
|
||||
release_title, track_number, disc_number
|
||||
"SELECT item_id, owner, title, artist_names, featured_artist_names,
|
||||
year, duration_seconds, content_id, release_title, track_number, disc_number
|
||||
FROM fed_likes ORDER BY liked_at DESC",
|
||||
)?;
|
||||
let rows = statement
|
||||
@@ -666,11 +720,18 @@ impl Library {
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
year: row.get(4)?,
|
||||
duration_seconds: row.get::<_, Option<f64>>(5)?.map(|d| d.round() as i64),
|
||||
release_title: row.get(6)?,
|
||||
track_number: row.get(7)?,
|
||||
disc_number: row.get(8)?,
|
||||
featured_artist_names: row
|
||||
.get::<_, String>(4)?
|
||||
.split("; ")
|
||||
.filter(|name| !name.is_empty())
|
||||
.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<_>>>()?;
|
||||
@@ -695,16 +756,19 @@ impl Library {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO fed_likes (item_id, owner, title, artist_names, year,
|
||||
duration_seconds, release_title, track_number, disc_number)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
"INSERT INTO fed_likes (item_id, owner, title, artist_names,
|
||||
featured_artist_names, year, duration_seconds, content_id,
|
||||
release_title, track_number, disc_number)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
|
||||
params![
|
||||
fed.item_id,
|
||||
fed.owner,
|
||||
fed.title,
|
||||
fed.artist_names.join("; "),
|
||||
fed.featured_artist_names.join("; "),
|
||||
fed.year,
|
||||
fed.duration_seconds.map(|d| d as f64),
|
||||
fed.content_id,
|
||||
fed.release_title,
|
||||
fed.track_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.
|
||||
fn query_tracks(
|
||||
conn: &Connection,
|
||||
@@ -1010,7 +1112,11 @@ fn query_tracks(
|
||||
audio_sample_rate: row.get(12)?,
|
||||
audio_bit_depth: row.get(13)?,
|
||||
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(),
|
||||
featured_artists: Vec::new(),
|
||||
fed: None,
|
||||
@@ -1064,6 +1170,42 @@ fn register_norm_function(conn: &Connection) -> Result<()> {
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1143,6 +1285,18 @@ mod tests {
|
||||
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]
|
||||
fn search_folds_case_beyond_ascii() {
|
||||
let lib = test_library();
|
||||
@@ -1159,7 +1313,8 @@ mod tests {
|
||||
let lib = test_library();
|
||||
let track_id = add_track(&lib, "Song", "Artist", "Album");
|
||||
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!(lib.toggle_like(track_id).unwrap());
|
||||
|
||||
+59
-5
@@ -31,6 +31,8 @@ pub struct TrackItem {
|
||||
pub release_year: Option<i32>,
|
||||
/// Absolute path to the local audio file.
|
||||
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).
|
||||
pub cover_path: Option<String>,
|
||||
pub audio_format: Option<String>,
|
||||
@@ -53,12 +55,24 @@ impl TrackItem {
|
||||
}
|
||||
|
||||
pub fn artist_line(&self) -> String {
|
||||
let mut names: Vec<&str> = self.artists.iter().map(|a| a.name.as_str()).collect();
|
||||
if !self.featured_artists.is_empty() {
|
||||
names.push("feat.");
|
||||
names.extend(self.featured_artists.iter().map(|a| a.name.as_str()));
|
||||
let artists = self
|
||||
.artists
|
||||
.iter()
|
||||
.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 {
|
||||
@@ -183,3 +197,43 @@ pub struct ReleaseEdit {
|
||||
pub year: Option<i32>,
|
||||
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 media;
|
||||
mod player;
|
||||
mod share;
|
||||
mod ui;
|
||||
|
||||
use std::io;
|
||||
|
||||
+4
-3
@@ -258,9 +258,10 @@ fn handle(
|
||||
}
|
||||
Command::Seek(position) => {
|
||||
if let Some(out) = output
|
||||
&& let Err(err) = out.player.try_seek(position) {
|
||||
tracing::warn!(%err, "seek failed");
|
||||
}
|
||||
&& let Err(err) = out.player.try_seek(position)
|
||||
{
|
||||
tracing::warn!(%err, "seek failed");
|
||||
}
|
||||
}
|
||||
Command::SetVolume(volume) => {
|
||||
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 line = Line::from(vec![
|
||||
Span::styled(marker, theme::accent()),
|
||||
Span::styled(format!("{label:<label_width$}"), if selected {
|
||||
theme::accent()
|
||||
} else {
|
||||
ratatui::style::Style::default()
|
||||
}),
|
||||
Span::styled(
|
||||
format!("{label:<label_width$}"),
|
||||
if selected {
|
||||
theme::accent()
|
||||
} else {
|
||||
ratatui::style::Style::default()
|
||||
},
|
||||
),
|
||||
Span::styled(value, theme::dim()),
|
||||
]);
|
||||
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 super::{art, theme};
|
||||
use crate::library::models::{ArtistCard, ReleaseCard, SearchResults};
|
||||
use crate::app::state::{
|
||||
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::library::models::{ArtistCard, ReleaseCard, SearchResults};
|
||||
|
||||
const TILE_MARQUEE_STEP_MS: u128 = 250;
|
||||
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()),
|
||||
);
|
||||
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![
|
||||
Line::default(),
|
||||
Line::styled(name.clone(), theme::header()),
|
||||
Line::default(),
|
||||
Line::styled(
|
||||
format!(
|
||||
"{} releases · {} tracks · from {} peers",
|
||||
card.releases.len(),
|
||||
tracks_total,
|
||||
card.peers
|
||||
),
|
||||
theme::dim(),
|
||||
),
|
||||
Line::styled("enter: open a release · esc: back", theme::dim()),
|
||||
Line::styled(stats, theme::dim()),
|
||||
Line::styled("enter: open release / play track · esc: back", theme::dim()),
|
||||
];
|
||||
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(
|
||||
frame,
|
||||
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 visible_rows = usize::from((content_area.height / TILE_HEIGHT).max(1));
|
||||
let cursor_row = cursor / columns;
|
||||
let total_rows = card.releases.len().div_ceil(columns);
|
||||
let first_row = cursor_row
|
||||
.saturating_sub(visible_rows / 2)
|
||||
.min(total_rows.saturating_sub(visible_rows));
|
||||
for (offset, row) in (first_row..total_rows).take(visible_rows).enumerate() {
|
||||
for column in 0..columns {
|
||||
let index = row * columns + column;
|
||||
let Some(release) = card.releases.get(index) else {
|
||||
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 releases_len = card.releases.len();
|
||||
let mut items = Vec::new();
|
||||
let mut cursor_item = None;
|
||||
let mut position = 0;
|
||||
for (label, group) in fed_release_groups(&card.releases) {
|
||||
items.push(PlanItem::Header(format!("{label} ({})", group.len())));
|
||||
for chunk in group.chunks(columns) {
|
||||
let row: Vec<usize> = (position..position + chunk.len()).collect();
|
||||
if row.contains(&cursor) {
|
||||
cursor_item = Some(items.len());
|
||||
}
|
||||
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,
|
||||
index == cursor,
|
||||
);
|
||||
items.push(PlanItem::TileRow(row));
|
||||
position += chunk.len();
|
||||
}
|
||||
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()),
|
||||
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);
|
||||
|
||||
|
||||
+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> {
|
||||
let mut spans: Vec<Span<'static>> = Vec::new();
|
||||
if let Some(track) = &player.current
|
||||
&& player.playing {
|
||||
let bar_width: usize = match width {
|
||||
0..=59 => 0,
|
||||
60..=79 => 8,
|
||||
80..=109 => 14,
|
||||
_ => 22,
|
||||
};
|
||||
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
|
||||
if bar_width > 0 && track.duration_seconds > 0.0 {
|
||||
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
|
||||
let filled = (ratio * bar_width as f64).round() as usize;
|
||||
spans.push(Span::styled("━".repeat(filled), theme::accent()));
|
||||
spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim()));
|
||||
spans.push(Span::raw(" "));
|
||||
} 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(),
|
||||
));
|
||||
}
|
||||
&& player.playing
|
||||
{
|
||||
let bar_width: usize = match width {
|
||||
0..=59 => 0,
|
||||
60..=79 => 8,
|
||||
80..=109 => 14,
|
||||
_ => 22,
|
||||
};
|
||||
spans.push(Span::raw(format!("{} ", format_secs(player.position_secs))));
|
||||
if bar_width > 0 && track.duration_seconds > 0.0 {
|
||||
let ratio = (player.position_secs / track.duration_seconds).clamp(0.0, 1.0);
|
||||
let filled = (ratio * bar_width as f64).round() as usize;
|
||||
spans.push(Span::styled("━".repeat(filled), theme::accent()));
|
||||
spans.push(Span::styled("─".repeat(bar_width - filled), theme::dim()));
|
||||
spans.push(Span::raw(" "));
|
||||
} 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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if width >= 80 {
|
||||
let volume_cells = usize::from(player.volume / 10);
|
||||
spans.extend([
|
||||
@@ -277,11 +278,9 @@ fn draw_status(frame: &mut Frame, area: Rect, state: &AppState) {
|
||||
// truncates into whatever is left.
|
||||
let center = player_right_line(player, area.width);
|
||||
let center_width = (center.width() as u16).min(area.width);
|
||||
let [title_area, right_area] = Layout::horizontal([
|
||||
Constraint::Min(8),
|
||||
Constraint::Length(center_width),
|
||||
])
|
||||
.areas(player_row);
|
||||
let [title_area, right_area] =
|
||||
Layout::horizontal([Constraint::Min(8), Constraint::Length(center_width)])
|
||||
.areas(player_row);
|
||||
|
||||
let mut spans = Vec::new();
|
||||
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 text_width = usize::from(width.saturating_sub(2));
|
||||
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()
|
||||
.title(format!(" {title} "))
|
||||
.title_style(theme::header())
|
||||
@@ -99,11 +103,13 @@ fn draw_edit(
|
||||
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
let focused = index == focus;
|
||||
let field_block = Block::bordered().title(field.label).border_style(if focused {
|
||||
theme::accent()
|
||||
} else {
|
||||
theme::dim()
|
||||
});
|
||||
let field_block = Block::bordered()
|
||||
.title(field.label)
|
||||
.border_style(if focused {
|
||||
theme::accent()
|
||||
} else {
|
||||
theme::dim()
|
||||
});
|
||||
let field_inner = field_block.inner(areas[index]);
|
||||
frame.render_widget(field_block, areas[index]);
|
||||
let width = usize::from(field_inner.width);
|
||||
@@ -218,8 +224,13 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol
|
||||
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"
|
||||
} else if can_share {
|
||||
"j/k scroll · c copy frid link · esc close"
|
||||
} else {
|
||||
"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>> {
|
||||
vec![
|
||||
field("ID", track.id.to_string()),
|
||||
let mut lines = vec![
|
||||
field("ID", row_id(track.id)),
|
||||
field("Title", track.title.clone()),
|
||||
field("Artists", artist_refs(&track.artists)),
|
||||
field("Featured artists", artist_refs(&track.featured_artists)),
|
||||
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("Track number", opt_display(track.track_number)),
|
||||
field(
|
||||
@@ -264,9 +275,23 @@ fn track_info_lines(track: &TrackItem) -> Vec<Line<'static>> {
|
||||
),
|
||||
field("File size", file_size(track.file_size_bytes)),
|
||||
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("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> {
|
||||
@@ -282,11 +307,25 @@ fn artist_refs(items: &[ArtistRef]) -> String {
|
||||
}
|
||||
items
|
||||
.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<_>>()
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
fn row_id(id: i64) -> String {
|
||||
if id >= 0 {
|
||||
id.to_string()
|
||||
} else {
|
||||
"—".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn release_label(track: &TrackItem) -> String {
|
||||
let mut label = empty_dash(&track.release_title);
|
||||
if let Some(year) = track.release_year {
|
||||
|
||||
Reference in New Issue
Block a user