//! 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. //! //! Wire shape on the `furumi-fd/catalog/1` ALPN: the requester sends one //! JSON line ([`CatalogRequest`]) and finishes; the owner answers with one //! JSON document ([`CatalogResponse`]) and finishes. All fields default, so //! the shape is extensible like the audio protocol. use std::collections::HashMap; use std::sync::Arc; use anyhow::{Context, Result}; use music_dht::{ByteStream, EndpointId, ItemKind, MusicDhtService, StreamAcceptor}; use serde::{Deserialize, Serialize}; use tokio::io::AsyncReadExt; use crate::library::Library; /// ALPN of the catalog protocol. pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/1"; /// Upper bound for one catalog response (thousands of tracks fit easily). const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024; #[derive(Debug, Serialize, Deserialize)] struct CatalogRequest { /// Artist display name; matched case-insensitively by the owner. artist: String, } #[derive(Debug, Default, Serialize, Deserialize)] struct CatalogResponse { ok: bool, #[serde(default)] error: Option, #[serde(default)] artist: Option, } /// One peer's library slice for an artist. #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CatalogArtist { #[serde(default)] pub name: String, #[serde(default)] pub releases: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CatalogRelease { #[serde(default)] pub title: String, #[serde(default)] pub release_type: String, #[serde(default)] pub year: Option, #[serde(default)] pub tracks: Vec, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct CatalogTrack { #[serde(default)] pub title: String, #[serde(default)] pub track_number: Option, #[serde(default)] pub disc_number: Option, #[serde(default)] pub duration_seconds: Option, /// Hex DHT item id — the key the audio is requested by (FedPlay). #[serde(default)] pub item_id: String, } // --------------------------------------------------------------------------- // Serving side // --------------------------------------------------------------------------- /// Runs the catalog accept loop until the acceptor closes. pub async fn serve_peers(mut acceptor: StreamAcceptor, library: Arc, own: EndpointId) { while let Some(stream) = acceptor.accept().await { let library = Arc::clone(&library); tokio::spawn(async move { let peer = stream.peer_id; if let Err(err) = serve_one(stream, library, own).await { tracing::warn!(peer = %peer, "catalog request failed: {err:#}"); } }); } } async fn serve_one(mut stream: ByteStream, library: Arc, own: EndpointId) -> Result<()> { let request: CatalogRequest = serde_json::from_slice(&super::audio::read_line(&mut stream.recv).await?)?; tracing::info!(peer = %stream.peer_id, artist = %request.artist, "peer requested a catalog"); let response = tokio::task::spawn_blocking(move || build_catalog(&library, own, &request.artist)) .await? .unwrap_or_else(|err| CatalogResponse { ok: false, error: Some(format!("catalog lookup failed: {err:#}")), artist: None, }); let payload = serde_json::to_vec(&response)?; stream.send.write_all(&payload).await?; stream.send.finish()?; let _ = stream.send.stopped().await; Ok(()) } /// 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 { return Ok(CatalogResponse { ok: false, error: Some("artist not found in the library".to_string()), artist: None, }); }; let detail = library.artist(artist_id)?; let item_id_of = |track_id: i64| -> String { super::audio::hex_encode( music_dht::ItemId::derive(&own, ItemKind::Track, &format!("track:{track_id}")) .as_bytes(), ) }; let mut releases = Vec::new(); for card in &detail.releases { let release = library.release(card.id)?; releases.push(CatalogRelease { title: release.title, release_type: release.release_type, year: release.year, tracks: release .tracks .iter() .map(|track| CatalogTrack { title: track.title.clone(), track_number: track.track_number, disc_number: track.disc_number, duration_seconds: (track.duration_seconds > 0.0) .then_some(track.duration_seconds), item_id: item_id_of(track.id), }) .collect(), }); } Ok(CatalogResponse { ok: true, error: None, artist: Some(CatalogArtist { name: detail.name, releases, }), }) } // --------------------------------------------------------------------------- // Requesting side // --------------------------------------------------------------------------- /// Fetches one peer's catalog slice for `artist`. pub async fn fetch_catalog( service: &MusicDhtService, owner: EndpointId, artist: &str, ) -> Result { let mut stream = service .open_stream(owner, CATALOG_ALPN) .await .map_err(|err| anyhow::anyhow!("cannot reach the peer: {err}"))?; let mut line = serde_json::to_vec(&CatalogRequest { artist: artist.to_string(), })?; line.push(b'\n'); stream.send.write_all(&line).await?; stream.send.finish()?; let mut payload = Vec::new(); // The whole response is one JSON document, bounded by the byte cap. tokio::io::AsyncReadExt::take(StreamReader(&mut stream), MAX_CATALOG_BYTES + 1) .read_to_end(&mut payload) .await?; anyhow::ensure!( payload.len() as u64 <= MAX_CATALOG_BYTES, "catalog response exceeds {MAX_CATALOG_BYTES} bytes" ); let response: CatalogResponse = serde_json::from_slice(&payload).context("malformed catalog response")?; if !response.ok { anyhow::bail!( "peer refused the catalog: {}", response .error .unwrap_or_else(|| "unknown error".to_string()) ); } response.artist.context("empty catalog response") } /// AsyncRead adapter over the receive half of a byte stream. struct StreamReader<'a>(&'a mut ByteStream); impl tokio::io::AsyncRead for StreamReader<'_> { fn poll_read( mut self: std::pin::Pin<&mut Self>, cx: &mut std::task::Context<'_>, buf: &mut tokio::io::ReadBuf<'_>, ) -> std::task::Poll> { std::pin::Pin::new(&mut self.0.recv).poll_read(cx, buf) } } // --------------------------------------------------------------------------- // Aggregation // --------------------------------------------------------------------------- /// The assembled, deduplicated federated artist card. #[derive(Debug, Clone, Default)] pub struct FedArtistCard { #[allow(dead_code, reason = "the open card is keyed by name in AppState")] pub name: String, /// Peers whose catalogs contributed to the card. pub peers: usize, pub releases: Vec, } #[derive(Debug, Clone, Default)] pub struct FedRelease { pub title: String, pub release_type: String, pub year: Option, pub tracks: Vec, } #[derive(Debug, Clone, Default)] pub struct FedCardTrack { pub title: String, pub track_number: Option, pub disc_number: Option, pub duration_seconds: 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)>, } /// Merges per-peer catalogs into one card: releases are keyed by normalized /// title, tracks within a release by normalized title + track number; a /// track present on several peers keeps every source. pub fn merge_catalogs(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> FedArtistCard { let peers = catalogs.len(); let mut releases: Vec = Vec::new(); let mut release_index: HashMap = HashMap::new(); for (owner_hex, catalog) in catalogs { for release in catalog.releases { let release_key = music_dht::normalize_name(&release.title); let slot = *release_index.entry(release_key).or_insert_with(|| { releases.push(FedRelease { title: release.title.clone(), release_type: release.release_type.clone(), year: None, tracks: Vec::new(), }); releases.len() - 1 }); let merged = &mut releases[slot]; if merged.year.is_none() { merged.year = release.year; } if merged.release_type.is_empty() { merged.release_type = release.release_type.clone(); } for track in release.tracks { if track.item_id.is_empty() { continue; } let existing = merged.tracks.iter_mut().find(|t| { music_dht::normalize_name(&t.title) == music_dht::normalize_name(&track.title) && (t.track_number == track.track_number || t.track_number.is_none() || track.track_number.is_none()) }); 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)); } 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)], }), } } } } for release in &mut releases { release .tracks .sort_by_key(|t| (t.disc_number.unwrap_or(1), t.track_number.unwrap_or(i32::MAX))); } releases.sort_by(|a, b| { a.year .unwrap_or(i32::MAX) .cmp(&b.year.unwrap_or(i32::MAX)) .then_with(|| a.title.cmp(&b.title)) }); FedArtistCard { name: name.to_string(), peers, releases, } } #[cfg(test)] mod tests { use super::*; fn track(title: &str, number: i32, item: &str) -> CatalogTrack { CatalogTrack { title: title.into(), track_number: Some(number), disc_number: None, duration_seconds: Some(100.0), item_id: item.into(), } } #[test] fn merges_and_dedupes_across_peers() { let catalog = |item_prefix: &str| CatalogArtist { name: "Metallica".into(), releases: vec![CatalogRelease { title: "Black Album".into(), release_type: "album".into(), year: Some(1991), tracks: vec![ track("Enter Sandman", 1, &format!("{item_prefix}1")), track("Sad But True", 2, &format!("{item_prefix}2")), ], }], }; let card = merge_catalogs( "Metallica", vec![ ("peer-a".to_string(), catalog("a")), ("peer-b".to_string(), catalog("b")), ], ); assert_eq!(card.peers, 2); assert_eq!(card.releases.len(), 1); let release = &card.releases[0]; assert_eq!(release.year, Some(1991)); assert_eq!(release.tracks.len(), 2); // Both peers stay as sources of the deduplicated track. assert_eq!(release.tracks[0].sources.len(), 2); } }