//! Federation lifecycle, DHT search and catalog artwork fetching. use std::collections::{HashMap, HashSet}; use std::hash::{DefaultHasher, Hash, Hasher}; use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, Result}; use furumi_backend_api::{FederationDebugSnapshot, SearchResults, SearchStats}; use furumi_domain::{ Artist, ArtistKey, ArtistRef, Artwork, AudioSource, CatalogSource, ContentId, Release, ReleaseKey, Track, TrackKey, }; use music_dht::catalog::{ CATALOG_ALPN, CatalogArtist, CatalogImageHeader, CatalogRequest, CatalogResponse, }; use music_dht::similarity_dht::SimilarityDht; use music_dht::similarity_lsh::SIMILARITY_DHT_ALPN; use music_dht::{ EndpointId, ItemKind, ItemSpec, LibraryItem, MusicDhtConfig, MusicDhtService, NetworkId, RendezvousConfig, }; use serde::{Deserialize, Serialize}; use tokio::io::{AsyncRead, AsyncReadExt as _}; pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1"; pub const AUDIO_PROTOCOL_VERSION: u16 = 1; const STREAM_BUFFER: u64 = 2 * 1024 * 1024; #[derive(Serialize)] struct AudioRequest { item_id: String, offset: u64, want_cover: bool, metadata_only: bool, } #[derive(Deserialize)] struct AudioHeader { ok: bool, #[serde(default)] error: Option, #[serde(default)] mime_type: String, #[serde(default)] total_size: u64, #[serde(default)] cover_size: u64, #[serde(default)] artist_image_size: u64, #[serde(default)] metadata: Option, } #[derive(Clone, Default, Deserialize)] pub struct TrackMetadata { #[serde(default)] pub title: String, #[serde(default)] pub artists: Vec, #[serde(default)] pub featured_artists: Vec, #[serde(default)] pub album_artists: Vec, #[serde(default)] pub release_title: String, #[serde(default)] pub release_type: Option, pub year: Option, pub track_number: Option, pub disc_number: Option, pub duration_seconds: Option, pub audio_format: Option, pub audio_bitrate: Option, pub audio_sample_rate: Option, pub audio_bit_depth: Option, } pub enum StreamEvent { Ready(crate::streaming::GrowingFileReader, String), Complete(PathBuf, Box>), Failed(String), } const MAX_IMAGE_BYTES: u64 = 16 * 1024 * 1024; const IMAGE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(4); pub struct Client { service: Arc, similarity_dht: Arc, media_dir: PathBuf, } impl Client { pub async fn start( data_dir: PathBuf, media_dir: PathBuf, network: &str, similarity: Arc, ) -> Result> { tokio::fs::create_dir_all(&data_dir).await?; tokio::fs::create_dir_all(&media_dir).await?; let similarity_routing_path = data_dir.join("similarity-routing.sqlite3"); let config = MusicDhtConfig::builder() .data_dir(&data_dir) .network_id(NetworkId::from_name(network)) .rendezvous(RendezvousConfig::default()) .stream_protocol(CATALOG_ALPN) .stream_protocol(AUDIO_ALPN) .stream_protocol(music_dht::device_sync::SYNC_ALPN_V1) .stream_protocol(music_dht::device_sync::SYNC_ALPN_V2) .schema_independent_stream_protocol(crate::federation_similarity::SIMILARITY_ALPN) .schema_independent_stream_protocol(SIMILARITY_DHT_ALPN) .build() .context("invalid federation configuration")?; let (service, mut events) = MusicDhtService::start(config) .await .context("starting federation node")?; tokio::spawn(async move { while events.recv().await.is_some() {} }); let service = Arc::new(service); let similarity_dht = SimilarityDht::open(Arc::clone(&service), similarity_routing_path) .await .context("starting similarity routing overlay")?; let routing_acceptor = service .stream_acceptor(SIMILARITY_DHT_ALPN) .context("starting similarity routing listener")?; tokio::spawn(Arc::clone(&similarity_dht).serve(routing_acceptor)); tokio::spawn(Arc::clone(&similarity_dht).maintenance()); tokio::spawn(crate::federation_similarity::sync_routes( Arc::clone(&similarity_dht), Arc::clone(&similarity), )); let similarity_acceptor = service .stream_acceptor(crate::federation_similarity::SIMILARITY_ALPN) .context("starting similarity listener")?; tokio::spawn(crate::federation_similarity::serve( similarity_acceptor, similarity, service.endpoint_id(), )); Ok(Arc::new(Self { service, similarity_dht, media_dir, })) } pub fn service(&self) -> Arc { Arc::clone(&self.service) } pub async fn debug_snapshot(&self) -> FederationDebugSnapshot { let stored_dht_records = self.service.dht_record_count().await.ok(); let published_items = self .service .list_local_items() .await .map_or(0, |items| items.len()); FederationDebugSnapshot { running: true, endpoint_id: self.service.endpoint_id().to_string(), dht_node_id: self.service.node_id().to_string(), connected_peers: self.service.connected_peers().len(), known_contacts: self.service.known_peers().len(), stored_dht_records, published_items, error: None, } } /// Resolves album artwork for a queue item independently of the screen /// from which the track was enqueued. pub async fn artwork_for_track(&self, track: &Track) -> Option { let peer_id = match &track.audio_source { AudioSource::Federation { peer_id, .. } if !peer_id.is_empty() => peer_id.as_str(), _ => track.key.federation_id()?.0, }; let owner = peer_id.parse::().ok()?; let artist = track .artists .first() .map(|artist| artist.name.as_str()) .or_else(|| (!track.artist.is_empty()).then_some(track.artist.as_str()))?; if track.release.is_empty() { return None; } self.cached_image( owner, artist, Some(&track.release), &format!("release-{peer_id}-{artist}-{}", track.release), ) .await } pub async fn search(&self, query: &str) -> Result<(SearchResults, SearchStats)> { let outcome = self.service.search_network(query).await?; let own = self.service.endpoint_id(); let mut results = convert_items(&outcome.network_results, own); self.fetch_artwork(&mut results).await; let stats = SearchStats { tracks: results.tracks.len(), artists: results.artists.len(), peers_queried: outcome.queried_nodes, duration_ms: u64::try_from(outcome.duration.as_millis()).unwrap_or(u64::MAX), }; Ok((results, stats)) } pub async fn search_similar( &self, query: crate::similarity::QueryVector, limit: usize, minimum_score: f32, max_tracks_per_artist: usize, ) -> Result> { let mut hits = crate::federation_similarity::search( Arc::clone(&self.service), Arc::clone(&self.similarity_dht), query, limit, minimum_score, max_tracks_per_artist, ) .await?; let mut results = SearchResults { tracks: hits.iter().map(|hit| hit.track.clone()).collect(), ..SearchResults::default() }; self.fetch_artwork(&mut results).await; for (hit, with_artwork) in hits.iter_mut().zip(results.tracks) { hit.track = with_artwork; } Ok(hits) } /// Resolves a portable queue entry when another connected device only /// knows its stable audio content id. pub async fn track_by_content_id(&self, content_id: &str) -> Result { let outcome = self.service.search_content_id(content_id).await?; let own = self.service.endpoint_id(); let items = outcome .local_results .into_iter() .chain(outcome.network_results) .collect::>(); let mut track = convert_items(&items, own) .tracks .into_iter() .next() .context("no federation peer currently publishes this track")?; if track.cover_uri.is_none() && let Some(path) = self.artwork_for_track(&track).await { track.cover_uri = Some(path.to_string_lossy().into_owned()); } Ok(track) } pub async fn publish(&self, specs: Vec) -> Result<()> { self.service.sync_library(specs).await?; Ok(()) } pub async fn artist_card(&self, name: &str) -> Result<(SearchResults, SearchStats)> { let started = std::time::Instant::now(); let outcome = self.service.search_network(name).await?; let own = self.service.endpoint_id(); let normalized = music_dht::normalize_name(name); let owners = outcome .network_results .iter() .filter(|item| { item.owner != own && ((item.kind == ItemKind::Artist && item.normalized_name == normalized) || item .artist_names .iter() .chain(item.featured_artist_names.iter()) .any(|artist| music_dht::normalize_name(artist) == normalized)) }) .map(|item| item.owner) .collect::>(); let mut catalogs = Vec::new(); for owner in owners { if let Ok(Ok(catalog)) = tokio::time::timeout( std::time::Duration::from_secs(5), fetch_catalog(&self.service, owner, name), ) .await { catalogs.push((owner.to_string(), catalog)); } } let mut result = card_results(name, catalogs); self.fetch_artwork(&mut result).await; let stats = SearchStats { tracks: result.tracks.len(), artists: result.artists.len(), peers_queried: outcome.queried_nodes, duration_ms: u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), }; Ok((result, stats)) } pub async fn stream_track( &self, peer: &str, item_id: &str, directory: &std::path::Path, stem: &str, events: tokio::sync::mpsc::Sender, ) -> Result<()> { use tokio::io::AsyncWriteExt as _; let owner: EndpointId = peer.parse().context("invalid peer id")?; tokio::fs::create_dir_all(directory).await?; let mut stream = self.service.open_stream(owner, AUDIO_ALPN).await?; let mut request = serde_json::to_vec(&AudioRequest { item_id: item_id.into(), offset: 0, want_cover: false, metadata_only: false, })?; request.push(b'\n'); stream.send.write_all(&request).await?; stream.send.finish()?; let header: AudioHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)?; anyhow::ensure!( header.ok, "{}", header.error.unwrap_or_else(|| "peer refused audio".into()) ); anyhow::ensure!( header.cover_size == 0 && header.artist_image_size == 0, "unexpected image data in audio stream" ); let extension = match header.mime_type.as_str() { "audio/mpeg" => "mp3", "audio/flac" | "audio/x-flac" => "flac", "audio/ogg" => "ogg", "audio/opus" => "opus", "audio/wav" | "audio/x-wav" => "wav", "audio/mp4" | "audio/x-m4a" => "m4a", "audio/aac" => "aac", _ => "bin", }; let final_path = directory.join(format!("{stem}.{extension}")); let part_path = directory.join(format!(".{stem}.{extension}.part")); let mut file = tokio::fs::File::create(&part_path).await?; let (reader, writer) = crate::streaming::growing_file(&part_path)?; let mut reader = Some(reader); let mut started = false; let mut received = 0u64; let threshold = header.total_size.clamp(1, STREAM_BUFFER); let mut chunk = vec![0; 64 * 1024]; while let Some(n) = stream.recv.read(&mut chunk).await? { file.write_all(&chunk[..n]).await?; received += n as u64; writer.add_available(n as u64); if !started && received >= threshold && let Some(reader) = reader.take() { let _ = events .send(StreamEvent::Ready(reader, header.mime_type.clone())) .await; started = true; } } if !started && received > 0 && let Some(reader) = reader.take() { let _ = events .send(StreamEvent::Ready(reader, header.mime_type.clone())) .await; } writer.finish(); file.flush().await?; drop(file); anyhow::ensure!( header.total_size == 0 || received == header.total_size, "incomplete audio download: {received}/{}", header.total_size ); tokio::fs::rename(&part_path, &final_path).await?; let _ = events .send(StreamEvent::Complete(final_path, Box::new(header.metadata))) .await; Ok(()) } async fn fetch_artwork(&self, results: &mut SearchResults) { for artist in &mut results.artists { let CatalogSource::Federation { peer_id } = &artist.source else { continue; }; let Ok(owner) = peer_id.parse::() else { continue; }; if let Some(path) = self .cached_image( owner, &artist.name, None, &format!("artist-{peer_id}-{}", artist.name), ) .await { artist.artwork.uri = Some(path.to_string_lossy().into_owned()); } } for release in &mut results.releases { let CatalogSource::Federation { peer_id } = &release.source else { continue; }; let Some(artist) = release.artists.first() else { continue; }; let Ok(owner) = peer_id.parse::() else { continue; }; if let Some(path) = self .cached_image( owner, &artist.name, Some(&release.title), &format!("release-{peer_id}-{}-{}", artist.name, release.title), ) .await { let uri = path.to_string_lossy().into_owned(); release.artwork.uri = Some(uri.clone()); for track in &mut release.tracks { if track.cover_uri.is_none() { track.cover_uri = Some(uri.clone()); } } for track in &mut results.tracks { if track.release == release.title && track .artists .first() .is_some_and(|candidate| candidate.name == artist.name) { track.cover_uri = Some(uri.clone()); } } } } for track in &mut results.tracks { if track.cover_uri.is_some() || track.release.is_empty() { continue; } let AudioSource::Federation { peer_id, .. } = &track.audio_source else { continue; }; let Some(artist) = track.artists.first() else { continue; }; let Ok(owner) = peer_id.parse::() else { continue; }; if let Some(path) = self .cached_image( owner, &artist.name, Some(&track.release), &format!("release-{peer_id}-{}-{}", artist.name, track.release), ) .await { track.cover_uri = Some(path.to_string_lossy().into_owned()); } } } async fn cached_image( &self, owner: EndpointId, artist: &str, release: Option<&str>, cache_key: &str, ) -> Option { let mut hasher = DefaultHasher::new(); cache_key.hash(&mut hasher); let base = self.media_dir.join(format!("{:016x}", hasher.finish())); for extension in ["jpg", "png", "webp", "gif", "bmp"] { let path = base.with_extension(extension); if path.is_file() { return Some(path); } } let fetched = tokio::time::timeout( IMAGE_TIMEOUT, fetch_image(&self.service, owner, artist, release), ) .await .ok()? .ok()?; let (bytes, extension) = fetched?; let path = base.with_extension(extension); tokio::fs::write(&path, bytes).await.ok()?; Some(path) } } async fn fetch_catalog( service: &MusicDhtService, owner: EndpointId, artist: &str, ) -> Result { let mut stream = service.open_stream(owner, CATALOG_ALPN).await?; let mut request = serde_json::to_vec(&CatalogRequest { artist: artist.into(), ..CatalogRequest::default() })?; request.push(b'\n'); stream.send.write_all(&request).await?; stream.send.finish()?; let mut payload = Vec::new(); stream .recv .take(4 * 1024 * 1024 + 1) .read_to_end(&mut payload) .await?; anyhow::ensure!( payload.len() <= 4 * 1024 * 1024, "catalog response is too large" ); let response: CatalogResponse = serde_json::from_slice(&payload)?; anyhow::ensure!( response.ok, "{}", response.error.unwrap_or_else(|| "catalog rejected".into()) ); response.artist.context("empty artist catalog") } #[allow( clippy::too_many_lines, reason = "wire catalog conversion is clearest as one pass" )] fn card_results(name: &str, catalogs: Vec<(String, CatalogArtist)>) -> SearchResults { let mut results = SearchResults::default(); if let Some((peer, _)) = catalogs.first() { results.artists.push(Artist { key: ArtistKey::Federation { peer_id: peer.clone(), id: music_dht::normalize_name(name), }, source: CatalogSource::Federation { peer_id: peer.clone(), }, name: name.into(), artwork: Artwork::default(), release_count: 0, track_count: 0, }); } let mut release_slots = HashMap::::new(); for (peer, catalog) in catalogs { let artist_key = ArtistKey::Federation { peer_id: peer.clone(), id: music_dht::normalize_name(name), }; for remote in catalog.releases { let normalized = music_dht::normalize_name(&remote.title); let slot = *release_slots.entry(normalized.clone()).or_insert_with(|| { results.releases.push(Release { key: ReleaseKey::Federation { peer_id: peer.clone(), id: format!("name:{normalized}"), }, source: CatalogSource::Federation { peer_id: peer.clone(), }, title: remote.title.clone(), artists: vec![ArtistRef { key: artist_key.clone(), name: name.into(), }], featured_artists: Vec::new(), release_type: remote.release_type.clone(), year: remote.year, artwork: Artwork::default(), tracks: Vec::new(), }); results.releases.len() - 1 }); let release = &mut results.releases[slot]; for item in remote.tracks { let duplicate = release.tracks.iter().any(|track| { music_dht::normalize_name(&track.title) == music_dht::normalize_name(&item.title) }); if duplicate || item.item_id.is_empty() { continue; } let content_id = item .content_id .as_deref() .and_then(|id| ContentId::parse(id).ok()); let artists = artist_refs(&peer, &item.artists); let featured = artist_refs(&peer, &item.featured_artists); let track = Track { key: TrackKey::federation( peer.clone(), item.item_id.clone(), content_id.clone(), ), title: item.title, artist: artist_line(&item.artists, &item.featured_artists), artists, featured_artists: featured, release: release.title.clone(), release_id: release.key.clone(), duration_seconds: item.duration_seconds.unwrap_or_default(), track_number: item .track_number .and_then(|value| u32::try_from(value).ok()), disc_number: item.disc_number.and_then(|value| u32::try_from(value).ok()), cover_uri: release.artwork.uri.clone(), audio_format: None, audio_bitrate_kbps: None, audio_sample_rate_hz: None, audio_bit_depth: None, file_size_bytes: None, liked: false, audio_source: AudioSource::Federation { peer_id: peer.clone(), content_id: content_id.unwrap_or_else(|| { ContentId::parse(format!("b3:{}", "0".repeat(64))) .expect("valid fallback") }), }, }; release.tracks.push(track.clone()); results.tracks.push(track); } } for appearance in catalog.appears_on { if appearance.track.item_id.is_empty() { continue; } let content_id = appearance .track .content_id .as_deref() .and_then(|id| ContentId::parse(id).ok()); let release_key = ReleaseKey::Federation { peer_id: peer.clone(), id: format!( "name:{}", music_dht::normalize_name(&appearance.release_title) ), }; results.tracks.push(Track { key: TrackKey::federation( peer.clone(), appearance.track.item_id.clone(), content_id.clone(), ), title: appearance.track.title, artist: artist_line( &appearance.track.artists, &appearance.track.featured_artists, ), artists: artist_refs(&peer, &appearance.track.artists), featured_artists: artist_refs(&peer, &appearance.track.featured_artists), release: appearance.release_title, release_id: release_key, duration_seconds: appearance.track.duration_seconds.unwrap_or_default(), track_number: appearance .track .track_number .and_then(|value| u32::try_from(value).ok()), disc_number: appearance .track .disc_number .and_then(|value| u32::try_from(value).ok()), cover_uri: None, audio_format: None, audio_bitrate_kbps: None, audio_sample_rate_hz: None, audio_bit_depth: None, file_size_bytes: None, liked: false, audio_source: AudioSource::Federation { peer_id: peer.clone(), content_id: content_id.unwrap_or_else(|| { ContentId::parse(format!("b3:{}", "0".repeat(64))).expect("valid fallback") }), }, }); } } for release in &mut results.releases { populate_release_contributors(release); } if let Some(artist) = results.artists.first_mut() { artist.release_count = results.releases.len(); artist.track_count = results.tracks.len(); } results } fn populate_release_contributors(release: &mut Release) { let mut known = release .artists .iter() .chain(release.featured_artists.iter()) .map(|artist| music_dht::normalize_name(&artist.name)) .collect::>(); for artist in release .tracks .iter() .flat_map(|track| track.artists.iter().chain(track.featured_artists.iter())) { if known.insert(music_dht::normalize_name(&artist.name)) { release.featured_artists.push(artist.clone()); } } } fn convert_items(items: &[LibraryItem], own: EndpointId) -> SearchResults { let mut results = SearchResults::default(); let mut artists = HashMap::<(String, String), Artist>::new(); let mut releases = HashSet::<(String, String)>::new(); let mut tracks = HashSet::<(String, String)>::new(); for item in items.iter().filter(|item| item.owner != own) { let peer = item.owner.to_string(); let source = CatalogSource::Federation { peer_id: peer.clone(), }; let refs = artist_refs(&peer, &item.artist_names); let featured = artist_refs(&peer, &item.featured_artist_names); for name in item .artist_names .iter() .chain(item.featured_artist_names.iter()) { note_artist(&mut artists, &peer, name); } match item.kind { ItemKind::Artist => { note_artist(&mut artists, &peer, &item.name); } ItemKind::Release => { if releases.insert((peer.clone(), item.id.to_string())) { results.releases.push(Release { key: ReleaseKey::Federation { peer_id: peer.clone(), id: item.id.to_string(), }, source, title: item.name.clone(), artists: refs, featured_artists: Vec::new(), release_type: item .release_type .clone() .unwrap_or_else(|| "release".into()), year: item.year, artwork: Artwork::default(), tracks: Vec::new(), }); } } ItemKind::Track => { if !tracks.insert((peer.clone(), item.id.to_string())) { continue; } let Some(content_id) = item .content_id .as_deref() .and_then(|value| ContentId::parse(value).ok()) else { continue; }; let release = item.release_title.clone().unwrap_or_default(); results.tracks.push(Track { key: TrackKey::federation( peer.clone(), item.id.to_string(), Some(content_id.clone()), ), title: item.name.clone(), artist: artist_line(&item.artist_names, &item.featured_artist_names), artists: refs.clone(), featured_artists: featured, release: release.clone(), release_id: ReleaseKey::Federation { peer_id: peer.clone(), id: format!("name:{}", music_dht::normalize_name(&release)), }, duration_seconds: item.duration_seconds.unwrap_or_default(), track_number: item .track_number .and_then(|value| u32::try_from(value).ok()), disc_number: item.disc_number.and_then(|value| u32::try_from(value).ok()), cover_uri: None, audio_format: None, audio_bitrate_kbps: None, audio_sample_rate_hz: None, audio_bit_depth: None, file_size_bytes: None, liked: false, audio_source: AudioSource::Federation { peer_id: peer, content_id, }, }); } } } results.artists = artists.into_values().collect(); results .artists .sort_by(|left, right| left.name.cmp(&right.name)); results } fn note_artist(artists: &mut HashMap<(String, String), Artist>, peer: &str, name: &str) { let normalized = music_dht::normalize_name(name); if normalized.is_empty() { return; } artists .entry((peer.to_owned(), normalized.clone())) .or_insert_with(|| Artist { key: ArtistKey::Federation { peer_id: peer.to_owned(), id: normalized, }, source: CatalogSource::Federation { peer_id: peer.to_owned(), }, name: name.to_owned(), artwork: Artwork::default(), release_count: 0, track_count: 0, }); } fn artist_refs(peer: &str, names: &[String]) -> Vec { names .iter() .map(|name| ArtistRef { key: ArtistKey::Federation { peer_id: peer.to_owned(), id: music_dht::normalize_name(name), }, name: name.clone(), }) .collect() } fn artist_line(main: &[String], featured: &[String]) -> String { let mut line = main.join(", "); if !featured.is_empty() { if !line.is_empty() { line.push_str(" feat. "); } line.push_str(&featured.join(", ")); } line } async fn fetch_image( service: &MusicDhtService, owner: EndpointId, artist: &str, release: Option<&str>, ) -> Result, &'static str)>> { let mut stream = service.open_stream(owner, CATALOG_ALPN).await?; let request = CatalogRequest { artist: artist.to_owned(), want: Some( if release.is_some() { "release_cover" } else { "artist_image" } .into(), ), release: release.map(str::to_owned), cursor: None, limit: None, }; let mut line = serde_json::to_vec(&request)?; line.push(b'\n'); stream.send.write_all(&line).await?; stream.send.finish()?; let header: CatalogImageHeader = serde_json::from_slice(&read_line(&mut stream.recv).await?)?; if !header.ok || header.size == 0 { return Ok(None); } anyhow::ensure!( header.size <= MAX_IMAGE_BYTES, "federated image exceeds size limit" ); let image_size = usize::try_from(header.size).context("image is too large for this platform")?; let mut bytes = vec![0; image_size]; stream.recv.read_exact(&mut bytes).await?; let extension = match header.mime_type.as_str() { "image/png" => "png", "image/webp" => "webp", "image/gif" => "gif", "image/bmp" => "bmp", _ => "jpg", }; Ok(Some((bytes, extension))) } async fn read_line(reader: &mut (impl AsyncRead + Unpin)) -> Result> { let mut line = Vec::new(); loop { let byte = reader.read_u8().await?; if byte == b'\n' { return Ok(line); } anyhow::ensure!(line.len() < 64 * 1024, "catalog header is too large"); line.push(byte); } }