From 5d54894437232b1767101c997da2d1038ca4d145 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Thu, 23 Jul 2026 16:45:30 +0300 Subject: [PATCH] Sync with FRID protocol v5. Improved direct content id search --- Cargo.lock | 42 ++++++------- Cargo.toml | 5 ++ src/app/cmdline.rs | 12 +++- src/app/popup.rs | 109 +++++++++++++++++++++++++++++++- src/app/state.rs | 8 +++ src/app/update.rs | 51 +++++++++++++++ src/federation/mod.rs | 129 ++++++++++++++++++++++++++++++-------- src/share.rs | 141 ++++++++++++++++++++++++++++++++++++++---- src/ui/popup.rs | 85 +++++++++++++++++++++++-- 9 files changed, 513 insertions(+), 69 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b572fc9..deeb6a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -303,7 +303,7 @@ checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -1428,7 +1428,6 @@ dependencies = [ [[package]] name = "federation-net" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#daea24042c01e8a69e7e9a90fc7784dade337bcc" dependencies = [ "blake3", "data-encoding", @@ -2664,9 +2663,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.188" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22053b6a34f84abc97f9129e61334f40174659a1b9bd18c970b83db6a9a6348b" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2961,7 +2960,6 @@ dependencies = [ [[package]] name = "music-dht" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#daea24042c01e8a69e7e9a90fc7784dade337bcc" dependencies = [ "async-trait", "blake3", @@ -3654,9 +3652,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" dependencies = [ "memchr", "ucd-trie", @@ -3664,9 +3662,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" dependencies = [ "pest", "pest_generator", @@ -3674,9 +3672,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" dependencies = [ "pest", "pest_meta", @@ -3687,9 +3685,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.7" +version = "2.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" dependencies = [ "pest", ] @@ -4454,9 +4452,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -4634,7 +4632,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -4658,7 +4656,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -5137,9 +5135,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.2" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -5319,7 +5317,7 @@ checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.2", + "syn 3.0.3", ] [[package]] @@ -5428,9 +5426,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", diff --git a/Cargo.toml b/Cargo.toml index 622fb9e..00695fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,3 +40,8 @@ windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_UI_Wi [target."cfg(unix)".dependencies] libc = "0.2.186" + +# Build against the local frid checkout; drop (or keep, for local +# development) once the frid changes are pushed and the lock is updated. +[patch."https://gt.hexor.cy/ab/frid.git"] +music-dht = { path = "../frid/crates/music-dht" } diff --git a/src/app/cmdline.rs b/src/app/cmdline.rs index 509bf9f..f9c2920 100644 --- a/src/app/cmdline.rs +++ b/src/app/cmdline.rs @@ -211,15 +211,21 @@ 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 { + let Some(link) = crate::share::parse_frid_link(&link) else { state.status_message = Some("usage: :open frid://".into()); return; }; - state.status_message = Some("federation: opening shared track…".into()); + state.status_message = Some(match link.label.as_deref() { + Some(label) => format!("federation: opening \"{label}\"…"), + None => "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 { + let event = match federation + .track_by_content_id(&link.content_id, link.label.as_deref()) + .await + { Ok(track) => AppEvent::EnqueueTracks { tracks: vec![crate::federation::pending_track(&track)], next: false, diff --git a/src/app/popup.rs b/src/app/popup.rs index 3fd3779..292eb49 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -16,7 +16,7 @@ use crate::app::state::{ }; use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem}; -pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { +pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { let Some(popup) = state.popup.take() else { return; }; @@ -43,7 +43,13 @@ pub fn handle_key(state: &mut AppState, runtime: &Runtime, key: KeyEvent) { tracks, cursor, scroll, - } => handle_track_info(state, tracks, cursor, scroll, key), + } => handle_track_info(state, runtime, tracks, cursor, scroll, key), + Popup::TrackArtists { + tracks, + cursor, + scroll, + selected, + } => handle_track_artists(state, runtime, tracks, cursor, scroll, selected, key), Popup::LogDetail(entry) => match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} _ => state.popup = Some(Popup::LogDetail(entry)), @@ -307,6 +313,7 @@ fn handle_confirm_delete( fn handle_track_info( state: &mut AppState, + runtime: &mut Runtime, tracks: Vec, cursor: usize, scroll: usize, @@ -315,6 +322,32 @@ fn handle_track_info( let len = tracks.len(); match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} + KeyCode::Char('a') => { + let artists = tracks + .get(cursor.min(len.saturating_sub(1))) + .map(crate::app::update::track_artist_refs) + .unwrap_or_default(); + match artists.len() { + 0 => { + state.status_message = Some("this track has no artists".into()); + state.popup = Some(Popup::TrackInfo { + tracks, + cursor, + scroll, + }); + } + // A single artist opens directly; the popup closes. + 1 => open_artist(state, runtime, &artists[0]), + _ => { + state.popup = Some(Popup::TrackArtists { + tracks, + cursor, + scroll, + selected: 0, + }); + } + } + } KeyCode::Up | KeyCode::Char('k') => { state.popup = Some(Popup::TrackInfo { tracks, @@ -373,6 +406,78 @@ fn handle_track_info( } } +/// The artist picker over the track info: j/k choose, Enter jumps to the +/// artist page, Esc returns to the info view. +fn handle_track_artists( + state: &mut AppState, + runtime: &mut Runtime, + tracks: Vec, + cursor: usize, + scroll: usize, + selected: usize, + key: KeyEvent, +) { + let artists = tracks + .get(cursor.min(tracks.len().saturating_sub(1))) + .map(crate::app::update::track_artist_refs) + .unwrap_or_default(); + if artists.is_empty() { + state.popup = Some(Popup::TrackInfo { + tracks, + cursor, + scroll, + }); + return; + } + let last = artists.len() - 1; + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('a') => { + state.popup = Some(Popup::TrackInfo { + tracks, + cursor, + scroll, + }); + } + KeyCode::Enter => open_artist(state, runtime, &artists[selected.min(last)]), + KeyCode::Up | KeyCode::Char('k') => { + state.popup = Some(Popup::TrackArtists { + tracks, + cursor, + scroll, + selected: selected.saturating_sub(1), + }); + } + KeyCode::Down | KeyCode::Char('j') => { + state.popup = Some(Popup::TrackArtists { + tracks, + cursor, + scroll, + selected: (selected + 1).min(last), + }); + } + _ => { + state.popup = Some(Popup::TrackArtists { + tracks, + cursor, + scroll, + selected: selected.min(last), + }); + } + } +} + +/// Closes the popup and jumps to the artist's page (local or federated). +fn open_artist( + state: &mut AppState, + runtime: &mut Runtime, + artist: &crate::library::models::ArtistRef, +) { + state.track_selection.clear(); + if let Some(effect) = crate::app::update::open_artist_ref(state, artist) { + super::perform_effect(state, runtime, effect); + } +} + fn copy_to_clipboard(text: &str) -> Result<(), String> { #[cfg(target_os = "macos")] { diff --git a/src/app/state.rs b/src/app/state.rs index fe45160..3e702b6 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -509,6 +509,14 @@ pub enum Popup { cursor: usize, scroll: usize, }, + /// Artist picker over the info popup ('a' with several artists): + /// Enter jumps to the chosen artist's page, Esc returns to the info. + TrackArtists { + tracks: Vec, + cursor: usize, + scroll: usize, + selected: usize, + }, /// Full, wrapped view of one log entry (Enter on the Logs tab). LogDetail(crate::config::logging::LogEntry), /// One-line text entry on the Federation tab (network id, peer ticket). diff --git a/src/app/update.rs b/src/app/update.rs index d243e28..5ee6594 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -1179,6 +1179,51 @@ fn open_release_for_track(state: &mut AppState, track: &TrackItem) { } } +/// Jumps to an artist page from anywhere: the local view when the artist is +/// in the library, the federated card otherwise. Returns the effect that +/// starts the federated fetch, if one is needed. +pub(crate) fn open_artist_ref( + state: &mut AppState, + artist: &crate::library::models::ArtistRef, +) -> Option { + let origin = state.active_tab; + state.active_tab = Tab::Global; + state.artist_fed_button = false; + let effect = if artist.id >= 0 { + match state.global.stack.last() { + Some(GlobalView::Artist { id, .. }) if *id == artist.id => {} + _ => state.global.stack.push(GlobalView::Artist { + id: artist.id, + cursor: 0, + }), + } + None + } else { + state.fed_artist_view = Some((artist.name.clone(), Loadable::Loading)); + state.global.stack.push(GlobalView::FedArtist { cursor: 0 }); + Some(Effect::FedOpenArtist(artist.name.clone())) + }; + if origin != Tab::Global { + state.jump_origin = Some((origin, state.global.stack.len() - 1)); + } + effect +} + +/// The artists of a track as shown in the info popup: main artists first, +/// then featured, without duplicates. +pub(crate) fn track_artist_refs(track: &TrackItem) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut refs: Vec = Vec::new(); + for artist in track.artists.iter().chain(track.featured_artists.iter()) { + let key = music_dht::normalize_name(&artist.name); + if key.is_empty() || !seen.insert(key) { + continue; + } + refs.push(artist.clone()); + } + refs +} + /// Insert tracks after the playing one (`next`) or at the end. Keeps the /// gapless prefetch index pointing at the same track if items shift. pub fn enqueue_tracks(state: &mut AppState, tracks: Vec, next: bool) { @@ -2024,6 +2069,12 @@ pub(crate) fn fed_appears_on_tracks(state: &AppState) -> Vec Vec { + // Federated cursors and selections live in Global-tab views only; on any + // other tab a stale Global cursor must not shadow that tab's own + // selection (e.g. `i` on a queue or playlist track). + if state.active_tab != Tab::Global { + return Vec::new(); + } // An active Shift-V range in a federated scope. if let Some(scope) = state.track_selection.scope.clone() { match scope { diff --git a/src/federation/mod.rs b/src/federation/mod.rs index 2a2857d..a2fc901 100644 --- a/src/federation/mod.rs +++ b/src/federation/mod.rs @@ -37,6 +37,12 @@ pub use catalog::{CATALOG_ALPN, FedAppearsOn, FedArtistCard, FedCardTrack, FedRe /// How often the published library is re-synchronized with the local index. const SYNC_INTERVAL: Duration = Duration::from_secs(60); +/// How many times a share-link content lookup is retried before the label +/// fallback kicks in. +const CONTENT_LOOKUP_ATTEMPTS: usize = 3; +/// Pause between share-link content lookup attempts. +const CONTENT_LOOKUP_RETRY_DELAY: Duration = Duration::from_secs(2); + /// Ephemeral (not-in-library) tracks get negative ids so the rest of the /// app can tell them apart from library rows (history, likes and release /// navigation skip them). @@ -627,33 +633,88 @@ impl Federation { } /// Resolves a share-link content id to one playable federated track. - pub async fn track_by_content_id(&self, content_id: &str) -> Result { + /// + /// Resolution order: the in-session metadata cache, the DHT content key + /// (retried — the DHT is eventually consistent, so a single lookup can + /// transiently come up short), then a name search by the link label: + /// records under the name keys carry content ids too, and failing an + /// exact match, a track whose artists and title all match the label is + /// the same song from another owner. + pub async fn track_by_content_id( + &self, + content_id: &str, + label: Option<&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") + + for cached in self.cached_metadata_snapshot() { + if cached.fed.content_id.as_deref() == Some(content_id) { + return Ok(cached.to_fed_track()); + } + } + + let mut queried_nodes = 0usize; + for attempt in 0..CONTENT_LOOKUP_ATTEMPTS { + if attempt > 0 { + tokio::time::sleep(CONTENT_LOOKUP_RETRY_DELAY).await; + } + let outcome = service + .search_content_id(content_id) + .await + .map_err(|err| anyhow::anyhow!("federated content lookup failed: {err}"))?; + queried_nodes = queried_nodes.max(outcome.queried_nodes); + if let Some(item) = outcome + .local_results + .into_iter() + .chain(outcome.network_results) + .find(|item| item.kind == ItemKind::Track) + { + return Ok(fed_track_from_item(item, own)); + } + } + + if let Some(label) = label { + let normalized = music_dht::normalize_name(label); + if !normalized.is_empty() { + let outcome = service + .search_network(label) + .await + .map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?; + queried_nodes = queried_nodes.max(outcome.queried_nodes); + let candidates: Vec = outcome + .local_results + .into_iter() + .chain(outcome.network_results) + .filter(|item| item.kind == ItemKind::Track) + .collect(); + if let Some(item) = candidates + .iter() + .find(|item| item.content_id.as_deref() == Some(content_id)) + { + return Ok(fed_track_from_item(item.clone(), own)); + } + // "feat" is an artifact of the label format ("A feat. B-Title"), + // not a token of any track record. + let tokens: Vec = music_dht::tokenize(&normalized) + .into_iter() + .filter(|token| token != "feat") + .collect(); + if !tokens.is_empty() + && let Some(item) = candidates.into_iter().find(|item| { + let item_tokens = item.search_tokens(); + tokens.iter().all(|token| item_tokens.contains(token)) + }) + { + return Ok(fed_track_from_item(item, own)); + } + } + } + + if queried_nodes == 0 { + anyhow::bail!("no federation peers reachable yet — check the Federation tab and retry"); + } + anyhow::bail!("no peers currently publish this shared track") } /// Assembles the federated artist card: finds the peers holding the @@ -1494,6 +1555,24 @@ fn dht_appearance_hit( }) } +/// Converts a raw DHT record into the UI-facing federated track shape. +fn fed_track_from_item(item: music_dht::LibraryItem, own: EndpointId) -> FedTrack { + 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, + } +} + fn cached_appearance_hit( cached: &CachedTrackMetadata, normalized_artist: &str, diff --git a/src/share.rs b/src/share.rs index f39fd4b..7331dd8 100644 --- a/src/share.rs +++ b/src/share.rs @@ -22,15 +22,30 @@ pub fn track_share_link(track: &TrackItem) -> Option { Some(track_share_link_for_content_id(track, &content_id)) } -pub fn parse_frid_content_id(value: &str) -> Option { +/// A parsed `frid://` share link. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FridLink { + /// Canonical content id (`b3:<64 hex>`). + pub content_id: String, + /// Human-readable "artists-title" label from the `t` query parameter. + pub label: Option, +} + +pub fn parse_frid_link(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) + let mut parts = rest.splitn(2, ['?', '#']); + let content_id = + music_dht::normalize_content_id(parts.next().unwrap_or_default().trim_end_matches('/'))?; + let label = parts.next().and_then(|query| { + query.split('&').find_map(|pair| { + let value = pair.strip_prefix("t=")?; + let decoded = percent_decode(value); + let decoded = decoded.trim(); + (!decoded.is_empty()).then(|| decoded.to_string()) + }) + }); + Some(FridLink { content_id, label }) } pub fn cached_track_share_link(track: &TrackItem) -> Option { @@ -71,6 +86,41 @@ fn percent_encode(value: &str) -> String { out } +fn percent_decode(value: &str) -> String { + let bytes = value.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + match bytes[index] { + b'%' if index + 3 <= bytes.len() => { + let hex = std::str::from_utf8(&bytes[index + 1..index + 3]) + .ok() + .and_then(|hex| u8::from_str_radix(hex, 16).ok()); + match hex { + Some(byte) => { + out.push(byte); + index += 3; + } + None => { + out.push(b'%'); + index += 1; + } + } + } + // Liberal form-encoding acceptance; our encoder never emits '+'. + b'+' => { + out.push(b' '); + index += 1; + } + byte => { + out.push(byte); + index += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + #[cfg(test)] mod tests { use super::*; @@ -116,13 +166,78 @@ mod tests { #[test] fn parses_frid_content_link() { + let link = parse_frid_link( + "frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=Artist-Track" + ) + .expect("valid link"); assert_eq!( - parse_frid_content_id( - "frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=Artist-Track" - ) - .as_deref(), - Some("b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef") + link.content_id, + "b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" ); - assert!(parse_frid_content_id("https://example.com").is_none()); + assert_eq!(link.label.as_deref(), Some("Artist-Track")); + assert!(parse_frid_link("https://example.com").is_none()); + } + + #[test] + fn parses_percent_encoded_label() { + let link = parse_frid_link( + "frid://B3:0123456789ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef?t=Rammstein-Du%20Riechst%20So%20Gut" + ) + .expect("valid link"); + assert_eq!( + link.content_id, + "b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + assert_eq!(link.label.as_deref(), Some("Rammstein-Du Riechst So Gut")); + + // Cyrillic labels and a missing label both survive. + let link = parse_frid_link( + "frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef?t=%D0%90%D1%80%D1%82%D0%B8%D1%81%D1%82" + ) + .expect("valid link"); + assert_eq!(link.label.as_deref(), Some("Артист")); + let link = parse_frid_link( + "frid://b3:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ) + .expect("valid link"); + assert_eq!(link.label, None); + } + + #[test] + fn share_link_round_trips_through_parse() { + let track = TrackItem { + id: 1, + title: "Du Riechst So Gut".into(), + track_number: None, + disc_number: None, + duration_seconds: 1.0, + artists: vec![ArtistRef { + id: 1, + name: "Rammstein".into(), + }], + featured_artists: Vec::new(), + release_id: 1, + release_title: "Herzeleid".into(), + release_year: None, + file_path: String::new(), + content_id: Some( + "b3:5ebd9e61e1154a64470e6ee4dd1225550f380dd575d01fd4ffba6a5bd1b34104".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, + }; + let link = track_share_link(&track).expect("link"); + let parsed = parse_frid_link(&link).expect("parses back"); + assert_eq!( + parsed.content_id, + "b3:5ebd9e61e1154a64470e6ee4dd1225550f380dd575d01fd4ffba6a5bd1b34104" + ); + assert_eq!(parsed.label.as_deref(), Some("Rammstein-Du Riechst So Gut")); } } diff --git a/src/ui/popup.rs b/src/ui/popup.rs index 27e1d3b..a67ef4e 100644 --- a/src/ui/popup.rs +++ b/src/ui/popup.rs @@ -26,6 +26,12 @@ pub fn draw(frame: &mut Frame, state: &AppState) { cursor, scroll, }) => draw_track_info(frame, tracks, *cursor, *scroll), + Some(Popup::TrackArtists { + tracks, + cursor, + selected, + .. + }) => draw_track_artists(frame, tracks, *cursor, *selected), Some(Popup::LogDetail(entry)) => draw_log_detail(frame, entry), Some(Popup::FedInput { field, input }) => draw_fed_input(frame, field.title(), input), Some(Popup::FedText { title, text }) => draw_fed_text(frame, title, text), @@ -226,13 +232,13 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol 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" + "j/k scroll · h/left previous · l/right next · a artist · c copy frid link · esc close" } else if tracks.len() > 1 { - "j/k scroll · h/left previous · l/right next · esc close" + "j/k scroll · h/left previous · l/right next · a artist · esc close" } else if can_share { - "j/k scroll · c copy frid link · esc close" + "j/k scroll · a artist · c copy frid link · esc close" } else { - "j/k scroll · esc close" + "j/k scroll · a artist · esc close" }; frame.render_widget( Paragraph::new(Line::styled(hint, theme::dim())).alignment(Alignment::Center), @@ -240,6 +246,77 @@ fn draw_track_info(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, scrol ); } +fn draw_track_artists(frame: &mut Frame, tracks: &[TrackItem], cursor: usize, selected: usize) { + let Some(track) = tracks.get(cursor.min(tracks.len().saturating_sub(1))) else { + return; + }; + let artists = crate::app::update::track_artist_refs(track); + // Main artists form the prefix of the deduplicated list; everything + // after them came from the featured credits. + let featured_from = artists + .iter() + .take_while(|kept| { + track + .artists + .iter() + .any(|main| main.id == kept.id && main.name == kept.name) + }) + .count(); + let height = (artists.len() as u16 + 4) + .min(frame.area().height.saturating_sub(2)) + .max(6); + let area = centered(frame.area(), 44, height); + + let block = Block::bordered() + .title(" Open artist ") + .title_style(theme::header()) + .border_style(theme::accent()); + let inner = block.inner(area); + frame.render_widget(Clear, area); + frame.render_widget(block, area); + + let [list_area, _, footer] = Layout::vertical([ + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(inner); + + let visible = usize::from(list_area.height.max(1)); + let first = selected + .saturating_sub(visible / 2) + .min(artists.len().saturating_sub(visible)); + for (index, artist) in artists.iter().enumerate().skip(first).take(visible) { + let row = Rect { + x: list_area.x, + y: list_area.y + (index - first) as u16, + width: list_area.width, + height: 1, + }; + let line = if index >= featured_from { + Line::from(vec![ + Span::raw(artist.name.clone()), + Span::styled(" feat.", theme::dim()), + ]) + } else { + Line::raw(artist.name.clone()) + }; + frame.render_widget(Paragraph::new(line), row); + if index == selected { + frame.buffer_mut().set_style(row, theme::tab_active()); + } + } + + frame.render_widget( + Paragraph::new(Line::styled( + "enter open card · esc back to info", + theme::dim(), + )) + .alignment(Alignment::Center), + footer, + ); +} + fn track_info_lines(track: &TrackItem) -> Vec> { let mut lines = vec![ field("ID", row_id(track.id)),