diff --git a/Cargo.lock b/Cargo.lock index 7cbbf5e..fe7ab71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1718,7 +1718,7 @@ dependencies = [ [[package]] name = "federation-net" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#085a4752da25a8d3fe7eac673af081a4a73c08bd" +source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" dependencies = [ "blake3", "data-encoding", @@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.8.2" +version = "0.8.3" dependencies = [ "anyhow", "async-trait", @@ -3622,7 +3622,7 @@ dependencies = [ [[package]] name = "music-dht" version = "0.1.0" -source = "git+https://gt.hexor.cy/ab/frid.git#085a4752da25a8d3fe7eac673af081a4a73c08bd" +source = "git+https://gt.hexor.cy/ab/frid.git#a9012351dcdbdf8dbaa1f5dd71e498b4bc678d99" dependencies = [ "async-trait", "blake3", @@ -3766,12 +3766,13 @@ dependencies = [ [[package]] name = "netlink-proto" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128" +checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6" dependencies = [ "bytes", - "futures", + "futures-channel", + "futures-util", "log", "netlink-packet-core", "netlink-sys", diff --git a/Cargo.toml b/Cargo.toml index 9a3fe18..d102879 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.8.3" +version = "0.8.4" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/federation/serve.rs b/src/federation/serve.rs index 1205246..388ed87 100644 --- a/src/federation/serve.rs +++ b/src/federation/serve.rs @@ -6,7 +6,12 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; -use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor}; +pub use music_dht::catalog::CATALOG_ALPN; +use music_dht::catalog::{ + CatalogArtist, CatalogArtistPreview, CatalogImageHeader as ImageHeader, CatalogRelease, + CatalogRequest, CatalogResponse, CatalogTrack, +}; +use music_dht::{ByteStream, EndpointId, ItemId, ItemKind, StreamAcceptor, normalize_name}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use sqlx::Row as _; @@ -16,8 +21,6 @@ use super::{TransportStats, record_stream_transport}; /// ALPN of the peer-to-peer audio streaming protocol. pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1"; -/// ALPN of the per-artist catalog protocol. -pub const CATALOG_ALPN: &[u8] = b"furumi-fd/catalog/1"; /// Maximum size of a JSON protocol line (request or response header). const MAX_PROTOCOL_LINE: usize = 4096; @@ -66,58 +69,6 @@ struct TrackMetadata { disc_number: Option, } -#[derive(Debug, Deserialize)] -struct CatalogRequest { - artist: String, - #[serde(default)] - want: Option, - #[serde(default)] - release: Option, -} - -#[derive(Debug, Default, Serialize)] -struct CatalogResponse { - ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - artist: Option, -} - -#[derive(Debug, Default, Serialize)] -struct CatalogArtist { - name: String, - releases: Vec, -} - -#[derive(Debug, Default, Serialize)] -struct CatalogRelease { - title: String, - release_type: String, - year: Option, - tracks: Vec, -} - -#[derive(Debug, Default, Serialize)] -struct CatalogTrack { - title: String, - track_number: Option, - disc_number: Option, - duration_seconds: Option, - #[serde(skip_serializing_if = "Option::is_none")] - content_id: Option, - item_id: String, -} - -#[derive(Debug, Default, Serialize)] -struct ImageHeader { - ok: bool, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, - mime_type: String, - size: u64, -} - // --------------------------------------------------------------------------- // Framing helpers // --------------------------------------------------------------------------- @@ -277,6 +228,31 @@ async fn track_artist_image_file(pool: &PgPool, track_id: i64) -> Result Result<(Vec, Vec)> { + let mut artists = Vec::new(); + let mut featured = Vec::new(); + let rows = sqlx::query( + "SELECT a.name, ta.role FROM furumusic__track_artist ta + JOIN furumusic__artist a ON a.id = ta.artist_id + WHERE ta.track_id = $1 ORDER BY ta.position", + ) + .bind(track_id) + .fetch_all(pool) + .await?; + for row in rows { + let name: String = row.get(0); + match row.get::(1).as_str() { + "featuring" => featured.push(name), + "main" => artists.push(name), + _ => {} + } + } + Ok((artists, featured)) +} + async fn track_metadata(pool: &PgPool, track_id: i64) -> Result> { let Some(track) = sqlx::query( "SELECT t.title, t.track_number, t.disc_number, COALESCE(t.year, r.year), @@ -542,7 +518,23 @@ async fn serve_catalog_one( Err(err) => CatalogResponse { ok: false, error: Some(format!("catalog lookup failed: {err:#}")), - artist: None, + ..CatalogResponse::default() + }, + }; + stream + .send + .write_all(&serde_json::to_vec(&response)?) + .await?; + } + Some("artists") => { + let cursor = request.cursor.clone(); + let limit = request.limit.unwrap_or(64).clamp(1, 200); + let response = match build_artist_slice(&pool, cursor, limit).await { + Ok(response) => response, + Err(err) => CatalogResponse { + ok: false, + error: Some(format!("artist slice lookup failed: {err:#}")), + ..CatalogResponse::default() }, }; stream @@ -584,7 +576,7 @@ async fn serve_catalog_one( let response = CatalogResponse { ok: false, error: Some(format!("unknown request kind '{other}'")), - artist: None, + ..CatalogResponse::default() }; stream .send @@ -611,7 +603,7 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result< return Ok(CatalogResponse { ok: false, error: Some("artist not found in the library".to_string()), - artist: None, + ..CatalogResponse::default() }); }; let artist_id: i64 = artist_row.get(0); @@ -646,8 +638,11 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result< for row in track_rows { let track_id: i64 = row.get(0); let duration: f64 = row.get(4); + let (artists, featured_artists) = track_catalog_artist_names(pool, track_id).await?; tracks.push(CatalogTrack { title: row.get(1), + artists, + featured_artists, track_number: row.get(2), disc_number: row.get(3), duration_seconds: (duration > 0.0).then_some(duration), @@ -665,11 +660,82 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result< Ok(CatalogResponse { ok: true, - error: None, artist: Some(CatalogArtist { name: artist_row.get(1), releases, + appears_on: Vec::new(), }), + ..CatalogResponse::default() + }) +} + +async fn build_artist_slice( + pool: &PgPool, + cursor: Option, + limit: usize, +) -> Result { + let offset = cursor + .as_deref() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .max(0); + let rows = sqlx::query( + r#"SELECT a.name::text AS name, + mf.file_path::text AS image_path, + COALESCE(s.release_count, 0)::bigint AS release_count, + COALESCE(s.track_count, 0)::bigint AS track_count + FROM furumusic__artist a + LEFT JOIN furumusic__media_file mf ON mf.id = a.image_file_id + LEFT JOIN ( + SELECT appearance.artist_id, + COUNT(DISTINCT appearance.release_id) FILTER (WHERE appearance.is_primary_release_artist) AS release_count, + COUNT(DISTINCT appearance.track_id) AS track_count + FROM ( + SELECT ta.artist_id, + t.id AS track_id, + r.id AS release_id, + primary_release.artist_id IS NOT NULL AS is_primary_release_artist + FROM furumusic__track_artist ta + JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false + JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false + LEFT JOIN furumusic__release_artist primary_release + ON primary_release.release_id = r.id + AND primary_release.artist_id = ta.artist_id + AND primary_release.position = 0 + ) appearance + GROUP BY appearance.artist_id + ) s ON s.artist_id = a.id + WHERE a.is_hidden = false + AND COALESCE(s.track_count, 0) > 0 + ORDER BY (COALESCE(s.release_count, 0) > 0) DESC, + COALESCE(s.release_count, 0) DESC, + COALESCE(s.track_count, 0) DESC, + a.name_sort + LIMIT $1 OFFSET $2"#, + ) + .bind(limit as i64 + 1) + .bind(offset) + .fetch_all(pool) + .await?; + + let mut artists = Vec::with_capacity(rows.len().min(limit)); + let has_more = rows.len() > limit; + for row in rows.into_iter().take(limit) { + let name: String = row.get(0); + artists.push(CatalogArtistPreview { + artist_key: normalize_name(&name), + name, + image_path: row.get(1), + release_count: row.get(2), + track_count: row.get(3), + }); + } + let next_cursor = has_more.then(|| (offset + artists.len() as i64).to_string()); + Ok(CatalogResponse { + ok: true, + artists, + next_cursor, + ..CatalogResponse::default() }) }