diff --git a/Cargo.lock b/Cargo.lock index 81f12d4..47955db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index ff6586f..622fb9e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/app/action.rs b/src/app/action.rs index 54a153a..89b3a73 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -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, diff --git a/src/app/cmdline.rs b/src/app/cmdline.rs index 221254f..509bf9f 100644 --- a/src/app/cmdline.rs +++ b/src/app/cmdline.rs @@ -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://".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); diff --git a/src/app/command.rs b/src/app/command.rs index 1b276b2..ca2eca9 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -25,6 +25,8 @@ pub enum Command { /// `:import ` — 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 ".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://".to_string()), + } + } "volume" | "vol" => match arg.and_then(|a| a.parse::().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)); diff --git a/src/app/event.rs b/src/app/event.rs index 2a4dbbb..598e9ec 100644 --- a/src/app/event.rs +++ b/src/app/event.rs @@ -119,6 +119,13 @@ pub enum AppEvent { placeholder_id: i64, result: Result, String>, }, + /// Rich metadata for a federated track-info preview arrived without + /// downloading the audio file. + FedTrackInfoLoaded { + placeholder_id: i64, + item_id: String, + result: Result, + }, /// This peer's connection ticket, requested from the Federation tab. FedTicket(Result), } diff --git a/src/app/mod.rs b/src/app/mod.rs index 990fa81..53f71a5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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) { diff --git a/src/app/popup.rs b/src/app/popup.rs index 3167c4c..3fd3779 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -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 // --------------------------------------------------------------------------- diff --git a/src/app/state.rs b/src/app/state.rs index d03d389..416a7db 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -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)> { + release_type_groups(releases, |release| &release.release_type) +} + +fn release_type_groups( + items: &[T], + release_type: impl Fn(&T) -> &str, +) -> Vec<(&'static str, Vec)> { const GROUPS: [(&str, &str); 4] = [ ("album", "Albums"), - ("ep", "EPs"), ("single", "Singles"), + ("ep", "EPs"), ("compilation", "Compilations"), ]; let mut groups: Vec<(&'static str, Vec)> = Vec::new(); for (kind, label) in GROUPS { - let indices: Vec = releases + let indices: Vec = 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 } } let known: Vec = groups.iter().flat_map(|(_, v)| v.iter().copied()).collect(); - let other: Vec = (0..releases.len()).filter(|i| !known.contains(i)).collect(); + let other: Vec = (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 { /// 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> { + grouped_release_rows(release_groups(releases), columns) +} + +pub fn fed_release_groups( + releases: &[crate::federation::FedRelease], +) -> Vec<(&'static str, Vec)> { + 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 { + 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> { + grouped_release_rows(fed_release_groups(releases), columns) +} + +fn grouped_release_rows( + groups: Vec<(&'static str, Vec)>, + columns: usize, +) -> Vec> { 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 diff --git a/src/app/update.rs b/src/app/update.rs index 793579e..d243e28 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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, }, + /// 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 { @@ -280,14 +285,25 @@ pub fn update(state: &mut AppState, action: Action) -> Option { 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::>(); 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 { } } +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, -) -> super::state::Popup { +fn artist_edit_popup(id: i64, name: &str, image_path: Option) -> 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 { 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 { 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 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 { } } } - 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 { 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 { 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, 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 { }, 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 { + 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 { 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 { + 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 { 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 { + 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 { + 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 { + let mut names: Vec = 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 { @@ -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 = 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, diff --git a/src/config/keymap.rs b/src/config/keymap.rs index dc23333..061e013 100644 --- a/src/config/keymap.rs +++ b/src/config/keymap.rs @@ -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 { // 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) diff --git a/src/federation/audio.rs b/src/federation/audio.rs index 8fe0da2..b8c81db 100644 --- a/src/federation/audio.rs +++ b/src/federation/audio.rs @@ -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, #[serde(default)] pub disc_number: Option, + #[serde(default)] + pub duration_seconds: Option, + #[serde(default)] + pub audio_format: Option, + #[serde(default)] + pub audio_bitrate: Option, + #[serde(default)] + pub audio_sample_rate: Option, + #[serde(default)] + pub audio_bit_depth: Option, } 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 { + 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(reader: &mut R) -> Result> { let mut line = Vec::new(); @@ -186,7 +209,10 @@ pub(super) async fn read_line(reader: &mut R) -> Result(writer: &mut W, value: &impl Serialize) -> Result<()> { +async fn write_line( + 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, &'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, +} + +/// 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 { + 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/.`. 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, &'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, &'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, 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, 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?; } diff --git a/src/federation/catalog.rs b/src/federation/catalog.rs index 5bfd060..9f23ce2 100644 --- a/src/federation/catalog.rs +++ b/src/federation/catalog.rs @@ -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, + /// Tracks where the requested artist is featured instead of being a + /// release/main artist. + #[serde(default)] + pub appears_on: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] @@ -81,16 +85,35 @@ pub struct CatalogRelease { pub tracks: Vec, } +#[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, + #[serde(default)] + pub track: CatalogTrack, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CatalogTrack { #[serde(default)] pub title: String, #[serde(default)] + pub artists: Vec, + #[serde(default)] + pub featured_artists: Vec, + #[serde(default)] pub track_number: Option, #[serde(default)] pub disc_number: Option, #[serde(default)] pub duration_seconds: Option, + /// Stable audio content id (`b3:<64 hex>`) when known. + #[serde(default)] + pub content_id: Option, /// 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, 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 { - 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> { + 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 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, /// 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, pub releases: Vec, + pub appears_on: Vec, } #[derive(Debug, Clone, Default)] @@ -404,12 +471,23 @@ pub struct FedRelease { pub tracks: Vec, } +#[derive(Debug, Clone, Default)] +pub struct FedAppearsOn { + pub release_title: String, + pub release_type: String, + pub year: Option, + pub track: FedCardTrack, +} + #[derive(Debug, Clone, Default)] pub struct FedCardTrack { pub title: String, + pub artists: Vec, + pub featured_artists: Vec, pub track_number: Option, pub disc_number: Option, pub duration_seconds: Option, + pub content_id: Option, /// 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 = Vec::new(); let mut release_index: HashMap = HashMap::new(); + let mut appears_on: Vec = Vec::new(); + let mut appearance_index: HashMap = HashMap::new(); let mut card_owners: Vec = 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"]); + } } diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 8a20348..01d2f5d 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -15,7 +15,7 @@ mod audio; pub mod catalog; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; use std::sync::atomic::{AtomicI64, Ordering}; @@ -23,8 +23,8 @@ use std::time::Duration; use anyhow::{Context, Result}; use music_dht::{ - EndpointId, ItemKind, ItemSpec, MusicDhtConfig, MusicDhtService, NetworkId, PeerTicket, - RendezvousConfig, + EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, + PeerTicket, PublishStats, RendezvousConfig, SyncStats, }; use serde::{Deserialize, Serialize}; @@ -32,7 +32,7 @@ use crate::library::Library; use crate::library::models::{ArtistRef, TrackItem}; pub use audio::{AUDIO_ALPN, TrackMetadata}; -pub use catalog::{CATALOG_ALPN, FedArtistCard, FedCardTrack, FedRelease}; +pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRelease}; /// How often the published library is re-synchronized with the local index. const SYNC_INTERVAL: Duration = Duration::from_secs(60); @@ -115,8 +115,11 @@ pub struct FedTrack { pub own: bool, pub title: String, pub artist_names: Vec, + pub featured_artist_names: Vec, pub year: Option, pub duration_seconds: Option, + /// Stable audio content id (`b3:<64 hex>`) when the owner published it. + pub content_id: Option, /// Release context, known when the track came from an artist card. pub release_title: Option, pub track_number: Option, @@ -125,7 +128,7 @@ pub struct FedTrack { impl FedTrack { pub fn artist_line(&self) -> String { - self.artist_names.join(", ") + artist_line(&self.artist_names, &self.featured_artist_names) } pub fn owner_short(&self) -> String { @@ -176,14 +179,52 @@ pub struct Federation { data_dir: PathBuf, cache_dir: PathBuf, media_dir: PathBuf, + metadata_cache: std::sync::Mutex>, settings: std::sync::Mutex, running: tokio::sync::Mutex>, last_sync: std::sync::Mutex>, last_error: std::sync::Mutex>, } +#[derive(Debug, Clone)] +struct CachedTrackMetadata { + fed: FedTrack, + title: String, + artists: Vec, + featured_artists: Vec, + release_title: Option, + release_type: Option, + year: Option, + duration_seconds: Option, + track_number: Option, + disc_number: Option, +} + +impl CachedTrackMetadata { + fn to_fed_track(&self) -> FedTrack { + FedTrack { + item_id: self.fed.item_id.clone(), + owner: self.fed.owner.clone(), + own: self.fed.own, + title: self.title.clone(), + artist_names: self.artists.clone(), + featured_artist_names: self.featured_artists.clone(), + year: self.year, + duration_seconds: self + .duration_seconds + .map(|duration| duration.round() as i64), + content_id: self.fed.content_id.clone(), + release_title: self.release_title.clone(), + track_number: self.track_number, + disc_number: self.disc_number, + } + } +} + fn lock(mutex: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { - mutex.lock().unwrap_or_else(std::sync::PoisonError::into_inner) + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) } fn now_label() -> String { @@ -221,6 +262,7 @@ impl Federation { data_dir, cache_dir, media_dir, + metadata_cache: std::sync::Mutex::new(Default::default()), settings: std::sync::Mutex::new(load_settings()), running: tokio::sync::Mutex::new(None), last_sync: std::sync::Mutex::new(None), @@ -232,6 +274,10 @@ impl Federation { lock(&self.settings).clone() } + fn cached_metadata_snapshot(&self) -> Vec { + lock(&self.metadata_cache).values().cloned().collect() + } + fn set_error(&self, message: Option) { *lock(&self.last_error) = message; } @@ -311,7 +357,7 @@ impl Federation { let mut interval = tokio::time::interval(SYNC_INTERVAL); loop { interval.tick().await; - sync_self.sync_once(&sync_service).await; + let _ = sync_self.sync_once(&sync_service).await; } }); // Serve audio requests from other peers of the network. @@ -364,17 +410,28 @@ impl Federation { async fn spawn_sync_soon(self: &Arc) { if let Ok(service) = self.service().await { let fed = Arc::clone(self); - tokio::spawn(async move { fed.sync_once(&service).await }); + tokio::spawn(async move { + let _ = fed.sync_once(&service).await; + }); } } pub async fn sync_now(self: &Arc) -> Result<()> { let service = self.service().await?; - self.sync_once(&service).await; + let sync_stats = self.sync_once(&service).await?; + let publish_stats = match service.republish().await { + Ok(stats) => stats, + Err(err) => { + tracing::warn!("federation republish failed: {err}"); + self.set_error(Some(format!("republish failed: {err}"))); + anyhow::bail!("republish failed: {err}"); + } + }; + self.record_publish_success(sync_stats, publish_stats); Ok(()) } - async fn sync_once(&self, service: &MusicDhtService) { + async fn sync_once(&self, service: &MusicDhtService) -> Result { let library = Arc::clone(&self.library); let specs = tokio::task::spawn_blocking(move || collect_specs(&library)).await; let specs = match specs { @@ -382,24 +439,16 @@ impl Federation { Ok(Err(err)) => { tracing::warn!("federation sync: library read failed: {err:#}"); self.set_error(Some(format!("library read failed: {err}"))); - return; + anyhow::bail!("library read failed: {err}"); } Err(err) => { tracing::warn!("federation sync task failed: {err}"); - return; + anyhow::bail!("sync task failed: {err}"); } }; match service.sync_library(specs).await { Ok(stats) => { - *lock(&self.last_sync) = Some(format!( - "{} (+{} ~{} −{}, unchanged {}, failed {})", - now_label(), - stats.added, - stats.updated, - stats.removed, - stats.unchanged, - stats.failed - )); + self.record_sync_success(stats); if stats.failed > 0 { self.set_error(Some(format!( "{} item(s) failed to publish in the last sync", @@ -408,14 +457,44 @@ impl Federation { } else { self.set_error(None); } + Ok(stats) } Err(err) => { tracing::warn!("federation sync failed: {err}"); self.set_error(Some(format!("sync failed: {err}"))); + Err(anyhow::anyhow!("sync failed: {err}")) } } } + fn record_sync_success(&self, stats: SyncStats) { + *lock(&self.last_sync) = Some(format!( + "{} (+{} ~{} −{}, unchanged {}, failed {})", + now_label(), + stats.added, + stats.updated, + stats.removed, + stats.unchanged, + stats.failed + )); + } + + fn record_publish_success(&self, sync_stats: SyncStats, publish_stats: PublishStats) { + *lock(&self.last_sync) = Some(format!( + "{} (+{} ~{} −{}, unchanged {}, failed {}; republished {} records, {} keys, remote nodes {})", + now_label(), + sync_stats.added, + sync_stats.updated, + sync_stats.removed, + sync_stats.unchanged, + sync_stats.failed, + publish_stats.records, + publish_stats.keys, + publish_stats.remote_nodes, + )); + self.set_error(None); + } + pub async fn status(&self) -> FedStatus { let settings = self.settings(); let guard = self.running.lock().await; @@ -450,12 +529,13 @@ impl Federation { /// names of matching tracks/releases). pub async fn search(&self, query: &str) -> Result { let service = self.service().await?; + let normalized = music_dht::normalize_name(query); let outcome = service .search_network(query) .await .map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?; let own = service.endpoint_id(); - let tracks: Vec = outcome + let mut tracks: Vec = outcome .network_results .iter() .filter(|item| item.kind == ItemKind::Track) @@ -465,18 +545,24 @@ impl Federation { own: item.owner == own, title: item.name.clone(), artist_names: item.artist_names.clone(), + featured_artist_names: item.featured_artist_names.clone(), year: item.year, duration_seconds: item.duration_seconds.map(|d| d.round() as i64), - release_title: None, - track_number: None, - disc_number: None, + content_id: item.content_id.clone(), + release_title: item.release_title.clone(), + track_number: item.track_number, + disc_number: item.disc_number, }) .collect(); + let mut seen_tracks: std::collections::HashSet<(String, String)> = tracks + .iter() + .map(|track| (track.owner.clone(), track.item_id.clone())) + .collect(); // Artists: normalized name -> (display name, distinct non-own peers). let mut artists: std::collections::HashMap< String, - (String, std::collections::HashSet), + (String, std::collections::HashSet), > = Default::default(); for item in &outcome.network_results { if item.owner == own { @@ -490,7 +576,7 @@ impl Federation { let entry = artists .entry(key) .or_insert_with(|| (name.to_string(), Default::default())); - entry.1.insert(item.owner); + entry.1.insert(item.owner.to_string()); }; if item.kind == ItemKind::Artist { note(&item.name); @@ -498,6 +584,30 @@ impl Federation { for artist in &item.artist_names { note(artist); } + for artist in &item.featured_artist_names { + note(artist); + } + } + for cached in self.cached_metadata_snapshot() { + if !cached_matches_query(&cached, &normalized) { + continue; + } + let fed = cached.to_fed_track(); + if seen_tracks.insert((fed.owner.clone(), fed.item_id.clone())) { + tracks.push(fed); + } + if !cached.fed.own { + for artist in &cached.featured_artists { + let key = music_dht::normalize_name(artist); + if key.is_empty() { + continue; + } + let entry = artists + .entry(key) + .or_insert_with(|| (artist.clone(), Default::default())); + entry.1.insert(cached.fed.owner.clone()); + } + } } let mut artists: Vec = artists .into_values() @@ -507,21 +617,59 @@ impl Federation { }) .collect(); artists.sort_by(|a, b| b.peers.cmp(&a.peers).then_with(|| a.name.cmp(&b.name))); + rank_fed_search_results(&mut artists, &mut tracks, &normalized); Ok(FedSearchResults { artists, tracks }) } + /// Resolves a share-link content id to one playable federated track. + pub async fn track_by_content_id(&self, content_id: &str) -> Result { + let service = self.service().await?; + let outcome = service + .search_content_id(content_id) + .await + .map_err(|err| anyhow::anyhow!("federated content lookup failed: {err}"))?; + let own = service.endpoint_id(); + outcome + .local_results + .into_iter() + .chain(outcome.network_results.into_iter()) + .find(|item| item.kind == ItemKind::Track) + .map(|item| FedTrack { + item_id: audio::hex_encode(item.id.as_bytes()), + owner: item.owner.to_string(), + own: item.owner == own, + title: item.name, + artist_names: item.artist_names, + featured_artist_names: item.featured_artist_names, + year: item.year, + duration_seconds: item.duration_seconds.map(|d| d.round() as i64), + content_id: item.content_id, + release_title: item.release_title, + track_number: item.track_number, + disc_number: item.disc_number, + }) + .context("no peers published this shared track") + } + /// Assembles the federated artist card: finds the peers holding the /// artist through the DHT, asks each for its catalog slice directly and - /// merges the answers (missing/slow peers are skipped). + /// merges the answers (missing/slow peers are skipped). Role-aware DHT + /// track records are also folded in, so featured appearances can be shown + /// even before a peer's catalog response arrives. pub async fn artist_card(&self, name: &str) -> Result { let service = self.service().await?; let own = service.endpoint_id(); + let own_hex = own.to_string(); let normalized = music_dht::normalize_name(name); let outcome = service .search_network(name) .await .map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?; + let cached_metadata = self.cached_metadata_snapshot(); + let has_cached_artist = cached_metadata + .iter() + .any(|cached| cached_has_artist(cached, &normalized)); let owners: std::collections::HashSet = outcome .network_results .iter() @@ -531,11 +679,37 @@ impl Federation { .artist_names .iter() .any(|artist| music_dht::normalize_name(artist) == normalized) + || item + .featured_artist_names + .iter() + .any(|artist| music_dht::normalize_name(artist) == normalized) }) .map(|item| item.owner) .filter(|owner| *owner != own) .collect(); - anyhow::ensure!(!owners.is_empty(), "no peers hold artist \"{name}\""); + + let local_library = Arc::clone(&self.library); + let local_name = name.to_string(); + let mut catalogs = match tokio::task::spawn_blocking(move || { + catalog::build_catalog_artist(&local_library, own, &local_name) + }) + .await + { + Ok(Ok(Some(catalog))) => vec![(own_hex.clone(), catalog)], + Ok(Ok(None)) => Vec::new(), + Ok(Err(err)) => { + tracing::warn!("local catalog lookup failed: {err:#}"); + Vec::new() + } + Err(err) => { + tracing::warn!("local catalog task failed: {err}"); + Vec::new() + } + }; + anyhow::ensure!( + !owners.is_empty() || !catalogs.is_empty() || has_cached_artist, + "no peers hold artist \"{name}\"" + ); let mut requests = Vec::new(); for owner in owners { @@ -560,17 +734,20 @@ impl Federation { } })); } - let mut catalogs = Vec::new(); for request in requests { if let Ok(Some(catalog)) = request.await { catalogs.push(catalog); } } + let mut card = catalog::merge_catalogs(name, catalogs); + add_dht_appearance_hits(&mut card, &outcome.network_results, &normalized, name); + add_cached_appearance_hits(&mut card, &cached_metadata, &normalized, name); anyhow::ensure!( - !catalogs.is_empty(), - "none of the peers answered the catalog request" + !card.releases.is_empty() || !card.appears_on.is_empty(), + "none of the peers returned releases or appearances" ); - Ok(catalog::merge_catalogs(name, catalogs)) + card.own_owner = Some(own_hex); + Ok(card) } pub async fn ticket(&self) -> Result { @@ -668,6 +845,45 @@ impl Federation { self.fetch_playable(fed, save).await } + /// Fetches rich metadata for a federated track without downloading the + /// audio bytes, for the track-info popup. + pub async fn track_info(&self, track: TrackItem) -> Result { + let Some(fed) = track.fed.clone() else { + return Ok(track); + }; + let service = self.service().await?; + let item_id = + audio::hex_decode_item_id(&fed.item_id).context("malformed item id in the result")?; + + if fed.own { + let library = Arc::clone(&self.library); + let own_id = service.endpoint_id(); + return tokio::task::spawn_blocking(move || -> Result { + let Some(track_id) = audio::resolve_local_track_id(&library, own_id, item_id)? + else { + anyhow::bail!("this track is no longer in the local library"); + }; + library + .tracks_by_ids(&[track_id])? + .into_iter() + .next() + .context("this track is no longer in the local library") + }) + .await?; + } + + let owner = EndpointId::from_str(&fed.owner) + .map_err(|_| anyhow::anyhow!("malformed owner id '{}'", fed.owner))?; + let fetched = self + .fetch_metadata_with_fallback(&service, owner, &fed) + .await?; + let enriched = metadata_preview_track(&track, &fed, &fetched); + if let Some(cached) = cached_track_metadata(&enriched, &fed, &fetched) { + lock(&self.metadata_cache).insert(cached_cache_key(&fed), cached); + } + Ok(enriched) + } + async fn fetch_playable(self: &Arc, fed: &FedTrack, save: bool) -> Result { let service = self.service().await?; let item_id = @@ -693,11 +909,16 @@ impl Federation { let owner = EndpointId::from_str(&fed.owner) .map_err(|_| anyhow::anyhow!("malformed owner id '{}'", fed.owner))?; - let dir = if save { &self.media_dir } else { &self.cache_dir }; + let dir = if save { + &self.media_dir + } else { + &self.cache_dir + }; tokio::fs::create_dir_all(dir).await?; - let downloaded = - audio::download_track(&service, owner, &fed.item_id, dir, &download_stem(fed)).await?; + let downloaded = self + .download_track_with_fallback(&service, owner, fed, dir) + .await?; tracing::info!( path = %downloaded.path.display(), mime = %downloaded.mime_type, @@ -750,7 +971,9 @@ impl Federation { } Ok(None) => {} Err(err) => { - tracing::warn!("importing the downloaded track failed: {err:#}; playing from the file"); + tracing::warn!( + "importing the downloaded track failed: {err:#}; playing from the file" + ); } } } @@ -770,14 +993,140 @@ impl Federation { } None => None, }; - let mut track = - ephemeral_track(fed, downloaded.metadata.as_ref(), &downloaded.path); + let mut track = ephemeral_track(fed, downloaded.metadata.as_ref(), &downloaded.path); track.cover_path = cover_path; Ok(FedPlayable { track, imported: false, }) } + + async fn download_track_with_fallback( + &self, + service: &MusicDhtService, + owner: EndpointId, + fed: &FedTrack, + dir: &Path, + ) -> Result { + let stem = download_stem(fed); + match audio::download_track(service, owner, &fed.item_id, dir, &stem).await { + Ok(downloaded) => return Ok(downloaded), + Err(primary_err) => { + let Some(content_id) = fed.content_id.as_deref() else { + return Err(primary_err); + }; + tracing::warn!( + owner = %fed.owner, + item_id = %fed.item_id, + content_id, + "primary federated source failed; searching content-id fallbacks: {primary_err:#}" + ); + let outcome = match service.search_content_id(content_id).await { + Ok(outcome) => outcome, + Err(err) => { + tracing::warn!(content_id, "content-id fallback lookup failed: {err}"); + return Err(primary_err); + } + }; + for item in outcome.network_results { + if item.kind != ItemKind::Track { + continue; + } + let candidate_item_id = audio::hex_encode(item.id.as_bytes()); + if item.owner == owner && candidate_item_id == fed.item_id { + continue; + } + let candidate_owner = item.owner; + match audio::download_track( + service, + candidate_owner, + &candidate_item_id, + dir, + &stem, + ) + .await + { + Ok(downloaded) => { + tracing::info!( + owner = %candidate_owner, + item_id = %candidate_item_id, + content_id, + "federated track downloaded from content-id fallback" + ); + return Ok(downloaded); + } + Err(err) => { + tracing::debug!( + owner = %candidate_owner, + item_id = %candidate_item_id, + content_id, + "content-id fallback source failed: {err:#}" + ); + } + } + } + Err(primary_err) + } + } + } + + async fn fetch_metadata_with_fallback( + &self, + service: &MusicDhtService, + owner: EndpointId, + fed: &FedTrack, + ) -> Result { + match audio::fetch_metadata(service, owner, &fed.item_id).await { + Ok(metadata) => return Ok(metadata), + Err(primary_err) => { + let Some(content_id) = fed.content_id.as_deref() else { + return Err(primary_err); + }; + tracing::warn!( + owner = %fed.owner, + item_id = %fed.item_id, + content_id, + "primary metadata source failed; searching content-id fallbacks: {primary_err:#}" + ); + let outcome = match service.search_content_id(content_id).await { + Ok(outcome) => outcome, + Err(err) => { + tracing::warn!(content_id, "metadata fallback lookup failed: {err}"); + return Err(primary_err); + } + }; + for item in outcome.network_results { + if item.kind != ItemKind::Track { + continue; + } + let candidate_item_id = audio::hex_encode(item.id.as_bytes()); + if item.owner == owner && candidate_item_id == fed.item_id { + continue; + } + match audio::fetch_metadata(service, item.owner, &candidate_item_id).await { + Ok(metadata) => { + tracing::info!( + owner = %item.owner, + item_id = %candidate_item_id, + content_id, + "federated metadata fetched from content-id fallback" + ); + return Ok(metadata); + } + Err(err) => { + tracing::debug!( + owner = %item.owner, + item_id = %candidate_item_id, + content_id, + "metadata fallback source failed: {err:#}" + ); + } + } + } + Err(primary_err) + } + } + } } /// Writes a received artist image into the covers directory and attaches it @@ -836,6 +1185,471 @@ fn apply_remote_metadata(import: &mut crate::library::import::TrackImport, meta: if meta.disc_number.is_some() { import.disc_number = meta.disc_number; } + if let Some(duration) = meta.duration_seconds { + import.duration_seconds = duration; + } + if meta.audio_format.is_some() { + import.audio_format = meta.audio_format.clone(); + } + if meta.audio_bitrate.is_some() { + import.audio_bitrate = meta.audio_bitrate; + } + if meta.audio_sample_rate.is_some() { + import.audio_sample_rate = meta.audio_sample_rate; + } + if meta.audio_bit_depth.is_some() { + import.audio_bit_depth = meta.audio_bit_depth; + } +} + +fn metadata_preview_track( + base: &TrackItem, + fed: &FedTrack, + fetched: &audio::FetchedMetadata, +) -> TrackItem { + let refs = |names: &[String]| -> Vec { + names + .iter() + .map(|name| ArtistRef { + id: -1, + name: name.clone(), + }) + .collect() + }; + let metadata = fetched.metadata.as_ref(); + let title = metadata + .map(|meta| meta.title.trim()) + .filter(|title| !title.is_empty()) + .unwrap_or(&base.title) + .to_string(); + let artists = match metadata { + Some(meta) if !meta.artists.is_empty() => refs(&meta.artists), + _ => base.artists.clone(), + }; + let featured_artists = match metadata { + Some(meta) if !meta.featured_artists.is_empty() => refs(&meta.featured_artists), + _ => base.featured_artists.clone(), + }; + let release_title = metadata + .map(|meta| meta.release_title.trim()) + .filter(|title| !title.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| base.release_title.clone()); + TrackItem { + id: base.id, + title, + track_number: metadata + .and_then(|meta| meta.track_number) + .or(base.track_number), + disc_number: metadata + .and_then(|meta| meta.disc_number) + .or(base.disc_number), + duration_seconds: metadata + .and_then(|meta| meta.duration_seconds) + .or_else(|| fed.duration_seconds.map(|duration| duration as f64)) + .unwrap_or(base.duration_seconds), + artists, + featured_artists, + release_id: base.release_id, + release_title, + release_year: metadata + .and_then(|meta| meta.year) + .or(base.release_year) + .or(fed.year), + file_path: String::new(), + content_id: fed.content_id.clone().or_else(|| base.content_id.clone()), + cover_path: base.cover_path.clone(), + audio_format: metadata + .and_then(|meta| meta.audio_format.clone()) + .or_else(|| audio::format_for_mime(&fetched.mime_type)) + .or_else(|| base.audio_format.clone()), + audio_bitrate: metadata + .and_then(|meta| meta.audio_bitrate) + .or(base.audio_bitrate), + audio_sample_rate: metadata + .and_then(|meta| meta.audio_sample_rate) + .or(base.audio_sample_rate), + audio_bit_depth: metadata + .and_then(|meta| meta.audio_bit_depth) + .or(base.audio_bit_depth), + file_size_bytes: (fetched.total_size > 0) + .then_some(fetched.total_size as i64) + .or(base.file_size_bytes), + play_count: base.play_count, + fed: Some(fed.clone()), + } +} + +fn cached_track_metadata( + track: &TrackItem, + fed: &FedTrack, + fetched: &audio::FetchedMetadata, +) -> Option { + let artist_names = |items: &[ArtistRef]| { + items + .iter() + .map(|artist| artist.name.clone()) + .collect::>() + }; + Some(CachedTrackMetadata { + fed: fed.clone(), + title: track.title.clone(), + artists: artist_names(&track.artists), + featured_artists: artist_names(&track.featured_artists), + release_title: (!track.release_title.trim().is_empty()) + .then(|| track.release_title.clone()), + release_type: fetched + .metadata + .as_ref() + .and_then(|metadata| metadata.release_type.clone()), + year: track.release_year.or(fed.year), + duration_seconds: (track.duration_seconds > 0.0).then_some(track.duration_seconds), + track_number: track.track_number.or(fed.track_number), + disc_number: track.disc_number.or(fed.disc_number), + }) +} + +fn cached_cache_key(fed: &FedTrack) -> String { + format!("{}:{}", fed.owner, fed.item_id) +} + +fn cached_matches_query(cached: &CachedTrackMetadata, normalized_query: &str) -> bool { + if normalized_query.is_empty() { + return false; + } + if music_dht::normalize_name(&cached.title) == normalized_query { + return true; + } + if cached + .release_title + .as_deref() + .is_some_and(|title| music_dht::normalize_name(title) == normalized_query) + { + return true; + } + let query_tokens = music_dht::tokenize(normalized_query); + if query_tokens.is_empty() { + return false; + } + let item_tokens = cached_search_tokens(cached); + query_tokens + .iter() + .all(|token| item_tokens.iter().any(|candidate| candidate == token)) +} + +fn cached_search_tokens(cached: &CachedTrackMetadata) -> Vec { + let mut tokens = music_dht::tokenize(&music_dht::normalize_name(&cached.title)); + for value in cached + .artists + .iter() + .chain(cached.featured_artists.iter()) + .chain(cached.release_title.iter()) + { + tokens.extend(music_dht::tokenize(&music_dht::normalize_name(value))); + } + tokens +} + +fn cached_has_artist(cached: &CachedTrackMetadata, normalized_artist: &str) -> bool { + cached + .featured_artists + .iter() + .any(|artist| music_dht::normalize_name(artist) == normalized_artist) +} + +fn rank_fed_search_results( + artists: &mut [FedArtistHit], + tracks: &mut [FedTrack], + normalized_query: &str, +) { + artists.sort_by(|a, b| { + exact_match_rank(&a.name, normalized_query) + .cmp(&exact_match_rank(&b.name, normalized_query)) + .then_with(|| b.peers.cmp(&a.peers)) + .then_with(|| a.name.cmp(&b.name)) + }); + tracks.sort_by_key(|track| fed_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 fed_track_match_rank(track: &FedTrack, normalized_query: &str) -> u8 { + if music_dht::normalize_name(&track.title) == normalized_query { + return 0; + } + if track + .release_title + .as_deref() + .is_some_and(|title| music_dht::normalize_name(title) == normalized_query) + { + return 1; + } + if track + .artist_names + .iter() + .chain(track.featured_artist_names.iter()) + .any(|artist| music_dht::normalize_name(artist) == normalized_query) + { + return 2; + } + 3 +} + +fn add_dht_appearance_hits( + card: &mut FedArtistCard, + hits: &[LibraryItem], + normalized_artist: &str, + display_artist: &str, +) { + let mut changed = false; + for item in hits { + let Some(appearance) = dht_appearance_hit(item, normalized_artist, display_artist) else { + continue; + }; + if add_appearance_to_card(card, appearance) { + changed = true; + } + } + if changed { + card.peers = card.owners.len(); + sort_fed_appearances(&mut card.appears_on); + } +} + +fn add_cached_appearance_hits( + card: &mut FedArtistCard, + cached: &[CachedTrackMetadata], + normalized_artist: &str, + display_artist: &str, +) { + let mut changed = false; + for track in cached { + let Some(appearance) = cached_appearance_hit(track, normalized_artist, display_artist) + else { + continue; + }; + if add_appearance_to_card(card, appearance) { + changed = true; + } + } + if changed { + card.peers = card.owners.len(); + sort_fed_appearances(&mut card.appears_on); + } +} + +fn dht_appearance_hit( + item: &LibraryItem, + normalized_artist: &str, + display_artist: &str, +) -> Option { + if item.kind != ItemKind::Track { + return None; + } + let appears_as_featured = item + .featured_artist_names + .iter() + .any(|artist| music_dht::normalize_name(artist) == normalized_artist); + if !appears_as_featured { + return None; + } + let mut artists = Vec::new(); + for artist in &item.artist_names { + push_artist_once(&mut artists, artist); + } + let mut featured_artists = Vec::new(); + for artist in &item.featured_artist_names { + push_artist_once(&mut featured_artists, artist); + } + if featured_artists.is_empty() { + push_artist_once(&mut featured_artists, display_artist); + } + Some(FedAppearsOn { + release_title: item.release_title.clone().unwrap_or_default(), + release_type: item.release_type.clone().unwrap_or_default(), + year: item.year, + track: FedCardTrack { + title: item.name.clone(), + artists, + featured_artists, + track_number: item.track_number, + disc_number: item.disc_number, + duration_seconds: item.duration_seconds, + content_id: item.content_id.clone(), + sources: vec![( + item.owner.to_string(), + audio::hex_encode(item.id.as_bytes()), + )], + }, + }) +} + +fn cached_appearance_hit( + cached: &CachedTrackMetadata, + normalized_artist: &str, + display_artist: &str, +) -> Option { + if !cached_has_artist(cached, normalized_artist) { + return None; + } + let mut featured_artists = cached.featured_artists.clone(); + if featured_artists.is_empty() { + push_artist_once(&mut featured_artists, display_artist); + } + Some(FedAppearsOn { + release_title: cached.release_title.clone().unwrap_or_default(), + release_type: cached.release_type.clone().unwrap_or_default(), + year: cached.year, + track: FedCardTrack { + title: cached.title.clone(), + artists: cached.artists.clone(), + featured_artists, + track_number: cached.track_number, + disc_number: cached.disc_number, + duration_seconds: cached.duration_seconds, + content_id: cached.fed.content_id.clone(), + sources: vec![(cached.fed.owner.clone(), cached.fed.item_id.clone())], + }, + }) +} + +fn add_appearance_to_card(card: &mut FedArtistCard, appearance: FedAppearsOn) -> bool { + let Some((owner, item_id)) = appearance.track.sources.first().cloned() else { + return false; + }; + if fed_releases_have_source(card, &owner, &item_id) { + return false; + } + if let Some(existing) = card.appears_on.iter_mut().find(|existing| { + existing + .track + .sources + .iter() + .any(|(source_owner, source_id)| source_owner == &owner && source_id == &item_id) + }) { + merge_dht_appearance(existing, appearance); + return true; + } + if !card.owners.contains(&owner) { + card.owners.push(owner); + } + if let Some(slot) = card + .appears_on + .iter_mut() + .find(|existing| same_appearance(existing, &appearance)) + { + merge_dht_appearance(slot, appearance); + } else { + card.appears_on.push(appearance); + } + true +} + +fn same_appearance(left: &FedAppearsOn, right: &FedAppearsOn) -> bool { + let left_release = music_dht::normalize_name(&left.release_title); + let right_release = music_dht::normalize_name(&right.release_title); + (left_release == right_release || left_release.is_empty() || right_release.is_empty()) + && music_dht::normalize_name(&left.track.title) + == music_dht::normalize_name(&right.track.title) + && (left.track.track_number == right.track.track_number + || left.track.track_number.is_none() + || right.track.track_number.is_none()) + && (left.year == right.year || left.year.is_none() || right.year.is_none()) +} + +fn fed_releases_have_source(card: &FedArtistCard, owner: &str, item_id: &str) -> bool { + card.releases + .iter() + .flat_map(|release| release.tracks.iter()) + .any(|track| { + track + .sources + .iter() + .any(|(source_owner, source_id)| source_owner == owner && source_id == item_id) + }) +} + +fn merge_dht_appearance(target: &mut FedAppearsOn, appearance: FedAppearsOn) { + if target.release_title.is_empty() { + target.release_title = appearance.release_title; + } + if target.release_type.is_empty() { + target.release_type = appearance.release_type; + } + if target.year.is_none() { + target.year = appearance.year; + } + if target.track.duration_seconds.is_none() { + target.track.duration_seconds = appearance.track.duration_seconds; + } + if target.track.content_id.is_none() { + target.track.content_id = appearance.track.content_id; + } + for artist in appearance.track.artists { + push_artist_once(&mut target.track.artists, &artist); + } + for artist in appearance.track.featured_artists { + push_artist_once(&mut target.track.featured_artists, &artist); + } + for source in appearance.track.sources { + if !target.track.sources.contains(&source) { + target.track.sources.push(source); + } + } +} + +fn push_artist_once(names: &mut Vec, name: &str) { + if names + .iter() + .any(|existing| music_dht::normalize_name(existing) == music_dht::normalize_name(name)) + { + return; + } + names.push(name.to_string()); +} + +fn artist_line(artists: &[String], featured_artists: &[String]) -> String { + let mut main = Vec::new(); + for artist in artists { + push_artist_once(&mut main, artist); + } + let mut featured = Vec::new(); + for artist in featured_artists { + if !main + .iter() + .any(|name| music_dht::normalize_name(name) == music_dht::normalize_name(artist)) + { + push_artist_once(&mut featured, artist); + } + } + match (main.is_empty(), featured.is_empty()) { + (false, false) => format!("{} feat. {}", main.join(", "), featured.join(", ")), + (false, true) => main.join(", "), + (true, false) => format!("feat. {}", featured.join(", ")), + (true, true) => String::new(), + } +} + +fn sort_fed_appearances(appearances: &mut [FedAppearsOn]) { + appearances.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)) + }); } async fn stop_running(running: Option) { @@ -860,9 +1674,14 @@ fn collect_specs(library: &Library) -> Result> { kind: ItemKind::Artist, name, artist_names: Vec::new(), + featured_artist_names: Vec::new(), year: None, release_type: None, + release_title: None, + track_number: None, + disc_number: None, duration_seconds: None, + content_id: None, }); } for release in export.releases { @@ -871,9 +1690,14 @@ fn collect_specs(library: &Library) -> Result> { kind: ItemKind::Release, name: release.title, artist_names: release.artist_names, + featured_artist_names: Vec::new(), year: release.year, release_type: Some(release.release_type), + release_title: None, + track_number: None, + disc_number: None, duration_seconds: None, + content_id: None, }); } for track in export.tracks { @@ -882,9 +1706,14 @@ fn collect_specs(library: &Library) -> Result> { kind: ItemKind::Track, name: track.title, artist_names: track.artist_names, + featured_artist_names: track.featured_artist_names, year: track.year, - release_type: None, + release_type: Some(track.release_type), + release_title: Some(track.release_title), + 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, }); } Ok(specs) @@ -937,7 +1766,7 @@ pub fn pending_track(fed: &FedTrack) -> TrackItem { disc_number: fed.disc_number, duration_seconds: fed.duration_seconds.unwrap_or(0) as f64, artists: refs(&fed.artist_names), - featured_artists: Vec::new(), + featured_artists: refs(&fed.featured_artist_names), release_id: -1, release_title: fed .release_title @@ -945,6 +1774,7 @@ pub fn pending_track(fed: &FedTrack) -> TrackItem { .unwrap_or_else(|| format!("federation · {}", fed.owner_short())), release_year: fed.year, file_path: String::new(), + content_id: fed.content_id.clone(), cover_path: None, audio_format: None, audio_bitrate: None, @@ -982,31 +1812,44 @@ fn ephemeral_track( Some(meta) if !meta.artists.is_empty() => refs(&meta.artists), _ => refs(&fed.artist_names), }; + let featured_artists = match metadata { + Some(meta) => refs(&meta.featured_artists), + None => refs(&fed.featured_artist_names), + }; let release_title = metadata .map(|m| m.release_title.trim()) .filter(|t| !t.is_empty()) .map(|t| t.to_string()) + .or_else(|| fed.release_title.clone()) .unwrap_or_else(|| format!("federation · {}", fed.owner_short())); TrackItem { id, title, track_number: metadata.and_then(|m| m.track_number), disc_number: metadata.and_then(|m| m.disc_number), - duration_seconds: fed.duration_seconds.unwrap_or(0) as f64, + duration_seconds: metadata + .and_then(|m| m.duration_seconds) + .or_else(|| fed.duration_seconds.map(|duration| duration as f64)) + .unwrap_or(0.0), artists, - featured_artists: metadata.map(|m| refs(&m.featured_artists)).unwrap_or_default(), + featured_artists, release_id: -1, release_title, release_year: metadata.and_then(|m| m.year).or(fed.year), file_path: path.to_string_lossy().into_owned(), + content_id: fed + .content_id + .clone() + .or_else(|| crate::library::audio_content_id(&path.to_string_lossy())), cover_path: None, - audio_format: path - .extension() - .and_then(|e| e.to_str()) - .map(|e| e.to_string()), - audio_bitrate: None, - audio_sample_rate: None, - audio_bit_depth: None, + audio_format: metadata.and_then(|m| m.audio_format.clone()).or_else(|| { + path.extension() + .and_then(|e| e.to_str()) + .map(str::to_string) + }), + audio_bitrate: metadata.and_then(|m| m.audio_bitrate), + audio_sample_rate: metadata.and_then(|m| m.audio_sample_rate), + audio_bit_depth: metadata.and_then(|m| m.audio_bit_depth), file_size_bytes: file_size, play_count: 0, // Keep the federation reference: the cached track can still be @@ -1014,3 +1857,103 @@ fn ephemeral_track( fed: Some(fed.clone()), } } + +#[cfg(test)] +mod tests { + use super::*; + + fn test_owner() -> EndpointId { + music_dht::SecretKey::from_bytes(&[7; 32]).public() + } + + fn dht_track(main: &[&str], featured: &[&str]) -> LibraryItem { + let owner = test_owner(); + LibraryItem { + id: music_dht::ItemId::derive(&owner, ItemKind::Track, "track:1"), + owner, + kind: ItemKind::Track, + name: "Guest Verse".into(), + normalized_name: music_dht::normalize_name("Guest Verse"), + artist_names: main.iter().map(|name| name.to_string()).collect(), + featured_artist_names: featured.iter().map(|name| name.to_string()).collect(), + year: Some(2024), + release_type: Some("album".into()), + release_title: Some("Host Album".into()), + track_number: Some(2), + disc_number: Some(1), + duration_seconds: Some(180.0), + content_id: Some( + "b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".into(), + ), + revision: 1, + deleted: false, + updated_at_ms: 0, + } + } + + #[test] + fn dht_appearance_requires_explicit_featured_artist() { + let normalized = music_dht::normalize_name("Guest"); + assert!(dht_appearance_hit(&dht_track(&["Guest"], &[]), &normalized, "Guest").is_none()); + + let hit = + dht_appearance_hit(&dht_track(&["Host"], &["Guest"]), &normalized, "Guest").unwrap(); + assert_eq!(hit.release_title, "Host Album"); + assert_eq!(hit.release_type, "album"); + assert_eq!(hit.year, Some(2024)); + assert_eq!(hit.track.artists, vec!["Host"]); + assert_eq!(hit.track.featured_artists, vec!["Guest"]); + assert_eq!(hit.track.track_number, Some(2)); + assert_eq!(hit.track.disc_number, Some(1)); + } + + #[test] + fn federation_search_ranks_exact_names_first() { + let normalized = music_dht::normalize_name("ежемесячные"); + let mut artists = vec![ + FedArtistHit { + name: "Booker".into(), + peers: 3, + }, + FedArtistHit { + name: "Ежемесячные".into(), + peers: 1, + }, + ]; + let mut tracks = vec![ + FedTrack { + item_id: "a".into(), + owner: "peer-a".into(), + own: false, + title: "Гость".into(), + artist_names: vec!["Other".into()], + featured_artist_names: vec!["Ежемесячные".into()], + year: None, + duration_seconds: None, + content_id: None, + release_title: None, + track_number: None, + disc_number: None, + }, + FedTrack { + item_id: "b".into(), + owner: "peer-b".into(), + own: false, + title: "Ежемесячные".into(), + artist_names: vec!["Other".into()], + featured_artist_names: Vec::new(), + year: None, + duration_seconds: None, + content_id: None, + release_title: None, + track_number: None, + disc_number: None, + }, + ]; + + rank_fed_search_results(&mut artists, &mut tracks, &normalized); + + assert_eq!(artists[0].name, "Ежемесячные"); + assert_eq!(tracks[0].title, "Ежемесячные"); + } +} diff --git a/src/library/import.rs b/src/library/import.rs index 74a055f..730d23e 100644 --- a/src/library/import.rs +++ b/src/library/import.rs @@ -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 { /// 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()); } diff --git a/src/library/mod.rs b/src/library/mod.rs index 2d95635..5c857aa 100644 --- a/src/library/mod.rs +++ b/src/library/mod.rs @@ -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, pub duration_seconds: f64, pub artist_names: Vec, + pub featured_artist_names: Vec, + pub release_title: String, + pub release_type: String, + pub track_number: Option, + pub disc_number: Option, + pub content_id: Option, } 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> = 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> = 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> = 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>(8)?, + row.get::<_, String>(9)?, + )) })? - .collect::>>()? - .into_iter() - .map(|mut track| { - track.artist_names = track_artists.remove(&track.id).unwrap_or_default(); - track - }) - .collect(); + .collect::>>()?; + 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> { 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>(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>(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::>>()?; @@ -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 { }) } +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>(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 { + 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> { + let mut statement = conn.prepare(&format!("PRAGMA table_info({table})"))?; + Ok(statement + .query_map([], |row| row.get::<_, String>(1))? + .collect::>>()?) +} + #[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()); diff --git a/src/library/models.rs b/src/library/models.rs index c403716..1b69914 100644 --- a/src/library/models.rs +++ b/src/library/models.rs @@ -31,6 +31,8 @@ pub struct TrackItem { pub release_year: Option, /// Absolute path to the local audio file. pub file_path: String, + /// Stable audio content id (`b3:<64 hex>`) when known. + pub content_id: Option, /// Path to a local cover image (the release cover). pub cover_path: Option, pub audio_format: Option, @@ -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::>() + .join(", "); + let featured = self + .featured_artists + .iter() + .map(|a| a.name.as_str()) + .collect::>() + .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, pub artists: Vec, } + +#[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"); + } +} diff --git a/src/main.rs b/src/main.rs index 44dd194..749521d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod federation; mod library; mod media; mod player; +mod share; mod ui; use std::io; diff --git a/src/player/mod.rs b/src/player/mod.rs index 939c94f..86ee208 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -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 { diff --git a/src/share.rs b/src/share.rs new file mode 100644 index 0000000..f39fd4b --- /dev/null +++ b/src/share.rs @@ -0,0 +1,128 @@ +use crate::library::models::TrackItem; + +pub fn track_content_id(track: &TrackItem) -> Option { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/src/ui/federation.rs b/src/ui/federation.rs index 70a7ea1..cb51e70 100644 --- a/src/ui/federation.rs +++ b/src/ui/federation.rs @@ -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: 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 = (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, 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); diff --git a/src/ui/mod.rs b/src/ui/mod.rs index ad1f74c..92342de 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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> = 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 { diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 05e244d..27e1d3b 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -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> { - 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> { ), 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::>() .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 {