Sync with FRID protocol v5. Improved direct content id search
This commit is contained in:
Generated
+20
-22
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+9
-3
@@ -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://<content_id>".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,
|
||||
|
||||
+107
-2
@@ -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<TrackItem>,
|
||||
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<TrackItem>,
|
||||
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")]
|
||||
{
|
||||
|
||||
@@ -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<TrackItem>,
|
||||
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).
|
||||
|
||||
@@ -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<Effect> {
|
||||
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<crate::library::models::ArtistRef> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut refs: Vec<crate::library::models::ArtistRef> = 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<TrackItem>, next: bool) {
|
||||
@@ -2024,6 +2069,12 @@ pub(crate) fn fed_appears_on_tracks(state: &AppState) -> Vec<crate::federation::
|
||||
/// Federated tracks covered by the active visual selection, or the single
|
||||
/// one under the cursor in a federated context.
|
||||
pub(crate) fn selected_fed_tracks(state: &AppState) -> Vec<crate::federation::FedTrack> {
|
||||
// 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 {
|
||||
|
||||
+104
-25
@@ -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<FedTrack> {
|
||||
///
|
||||
/// 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<FedTrack> {
|
||||
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<music_dht::LibraryItem> = 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<String> = 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,
|
||||
|
||||
+128
-13
@@ -22,15 +22,30 @@ pub fn track_share_link(track: &TrackItem) -> Option<String> {
|
||||
Some(track_share_link_for_content_id(track, &content_id))
|
||||
}
|
||||
|
||||
pub fn parse_frid_content_id(value: &str) -> Option<String> {
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
pub fn parse_frid_link(value: &str) -> Option<FridLink> {
|
||||
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<String> {
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+81
-4
@@ -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<Line<'static>> {
|
||||
let mut lines = vec![
|
||||
field("ID", row_id(track.id)),
|
||||
|
||||
Reference in New Issue
Block a user