Added fed artist page

This commit is contained in:
Ultradesu
2026-07-17 01:17:54 +03:00
parent 73ee4dab9d
commit a723fcc18e
14 changed files with 1049 additions and 140 deletions
+1 -1
View File
@@ -168,7 +168,7 @@ fn extension_for_mime(mime: &str) -> &'static str {
}
/// Reads one `\n`-terminated line, bounded by [`MAX_PROTOCOL_LINE`].
async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
pub(super) async fn read_line<R: AsyncRead + Unpin>(reader: &mut R) -> Result<Vec<u8>> {
let mut line = Vec::new();
let mut byte = [0u8; 1];
loop {
+370
View File
@@ -0,0 +1,370 @@
//! 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<String>,
#[serde(default)]
artist: Option<CatalogArtist>,
}
/// 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<CatalogRelease>,
}
#[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<i32>,
#[serde(default)]
pub tracks: Vec<CatalogTrack>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CatalogTrack {
#[serde(default)]
pub title: String,
#[serde(default)]
pub track_number: Option<i32>,
#[serde(default)]
pub disc_number: Option<i32>,
#[serde(default)]
pub duration_seconds: Option<f64>,
/// 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<Library>, 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<Library>, 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<CatalogResponse> {
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<CatalogArtist> {
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::io::Result<()>> {
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<FedRelease>,
}
#[derive(Debug, Clone, Default)]
pub struct FedRelease {
pub title: String,
pub release_type: String,
pub year: Option<i32>,
pub tracks: Vec<FedCardTrack>,
}
#[derive(Debug, Clone, Default)]
pub struct FedCardTrack {
pub title: String,
pub track_number: Option<i32>,
pub disc_number: Option<i32>,
pub duration_seconds: Option<f64>,
/// 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<FedRelease> = Vec::new();
let mut release_index: HashMap<String, usize> = 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);
}
}
+136 -5
View File
@@ -13,6 +13,7 @@
//! the network too).
mod audio;
pub mod catalog;
use std::path::PathBuf;
use std::str::FromStr;
@@ -31,6 +32,7 @@ use crate::library::Library;
use crate::library::models::{ArtistRef, TrackItem};
pub use audio::{AUDIO_ALPN, TrackMetadata};
pub use catalog::{CATALOG_ALPN, FedArtistCard, FedCardTrack, FedRelease};
/// How often the published library is re-synchronized with the local index.
const SYNC_INTERVAL: Duration = Duration::from_secs(60);
@@ -85,6 +87,23 @@ fn save_settings(settings: &FedSettings) -> Result<()> {
// Data shapes for the UI
// ---------------------------------------------------------------------------
/// An artist surfaced by federated search — either an artist record, or
/// derived from the artist names of matching tracks/releases (so searching
/// a track title still leads to the artist's card).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FedArtistHit {
pub name: String,
/// Distinct peers (other than this instance) holding the artist.
pub peers: usize,
}
/// Federated search results for the UI.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FedSearchResults {
pub artists: Vec<FedArtistHit>,
pub tracks: Vec<FedTrack>,
}
/// A track found through federated search.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FedTrack {
@@ -261,6 +280,8 @@ impl Federation {
.rendezvous(RendezvousConfig::default())
// Peers stream each other's audio over this protocol.
.stream_protocol(AUDIO_ALPN)
// ...and browse each other's per-artist catalogs over this one.
.stream_protocol(CATALOG_ALPN)
.build()
.map_err(|err| anyhow::anyhow!("invalid federation config: {err}"))?;
let (service, mut events) = MusicDhtService::start(config)
@@ -298,11 +319,20 @@ impl Federation {
Arc::clone(&self.library),
service.endpoint_id(),
));
// Serve per-artist catalog requests (the federated artist card).
let catalog_acceptor = service
.stream_acceptor(CATALOG_ALPN)
.map_err(|err| anyhow::anyhow!("failed to take the catalog acceptor: {err}"))?;
let catalog_task = tokio::spawn(catalog::serve_peers(
catalog_acceptor,
Arc::clone(&self.library),
service.endpoint_id(),
));
*guard = Some(Running {
service,
network_name,
tasks: vec![event_task, sync_task, audio_task],
tasks: vec![event_task, sync_task, audio_task, catalog_task],
});
self.set_error(None);
Ok(())
@@ -403,15 +433,17 @@ impl Federation {
status
}
/// Searches the federated network for tracks matching `query`.
pub async fn search(&self, query: &str) -> Result<Vec<FedTrack>> {
/// Searches the federated network: matching tracks plus the artists a
/// card can be assembled for (from artist records and from the artist
/// names of matching tracks/releases).
pub async fn search(&self, query: &str) -> Result<FedSearchResults> {
let service = self.service().await?;
let outcome = service
.search_network(query)
.await
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
let own = service.endpoint_id();
Ok(outcome
let tracks: Vec<FedTrack> = outcome
.network_results
.iter()
.filter(|item| item.kind == ItemKind::Track)
@@ -424,7 +456,106 @@ impl Federation {
year: item.year,
duration_seconds: item.duration_seconds.map(|d| d.round() as i64),
})
.collect())
.collect();
// Artists: normalized name -> (display name, distinct non-own peers).
let mut artists: std::collections::HashMap<
String,
(String, std::collections::HashSet<EndpointId>),
> = Default::default();
for item in &outcome.network_results {
if item.owner == own {
continue;
}
let mut note = |name: &str| {
let key = music_dht::normalize_name(name);
if key.is_empty() {
return;
}
let entry = artists
.entry(key)
.or_insert_with(|| (name.to_string(), Default::default()));
entry.1.insert(item.owner);
};
if item.kind == ItemKind::Artist {
note(&item.name);
}
for artist in &item.artist_names {
note(artist);
}
}
let mut artists: Vec<FedArtistHit> = artists
.into_values()
.map(|(name, owners)| FedArtistHit {
name,
peers: owners.len(),
})
.collect();
artists.sort_by(|a, b| b.peers.cmp(&a.peers).then_with(|| a.name.cmp(&b.name)));
Ok(FedSearchResults { artists, tracks })
}
/// Assembles the federated artist card: finds the peers holding the
/// artist through the DHT, asks each for its catalog slice directly and
/// merges the answers (missing/slow peers are skipped).
pub async fn artist_card(&self, name: &str) -> Result<FedArtistCard> {
let service = self.service().await?;
let own = service.endpoint_id();
let normalized = music_dht::normalize_name(name);
let outcome = service
.search_network(name)
.await
.map_err(|err| anyhow::anyhow!("federated search failed: {err}"))?;
let owners: std::collections::HashSet<EndpointId> = outcome
.network_results
.iter()
.filter(|item| {
(item.kind == ItemKind::Artist && item.normalized_name == normalized)
|| item
.artist_names
.iter()
.any(|artist| music_dht::normalize_name(artist) == normalized)
})
.map(|item| item.owner)
.filter(|owner| *owner != own)
.collect();
anyhow::ensure!(!owners.is_empty(), "no peers hold artist \"{name}\"");
let mut requests = Vec::new();
for owner in owners {
let service = Arc::clone(&service);
let name = name.to_string();
requests.push(tokio::spawn(async move {
let result = tokio::time::timeout(
Duration::from_secs(5),
catalog::fetch_catalog(&service, owner, &name),
)
.await;
match result {
Ok(Ok(catalog)) => Some((owner.to_string(), catalog)),
Ok(Err(err)) => {
tracing::warn!(peer = %owner, "catalog fetch failed: {err:#}");
None
}
Err(_) => {
tracing::warn!(peer = %owner, "catalog fetch timed out");
None
}
}
}));
}
let mut catalogs = Vec::new();
for request in requests {
if let Ok(Some(catalog)) = request.await {
catalogs.push(catalog);
}
}
anyhow::ensure!(
!catalogs.is_empty(),
"none of the peers answered the catalog request"
);
Ok(catalog::merge_catalogs(name, catalogs))
}
pub async fn ticket(&self) -> Result<String> {