Init commit
This commit is contained in:
@@ -0,0 +1,858 @@
|
||||
//! 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::{
|
||||
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>,
|
||||
media_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
pub async fn start(data_dir: PathBuf, media_dir: PathBuf, network: &str) -> Result<Arc<Self>> {
|
||||
tokio::fs::create_dir_all(&data_dir).await?;
|
||||
tokio::fs::create_dir_all(&media_dir).await?;
|
||||
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)
|
||||
.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() {} });
|
||||
Ok(Arc::new(Self {
|
||||
service: Arc::new(service),
|
||||
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))
|
||||
}
|
||||
|
||||
/// 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<_>>();
|
||||
convert_items(&items, own)
|
||||
.tracks
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("no federation peer currently publishes this 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user