Files
furumi-desktop/crates/backend/src/federation.rs
T
2026-08-14 02:14:46 +01:00

925 lines
33 KiB
Rust

//! 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<String>,
#[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<TrackMetadata>,
}
#[derive(Clone, Default, Deserialize)]
pub struct TrackMetadata {
#[serde(default)]
pub title: String,
#[serde(default)]
pub artists: Vec<String>,
#[serde(default)]
pub featured_artists: Vec<String>,
#[serde(default)]
pub album_artists: Vec<String>,
#[serde(default)]
pub release_title: String,
#[serde(default)]
pub release_type: Option<String>,
pub year: Option<i32>,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: Option<f64>,
pub audio_format: Option<String>,
pub audio_bitrate: Option<i32>,
pub audio_sample_rate: Option<i32>,
pub audio_bit_depth: Option<i32>,
}
pub enum StreamEvent {
Ready(crate::streaming::GrowingFileReader, String),
Complete(PathBuf, Box<Option<TrackMetadata>>),
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<MusicDhtService>,
similarity_dht: Arc<SimilarityDht>,
media_dir: PathBuf,
}
impl Client {
pub async fn start(
data_dir: PathBuf,
media_dir: PathBuf,
network: &str,
similarity: Arc<crate::similarity::Manager>,
) -> Result<Arc<Self>> {
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<MusicDhtService> {
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<PathBuf> {
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::<EndpointId>().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<Vec<crate::federation_similarity::ScoredTrack>> {
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<Track> {
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::<Vec<_>>();
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<ItemSpec>) -> 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::<HashSet<_>>();
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<StreamEvent>,
) -> 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::<EndpointId>() 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::<EndpointId>() 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::<EndpointId>() 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<PathBuf> {
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<CatalogArtist> {
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::<String, usize>::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::<HashSet<_>>();
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<ArtistRef> {
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<Option<(Vec<u8>, &'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<Vec<u8>> {
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);
}
}