Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd766cda24 |
Generated
+7
-6
@@ -1718,7 +1718,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "federation-net"
|
name = "federation-net"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"blake3",
|
"blake3",
|
||||||
"data-encoding",
|
"data-encoding",
|
||||||
@@ -1845,7 +1845,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.8.2"
|
version = "0.8.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
@@ -3622,7 +3622,7 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "music-dht"
|
name = "music-dht"
|
||||||
version = "0.1.0"
|
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 = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"blake3",
|
"blake3",
|
||||||
@@ -3766,12 +3766,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "netlink-proto"
|
name = "netlink-proto"
|
||||||
version = "0.12.0"
|
version = "0.12.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b65d130ee111430e47eed7896ea43ca693c387f097dd97376bffafbf25812128"
|
checksum = "e6f7398dddf5f152d2a91a2921a134c6097056e292c0d4b9906007855e7cece6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"futures",
|
"futures-channel",
|
||||||
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"netlink-packet-core",
|
"netlink-packet-core",
|
||||||
"netlink-sys",
|
"netlink-sys",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.8.3"
|
version = "0.8.4"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
+125
-59
@@ -6,7 +6,12 @@ use std::path::{Path, PathBuf};
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use anyhow::Result;
|
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 serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use sqlx::Row as _;
|
use sqlx::Row as _;
|
||||||
@@ -16,8 +21,6 @@ use super::{TransportStats, record_stream_transport};
|
|||||||
|
|
||||||
/// ALPN of the peer-to-peer audio streaming protocol.
|
/// ALPN of the peer-to-peer audio streaming protocol.
|
||||||
pub const AUDIO_ALPN: &[u8] = b"furumi-fd/audio/1";
|
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).
|
/// Maximum size of a JSON protocol line (request or response header).
|
||||||
const MAX_PROTOCOL_LINE: usize = 4096;
|
const MAX_PROTOCOL_LINE: usize = 4096;
|
||||||
@@ -66,58 +69,6 @@ struct TrackMetadata {
|
|||||||
disc_number: Option<i32>,
|
disc_number: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct CatalogRequest {
|
|
||||||
artist: String,
|
|
||||||
#[serde(default)]
|
|
||||||
want: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
release: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct CatalogResponse {
|
|
||||||
ok: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
error: Option<String>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
artist: Option<CatalogArtist>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct CatalogArtist {
|
|
||||||
name: String,
|
|
||||||
releases: Vec<CatalogRelease>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct CatalogRelease {
|
|
||||||
title: String,
|
|
||||||
release_type: String,
|
|
||||||
year: Option<i32>,
|
|
||||||
tracks: Vec<CatalogTrack>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct CatalogTrack {
|
|
||||||
title: String,
|
|
||||||
track_number: Option<i32>,
|
|
||||||
disc_number: Option<i32>,
|
|
||||||
duration_seconds: Option<f64>,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
content_id: Option<String>,
|
|
||||||
item_id: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Default, Serialize)]
|
|
||||||
struct ImageHeader {
|
|
||||||
ok: bool,
|
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
|
||||||
error: Option<String>,
|
|
||||||
mime_type: String,
|
|
||||||
size: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Framing helpers
|
// Framing helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -277,6 +228,31 @@ async fn track_artist_image_file(pool: &PgPool, track_id: i64) -> Result<Option<
|
|||||||
Ok(row.map(|row| (row.get(0), row.get(1))))
|
Ok(row.map(|row| (row.get(0), row.get(1))))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn track_catalog_artist_names(
|
||||||
|
pool: &PgPool,
|
||||||
|
track_id: i64,
|
||||||
|
) -> Result<(Vec<String>, Vec<String>)> {
|
||||||
|
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::<String, _>(1).as_str() {
|
||||||
|
"featuring" => featured.push(name),
|
||||||
|
"main" => artists.push(name),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok((artists, featured))
|
||||||
|
}
|
||||||
|
|
||||||
async fn track_metadata(pool: &PgPool, track_id: i64) -> Result<Option<TrackMetadata>> {
|
async fn track_metadata(pool: &PgPool, track_id: i64) -> Result<Option<TrackMetadata>> {
|
||||||
let Some(track) = sqlx::query(
|
let Some(track) = sqlx::query(
|
||||||
"SELECT t.title, t.track_number, t.disc_number, COALESCE(t.year, r.year),
|
"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 {
|
Err(err) => CatalogResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(format!("catalog lookup failed: {err:#}")),
|
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
|
stream
|
||||||
@@ -584,7 +576,7 @@ async fn serve_catalog_one(
|
|||||||
let response = CatalogResponse {
|
let response = CatalogResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some(format!("unknown request kind '{other}'")),
|
error: Some(format!("unknown request kind '{other}'")),
|
||||||
artist: None,
|
..CatalogResponse::default()
|
||||||
};
|
};
|
||||||
stream
|
stream
|
||||||
.send
|
.send
|
||||||
@@ -611,7 +603,7 @@ async fn build_catalog(pool: &PgPool, own: &EndpointId, artist: &str) -> Result<
|
|||||||
return Ok(CatalogResponse {
|
return Ok(CatalogResponse {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: Some("artist not found in the library".to_string()),
|
error: Some("artist not found in the library".to_string()),
|
||||||
artist: None,
|
..CatalogResponse::default()
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
let artist_id: i64 = artist_row.get(0);
|
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 {
|
for row in track_rows {
|
||||||
let track_id: i64 = row.get(0);
|
let track_id: i64 = row.get(0);
|
||||||
let duration: f64 = row.get(4);
|
let duration: f64 = row.get(4);
|
||||||
|
let (artists, featured_artists) = track_catalog_artist_names(pool, track_id).await?;
|
||||||
tracks.push(CatalogTrack {
|
tracks.push(CatalogTrack {
|
||||||
title: row.get(1),
|
title: row.get(1),
|
||||||
|
artists,
|
||||||
|
featured_artists,
|
||||||
track_number: row.get(2),
|
track_number: row.get(2),
|
||||||
disc_number: row.get(3),
|
disc_number: row.get(3),
|
||||||
duration_seconds: (duration > 0.0).then_some(duration),
|
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(CatalogResponse {
|
||||||
ok: true,
|
ok: true,
|
||||||
error: None,
|
|
||||||
artist: Some(CatalogArtist {
|
artist: Some(CatalogArtist {
|
||||||
name: artist_row.get(1),
|
name: artist_row.get(1),
|
||||||
releases,
|
releases,
|
||||||
|
appears_on: Vec::new(),
|
||||||
}),
|
}),
|
||||||
|
..CatalogResponse::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_artist_slice(
|
||||||
|
pool: &PgPool,
|
||||||
|
cursor: Option<String>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<CatalogResponse> {
|
||||||
|
let offset = cursor
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|value| value.parse::<i64>().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()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user