mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(cluster): virtual aggregate album/artist detail pages
Add Rust library_cluster_album_detail and library_cluster_artist_detail with cluster_key dedup, owner metadata fallback, and merged tracklists. Wire cluster-mode hooks and navigation seedServerId so detail routes never fall back to single-server getAlbum/getArtist.
This commit is contained in:
@@ -24,6 +24,8 @@ use crate::dto::{
|
||||
LibraryClusterListTracksRequest, LibraryClusterResolveRequest,
|
||||
LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse,
|
||||
LibraryClusterScopeRequest, LibraryClusterPlayerStatsRequest,
|
||||
LibraryClusterEntityDetailRequest, LibraryClusterAlbumDetailResponse,
|
||||
LibraryClusterArtistDetailResponse,
|
||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
|
||||
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
@@ -677,6 +679,36 @@ pub async fn library_cluster_resolve_candidates(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_album_detail(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterEntityDetailRequest,
|
||||
) -> Result<LibraryClusterAlbumDetailResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let server_id = request.server_id;
|
||||
let entity_id = request.entity_id;
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::cluster_album_detail(&store, &servers_ordered, &server_id, &entity_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_artist_detail(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterEntityDetailRequest,
|
||||
) -> Result<LibraryClusterArtistDetailResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let server_id = request.server_id;
|
||||
let entity_id = request.entity_id;
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::cluster_artist_detail(&store, &servers_ordered, &server_id, &entity_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_search_cluster(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
|
||||
@@ -737,6 +737,37 @@ pub struct LibraryClusterResolveResponse {
|
||||
pub cluster_key: Option<String>,
|
||||
}
|
||||
|
||||
/// `library_cluster_album_detail` request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterEntityDetailRequest {
|
||||
pub servers_ordered: Vec<String>,
|
||||
pub server_id: String,
|
||||
pub entity_id: String,
|
||||
}
|
||||
|
||||
/// Virtual aggregate album detail (spec §4).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterAlbumDetailResponse {
|
||||
pub album: LibraryAlbumDto,
|
||||
pub tracks: Vec<LibraryTrackDto>,
|
||||
pub owner_server_id: String,
|
||||
pub related_albums: Vec<LibraryAlbumDto>,
|
||||
}
|
||||
|
||||
/// Virtual aggregate artist detail (spec §4).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterArtistDetailResponse {
|
||||
pub artist: LibraryArtistDto,
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub top_tracks: Vec<LibraryTrackDto>,
|
||||
pub owner_server_id: String,
|
||||
#[serde(default)]
|
||||
pub artist_key: Option<String>,
|
||||
}
|
||||
|
||||
/// Read `MAX(server_updated_at)` for non-deleted tracks on this server
|
||||
/// — used by `SyncStateDto` so callers can show "tracks watermark" in
|
||||
/// Settings without a separate column.
|
||||
|
||||
@@ -0,0 +1,834 @@
|
||||
//! Virtual aggregate detail pages for cluster scope (spec §4 — `/album/:id`, `/artist/:id`).
|
||||
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{
|
||||
LibraryAlbumDto, LibraryArtistDto, LibraryClusterAlbumDetailResponse,
|
||||
LibraryClusterArtistDetailResponse, LibraryTrackDto,
|
||||
};
|
||||
use crate::repos;
|
||||
use crate::search::aliased_track_columns;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
use super::db::ATTACH_ALIAS;
|
||||
use super::list_albums::list_merged_albums;
|
||||
use super::merge::{solo_partition_key, DURATION_TOLERANCE_SEC};
|
||||
use super::priority::{in_list_sql, priority_case_sql};
|
||||
|
||||
const TOP_TRACKS_LIMIT: u32 = 50;
|
||||
|
||||
/// Resolve `(server_id, album_id)` seed — prefer explicit server when it holds the album.
|
||||
fn resolve_album_seed(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
seed_server_id: &str,
|
||||
seed_album_id: &str,
|
||||
) -> Result<Option<(String, String)>, String> {
|
||||
if servers_ordered.is_empty() || seed_album_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let try_order: Vec<&str> = if servers_ordered.iter().any(|s| s == seed_server_id) {
|
||||
std::iter::once(seed_server_id)
|
||||
.chain(servers_ordered.iter().map(String::as_str).filter(|s| *s != seed_server_id))
|
||||
.collect()
|
||||
} else {
|
||||
servers_ordered.iter().map(String::as_str).collect()
|
||||
};
|
||||
for sid in try_order {
|
||||
let exists: bool = store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM track
|
||||
WHERE server_id = ?1 AND album_id = ?2 AND deleted = 0 LIMIT 1
|
||||
)",
|
||||
rusqlite::params![sid, seed_album_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})?;
|
||||
if exists {
|
||||
return Ok(Some((sid.to_string(), seed_album_id.to_string())));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn album_key_for_pair(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
album_id: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT k.album_key FROM track t
|
||||
JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||
WHERE t.server_id = ?1 AND t.album_id = ?2 AND t.deleted = 0
|
||||
AND k.album_key IS NOT NULL
|
||||
LIMIT 1"
|
||||
),
|
||||
rusqlite::params![server_id, album_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn member_album_pairs(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
merge_key: &str,
|
||||
solo_seed: Option<(&str, &str)>,
|
||||
) -> Result<Vec<(String, String, u32)>, String> {
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if let Some((sid, aid)) = solo_seed {
|
||||
if merge_key == solo_partition_key(sid, aid) {
|
||||
let rank = servers_ordered
|
||||
.iter()
|
||||
.position(|s| s == sid)
|
||||
.unwrap_or(9999) as u32;
|
||||
return Ok(vec![(sid.to_string(), aid.to_string(), rank)]);
|
||||
}
|
||||
}
|
||||
|
||||
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let sql = format!(
|
||||
"SELECT DISTINCT t.server_id, t.album_id, ({priority_sql}) AS priority_rank
|
||||
FROM track t
|
||||
JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||
WHERE t.deleted = 0
|
||||
AND t.server_id IN ({in_placeholders})
|
||||
AND t.album_id IS NOT NULL AND t.album_id != ''
|
||||
AND k.album_key = ?
|
||||
ORDER BY priority_rank, t.server_id, t.album_id"
|
||||
);
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.push(SqlValue::Text(merge_key.to_string()));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get::<_, i64>(2)? as u32))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn load_album_row(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
album_id: &str,
|
||||
) -> Result<Option<LibraryAlbumDto>, String> {
|
||||
let sql = "SELECT
|
||||
t.server_id,
|
||||
t.album_id,
|
||||
COALESCE(a.name, t.album),
|
||||
COALESCE(a.artist, t.artist),
|
||||
COALESCE(a.artist_id, t.artist_id),
|
||||
COALESCE(a.song_count, (
|
||||
SELECT COUNT(*) FROM track c
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
|
||||
)),
|
||||
COALESCE(a.duration_sec, (
|
||||
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
|
||||
)),
|
||||
COALESCE(a.year, t.year),
|
||||
COALESCE(a.genre, t.genre),
|
||||
COALESCE(a.cover_art_id, t.cover_art_id),
|
||||
COALESCE(a.starred_at, (
|
||||
SELECT MIN(c.starred_at) FROM track c
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id
|
||||
AND c.deleted = 0 AND c.starred_at IS NOT NULL
|
||||
)),
|
||||
COALESCE(a.synced_at, t.synced_at),
|
||||
a.raw_json
|
||||
FROM track t
|
||||
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
|
||||
WHERE t.server_id = ?1 AND t.album_id = ?2 AND t.deleted = 0
|
||||
LIMIT 1";
|
||||
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(sql, rusqlite::params![server_id, album_id], |r| {
|
||||
let raw: Option<String> = r.get(12)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
artist_id: r.get(4)?,
|
||||
song_count: r.get(5)?,
|
||||
duration_sec: r.get(6)?,
|
||||
year: r.get(7)?,
|
||||
genre: r.get(8)?,
|
||||
cover_art_id: r.get(9)?,
|
||||
starred_at: r.get(10)?,
|
||||
synced_at: r.get(11)?,
|
||||
raw_json: raw
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
})
|
||||
})
|
||||
.optional()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn coalesce_opt<T: Clone>(values: &[Option<T>]) -> Option<T> {
|
||||
values.iter().find_map(|v| v.clone())
|
||||
}
|
||||
|
||||
fn coalesce_str(values: &[Option<String>]) -> Option<String> {
|
||||
values
|
||||
.iter()
|
||||
.find_map(|v| v.as_ref().filter(|s| !s.is_empty()).cloned())
|
||||
}
|
||||
|
||||
fn merge_album_metadata(rows: &[LibraryAlbumDto]) -> Option<LibraryAlbumDto> {
|
||||
if rows.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let owner = rows[0].clone();
|
||||
let names: Vec<Option<String>> = rows.iter().map(|r| Some(r.name.clone())).collect();
|
||||
let artists: Vec<Option<String>> = rows.iter().map(|r| r.artist.clone()).collect();
|
||||
let artist_ids: Vec<Option<String>> = rows.iter().map(|r| r.artist_id.clone()).collect();
|
||||
let years: Vec<Option<i64>> = rows.iter().map(|r| r.year).collect();
|
||||
let genres: Vec<Option<String>> = rows.iter().map(|r| r.genre.clone()).collect();
|
||||
let covers: Vec<Option<String>> = rows.iter().map(|r| r.cover_art_id.clone()).collect();
|
||||
Some(LibraryAlbumDto {
|
||||
server_id: owner.server_id.clone(),
|
||||
id: owner.id.clone(),
|
||||
name: coalesce_str(&names).unwrap_or(owner.name),
|
||||
artist: coalesce_opt(&artists),
|
||||
artist_id: coalesce_opt(&artist_ids),
|
||||
song_count: None, // filled after track merge
|
||||
duration_sec: None,
|
||||
year: coalesce_opt(&years),
|
||||
genre: coalesce_str(&genres),
|
||||
cover_art_id: coalesce_opt(&covers),
|
||||
starred_at: rows.iter().filter_map(|r| r.starred_at).min(),
|
||||
synced_at: rows.iter().map(|r| r.synced_at).max().unwrap_or(owner.synced_at),
|
||||
raw_json: owner.raw_json.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn merged_tracks_for_album_pairs(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
pairs: &[(String, String, u32)],
|
||||
) -> Result<Vec<LibraryTrackDto>, String> {
|
||||
if pairs.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let pair_clauses: Vec<String> = pairs
|
||||
.iter()
|
||||
.map(|_| "(t.server_id = ? AND t.album_id = ?)".to_string())
|
||||
.collect();
|
||||
let pair_filter = pair_clauses.join(" OR ");
|
||||
|
||||
let cols = aliased_track_columns("t");
|
||||
let sql = format!(
|
||||
"WITH candidates AS (
|
||||
SELECT
|
||||
t.rowid AS tid,
|
||||
t.server_id,
|
||||
t.id AS track_id,
|
||||
k.cluster_key,
|
||||
COALESCE(k.duration_sec, t.duration_sec) AS dur,
|
||||
({priority_sql}) AS priority_rank
|
||||
FROM track t
|
||||
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||
WHERE t.deleted = 0 AND ({pair_filter})
|
||||
),
|
||||
refs AS (
|
||||
SELECT cluster_key, MIN(priority_rank) AS best_rank
|
||||
FROM candidates
|
||||
WHERE cluster_key IS NOT NULL
|
||||
GROUP BY cluster_key
|
||||
),
|
||||
ref_dur AS (
|
||||
SELECT c.cluster_key, c.dur AS ref_dur
|
||||
FROM candidates c
|
||||
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
|
||||
),
|
||||
partitioned AS (
|
||||
SELECT c.tid,
|
||||
CASE
|
||||
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
|
||||
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
|
||||
ELSE 'solo:' || c.server_id || ':' || c.track_id
|
||||
END AS merge_key,
|
||||
c.priority_rank
|
||||
FROM candidates c
|
||||
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
|
||||
),
|
||||
winners AS (
|
||||
SELECT tid,
|
||||
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
|
||||
FROM partitioned
|
||||
)
|
||||
SELECT {cols}
|
||||
FROM winners w
|
||||
JOIN track t ON t.rowid = w.tid
|
||||
WHERE w.rn = 1
|
||||
ORDER BY COALESCE(t.disc_number, 1), COALESCE(t.track_number, 9999), t.title COLLATE NOCASE",
|
||||
tol = DURATION_TOLERANCE_SEC,
|
||||
);
|
||||
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
for (sid, aid, _) in pairs {
|
||||
params.push(SqlValue::Text(sid.clone()));
|
||||
params.push(SqlValue::Text(aid.clone()));
|
||||
}
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn cluster_album_detail(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
seed_server_id: &str,
|
||||
seed_album_id: &str,
|
||||
) -> Result<LibraryClusterAlbumDetailResponse, String> {
|
||||
let Some((seed_sid, seed_aid)) =
|
||||
resolve_album_seed(store, servers_ordered, seed_server_id, seed_album_id)?
|
||||
else {
|
||||
return Err("album not found in cluster scope".to_string());
|
||||
};
|
||||
|
||||
let album_key = album_key_for_pair(store, &seed_sid, &seed_aid)?;
|
||||
let merge_key = album_key
|
||||
.clone()
|
||||
.unwrap_or_else(|| solo_partition_key(&seed_sid, &seed_aid));
|
||||
let solo = album_key.is_none().then_some((seed_sid.as_str(), seed_aid.as_str()));
|
||||
|
||||
let pairs = member_album_pairs(store, servers_ordered, &merge_key, solo)?;
|
||||
if pairs.is_empty() {
|
||||
return Err("album not found in cluster scope".to_string());
|
||||
}
|
||||
|
||||
let owner_sid = pairs[0].0.clone();
|
||||
let owner_aid = pairs[0].1.clone();
|
||||
|
||||
let mut album_rows = Vec::with_capacity(pairs.len());
|
||||
for (sid, aid, _) in &pairs {
|
||||
if let Some(row) = load_album_row(store, sid, aid)? {
|
||||
album_rows.push(row);
|
||||
}
|
||||
}
|
||||
let mut album = merge_album_metadata(&album_rows).ok_or_else(|| "album metadata missing".to_string())?;
|
||||
album.server_id = owner_sid.clone();
|
||||
album.id = owner_aid.clone();
|
||||
|
||||
let tracks = merged_tracks_for_album_pairs(store, servers_ordered, &pairs)?;
|
||||
album.song_count = Some(tracks.len() as i64);
|
||||
album.duration_sec = Some(tracks.iter().map(|t| t.duration_sec).sum());
|
||||
|
||||
let related = list_related_albums(store, servers_ordered, &album, &merge_key)?;
|
||||
|
||||
Ok(LibraryClusterAlbumDetailResponse {
|
||||
album,
|
||||
tracks,
|
||||
owner_server_id: owner_sid,
|
||||
related_albums: related,
|
||||
})
|
||||
}
|
||||
|
||||
fn list_related_albums(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
album: &LibraryAlbumDto,
|
||||
_exclude_merge_key: &str,
|
||||
) -> Result<Vec<LibraryAlbumDto>, String> {
|
||||
let artist_id = album.artist_id.as_deref().unwrap_or("");
|
||||
let artist_name = album.artist.as_deref().unwrap_or("");
|
||||
if artist_id.is_empty() && artist_name.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let resp = list_merged_albums(store, servers_ordered, 500, 0)?;
|
||||
Ok(resp
|
||||
.albums
|
||||
.into_iter()
|
||||
.filter(|a| {
|
||||
if a.id == album.id && a.server_id == album.server_id {
|
||||
return false;
|
||||
}
|
||||
let has_artist_id = artist_id.chars().any(|c| !c.is_whitespace());
|
||||
let same_artist = (has_artist_id && a.artist_id.as_deref() == Some(artist_id))
|
||||
|| (!artist_name.is_empty()
|
||||
&& a.artist.as_deref().is_some_and(|n| n.eq_ignore_ascii_case(artist_name)));
|
||||
same_artist
|
||||
})
|
||||
.take(100)
|
||||
.collect())
|
||||
}
|
||||
|
||||
// --- Artist detail ---
|
||||
|
||||
fn resolve_artist_seed(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
seed_server_id: &str,
|
||||
seed_artist_id: &str,
|
||||
) -> Result<Option<(String, String)>, String> {
|
||||
if servers_ordered.is_empty() || seed_artist_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let try_order: Vec<&str> = if servers_ordered.iter().any(|s| s == seed_server_id) {
|
||||
std::iter::once(seed_server_id)
|
||||
.chain(servers_ordered.iter().map(String::as_str).filter(|s| *s != seed_server_id))
|
||||
.collect()
|
||||
} else {
|
||||
servers_ordered.iter().map(String::as_str).collect()
|
||||
};
|
||||
for sid in try_order {
|
||||
let exists: bool = store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM track
|
||||
WHERE server_id = ?1 AND deleted = 0
|
||||
AND (artist_id = ?2 OR artist = ?2)
|
||||
LIMIT 1
|
||||
)",
|
||||
rusqlite::params![sid, seed_artist_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
})?;
|
||||
if exists {
|
||||
return Ok(Some((sid.to_string(), seed_artist_id.to_string())));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn artist_key_for_pair(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
artist_ref: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
store.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
&format!(
|
||||
"SELECT k.artist_key FROM track t
|
||||
JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||
WHERE t.server_id = ?1 AND t.deleted = 0
|
||||
AND (t.artist_id = ?2 OR t.artist = ?2)
|
||||
AND k.artist_key IS NOT NULL
|
||||
LIMIT 1"
|
||||
),
|
||||
rusqlite::params![server_id, artist_ref],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn load_artist_row(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
artist_id: &str,
|
||||
) -> Result<Option<LibraryArtistDto>, String> {
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT
|
||||
ar.server_id,
|
||||
ar.id,
|
||||
ar.name,
|
||||
ar.album_count,
|
||||
ar.synced_at,
|
||||
ar.raw_json
|
||||
FROM artist ar
|
||||
WHERE ar.server_id = ?1 AND ar.id = ?2",
|
||||
rusqlite::params![server_id, artist_id],
|
||||
|r| {
|
||||
let raw: Option<String> = r.get(5)?;
|
||||
Ok(LibraryArtistDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
album_count: r.get(3)?,
|
||||
synced_at: r.get(4)?,
|
||||
raw_json: raw
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn fallback_artist_from_tracks(
|
||||
store: &LibraryStore,
|
||||
server_id: &str,
|
||||
artist_ref: &str,
|
||||
) -> Result<Option<LibraryArtistDto>, String> {
|
||||
store
|
||||
.with_read_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT
|
||||
t.server_id,
|
||||
COALESCE(NULLIF(t.artist_id, ''), t.artist),
|
||||
COALESCE(t.artist, ''),
|
||||
COUNT(DISTINCT t.album_id),
|
||||
MAX(t.synced_at)
|
||||
FROM track t
|
||||
WHERE t.server_id = ?1 AND t.deleted = 0
|
||||
AND (t.artist_id = ?2 OR t.artist = ?2)
|
||||
GROUP BY t.server_id, COALESCE(NULLIF(t.artist_id, ''), t.artist)",
|
||||
rusqlite::params![server_id, artist_ref],
|
||||
|r| {
|
||||
Ok(LibraryArtistDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
album_count: Some(r.get(3)?),
|
||||
synced_at: r.get(4)?,
|
||||
raw_json: Value::Null,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn merged_albums_for_artist_key(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
artist_key: &str,
|
||||
) -> Result<Vec<LibraryAlbumDto>, String> {
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let sql = format!(
|
||||
"WITH candidates AS (
|
||||
SELECT
|
||||
t.rowid AS tid,
|
||||
t.server_id,
|
||||
t.album_id,
|
||||
k.album_key,
|
||||
({priority_sql}) AS priority_rank
|
||||
FROM track t
|
||||
JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||
WHERE t.deleted = 0
|
||||
AND t.server_id IN ({in_placeholders})
|
||||
AND k.artist_key = ?
|
||||
AND t.album_id IS NOT NULL AND t.album_id != ''
|
||||
),
|
||||
partitioned AS (
|
||||
SELECT c.tid,
|
||||
CASE
|
||||
WHEN c.album_key IS NULL THEN 'solo:' || c.server_id || ':' || c.album_id
|
||||
ELSE c.album_key
|
||||
END AS merge_key,
|
||||
c.priority_rank
|
||||
FROM candidates c
|
||||
),
|
||||
winners AS (
|
||||
SELECT tid,
|
||||
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
|
||||
FROM partitioned
|
||||
)
|
||||
SELECT
|
||||
t.server_id,
|
||||
t.album_id,
|
||||
COALESCE(a.name, t.album),
|
||||
COALESCE(a.artist, t.artist),
|
||||
COALESCE(a.artist_id, t.artist_id),
|
||||
COALESCE(a.song_count, (
|
||||
SELECT COUNT(*) FROM track c
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
|
||||
)),
|
||||
COALESCE(a.duration_sec, (
|
||||
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
|
||||
)),
|
||||
COALESCE(a.year, t.year),
|
||||
COALESCE(a.genre, t.genre),
|
||||
COALESCE(a.cover_art_id, t.cover_art_id),
|
||||
COALESCE(a.starred_at, t.starred_at),
|
||||
COALESCE(a.synced_at, t.synced_at),
|
||||
a.raw_json
|
||||
FROM winners w
|
||||
JOIN track t ON t.rowid = w.tid
|
||||
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
|
||||
WHERE w.rn = 1
|
||||
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE",
|
||||
);
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.push(SqlValue::Text(artist_key.to_string()));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
let raw: Option<String> = r.get(12)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
artist_id: r.get(4)?,
|
||||
song_count: r.get(5)?,
|
||||
duration_sec: r.get(6)?,
|
||||
year: r.get(7)?,
|
||||
genre: r.get(8)?,
|
||||
cover_art_id: r.get(9)?,
|
||||
starred_at: r.get(10)?,
|
||||
synced_at: r.get(11)?,
|
||||
raw_json: raw
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
})
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn merged_top_tracks_for_artist_key(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
artist_key: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<LibraryTrackDto>, String> {
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
let cols = aliased_track_columns("t");
|
||||
let sql = format!(
|
||||
"WITH candidates AS (
|
||||
SELECT
|
||||
t.rowid AS tid,
|
||||
t.server_id,
|
||||
t.id AS track_id,
|
||||
k.cluster_key,
|
||||
COALESCE(k.duration_sec, t.duration_sec) AS dur,
|
||||
({priority_sql}) AS priority_rank,
|
||||
COALESCE(t.play_count, 0) AS play_count
|
||||
FROM track t
|
||||
JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||
WHERE t.deleted = 0
|
||||
AND t.server_id IN ({in_placeholders})
|
||||
AND k.artist_key = ?
|
||||
),
|
||||
refs AS (
|
||||
SELECT cluster_key, MIN(priority_rank) AS best_rank
|
||||
FROM candidates
|
||||
WHERE cluster_key IS NOT NULL
|
||||
GROUP BY cluster_key
|
||||
),
|
||||
ref_dur AS (
|
||||
SELECT c.cluster_key, c.dur AS ref_dur
|
||||
FROM candidates c
|
||||
JOIN refs r ON c.cluster_key = r.cluster_key AND c.priority_rank = r.best_rank
|
||||
),
|
||||
partitioned AS (
|
||||
SELECT c.tid, c.play_count,
|
||||
CASE
|
||||
WHEN c.cluster_key IS NULL THEN 'solo:' || c.server_id || ':' || c.track_id
|
||||
WHEN ABS(c.dur - rd.ref_dur) <= {tol} THEN c.cluster_key
|
||||
ELSE 'solo:' || c.server_id || ':' || c.track_id
|
||||
END AS merge_key,
|
||||
c.priority_rank
|
||||
FROM candidates c
|
||||
LEFT JOIN ref_dur rd ON c.cluster_key = rd.cluster_key
|
||||
),
|
||||
winners AS (
|
||||
SELECT tid, play_count,
|
||||
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
|
||||
FROM partitioned
|
||||
)
|
||||
SELECT {cols}
|
||||
FROM winners w
|
||||
JOIN track t ON t.rowid = w.tid
|
||||
WHERE w.rn = 1
|
||||
ORDER BY w.play_count DESC, t.title COLLATE NOCASE
|
||||
LIMIT ?",
|
||||
tol = DURATION_TOLERANCE_SEC,
|
||||
);
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.push(SqlValue::Text(artist_key.to_string()));
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
|
||||
store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| {
|
||||
repos::row_to_track_row(r).map(|row| LibraryTrackDto::from_row(&row))
|
||||
})?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn cluster_artist_detail(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
seed_server_id: &str,
|
||||
seed_artist_id: &str,
|
||||
) -> Result<LibraryClusterArtistDetailResponse, String> {
|
||||
let Some((seed_sid, seed_aid)) =
|
||||
resolve_artist_seed(store, servers_ordered, seed_server_id, seed_artist_id)?
|
||||
else {
|
||||
return Err("artist not found in cluster scope".to_string());
|
||||
};
|
||||
|
||||
let artist_key = artist_key_for_pair(store, &seed_sid, &seed_aid)?;
|
||||
|
||||
let owner_sid = seed_sid.clone();
|
||||
let owner_aid = seed_aid.clone();
|
||||
|
||||
let artist = load_artist_row(store, &owner_sid, &owner_aid)?
|
||||
.or_else(|| fallback_artist_from_tracks(store, &owner_sid, &owner_aid).ok().flatten())
|
||||
.ok_or_else(|| "artist metadata missing".to_string())?;
|
||||
|
||||
let albums = if let Some(ref key) = artist_key {
|
||||
merged_albums_for_artist_key(store, servers_ordered, key)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let top_tracks = if let Some(ref key) = artist_key {
|
||||
merged_top_tracks_for_artist_key(store, servers_ordered, key, TOP_TRACKS_LIMIT)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
Ok(LibraryClusterArtistDetailResponse {
|
||||
artist,
|
||||
albums,
|
||||
top_tracks,
|
||||
owner_server_id: owner_sid,
|
||||
artist_key,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn track(
|
||||
server: &str,
|
||||
id: &str,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
album_id: &str,
|
||||
disc: i64,
|
||||
track_no: i64,
|
||||
) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server.into(),
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
title_sort: None,
|
||||
artist: Some(artist.into()),
|
||||
artist_id: Some(format!("art-{server}")),
|
||||
album: album.into(),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some(artist.into()),
|
||||
duration_sec: 200,
|
||||
track_number: Some(track_no),
|
||||
disc_number: Some(disc),
|
||||
year: Some(2020),
|
||||
genre: None,
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: None,
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: Some(1),
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: None,
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn album_detail_merges_tracks_and_picks_owner() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "One", "Band", "LP", "alb1", 1, 1),
|
||||
track("s1", "t2", "Two", "Band", "LP", "alb1", 1, 2),
|
||||
track("s2", "t3", "One", "Band", "LP", "alb2", 1, 1),
|
||||
track("s2", "t4", "Exclusive", "Band", "LP", "alb2", 1, 3),
|
||||
])
|
||||
.unwrap();
|
||||
rebuild_all_cluster_keys(&store).unwrap();
|
||||
|
||||
let resp = cluster_album_detail(&store, &["s1".into(), "s2".into()], "s1", "alb1").unwrap();
|
||||
assert_eq!(resp.owner_server_id, "s1");
|
||||
assert_eq!(resp.tracks.len(), 3);
|
||||
assert_eq!(resp.tracks[0].title, "One");
|
||||
assert_eq!(resp.tracks[2].title, "Exclusive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artist_detail_lists_merged_albums() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "A", "Band", "LP1", "alb1", 1, 1),
|
||||
track("s2", "t2", "B", "Band", "LP1", "alb2", 1, 1),
|
||||
track("s1", "t3", "C", "Band", "LP2", "alb3", 1, 1),
|
||||
])
|
||||
.unwrap();
|
||||
rebuild_all_cluster_keys(&store).unwrap();
|
||||
|
||||
let resp =
|
||||
cluster_artist_detail(&store, &["s1".into(), "s2".into()], "s1", "art-s1").unwrap();
|
||||
assert_eq!(resp.albums.len(), 2);
|
||||
assert!(!resp.top_tracks.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//! in a separate attached SQLite DB (`library-cluster.db`). Distinct from
|
||||
//! `repos/play_session/cluster.rs` (listening-session time-gap grouping).
|
||||
|
||||
mod detail;
|
||||
mod db;
|
||||
mod keys;
|
||||
mod list;
|
||||
@@ -16,6 +17,7 @@ mod rebuild;
|
||||
mod resolve;
|
||||
mod search;
|
||||
|
||||
pub use detail::{cluster_album_detail, cluster_artist_detail};
|
||||
pub use db::{
|
||||
attach_cluster_database, attach_cluster_database_uri, cluster_db_path, ensure_cluster_schema,
|
||||
init_cluster_meta, needs_norm_rebuild, ATTACH_ALIAS, CLUSTER_DB_FILENAME, NORM_VERSION,
|
||||
|
||||
@@ -719,6 +719,8 @@ pub fn run() {
|
||||
psysonic_library::commands::library_cluster_player_stats_year_summary,
|
||||
psysonic_library::commands::library_cluster_player_stats_heatmap,
|
||||
psysonic_library::commands::library_cluster_resolve_candidates,
|
||||
psysonic_library::commands::library_cluster_album_detail,
|
||||
psysonic_library::commands::library_cluster_artist_detail,
|
||||
psysonic_library::commands::library_search_cluster,
|
||||
psysonic_library::commands::library_get_track,
|
||||
psysonic_library::commands::library_get_tracks_batch,
|
||||
|
||||
@@ -606,6 +606,64 @@ export function libraryClusterResolveCandidates(args: {
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryClusterAlbumDetailResponse {
|
||||
album: LibraryAlbumDto;
|
||||
tracks: LibraryTrackDto[];
|
||||
ownerServerId: string;
|
||||
relatedAlbums: LibraryAlbumDto[];
|
||||
}
|
||||
|
||||
export interface LibraryClusterArtistDetailResponse {
|
||||
artist: LibraryArtistDto;
|
||||
albums: LibraryAlbumDto[];
|
||||
topTracks: LibraryTrackDto[];
|
||||
ownerServerId: string;
|
||||
artistKey?: string | null;
|
||||
}
|
||||
|
||||
export function libraryClusterAlbumDetail(args: {
|
||||
serversOrdered: string[];
|
||||
serverId: string;
|
||||
entityId: string;
|
||||
}): Promise<LibraryClusterAlbumDetailResponse> {
|
||||
return invoke<LibraryClusterAlbumDetailResponse>('library_cluster_album_detail', {
|
||||
request: {
|
||||
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
|
||||
serverId: serverIndexKeyForId(args.serverId),
|
||||
entityId: args.entityId,
|
||||
},
|
||||
}).then(resp => ({
|
||||
...resp,
|
||||
album: { ...resp.album, serverId: mapServerIdFromIndexKey(resp.album.serverId) },
|
||||
tracks: mapTracksServerId(resp.tracks),
|
||||
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
|
||||
relatedAlbums: resp.relatedAlbums.map(a => ({
|
||||
...a,
|
||||
serverId: mapServerIdFromIndexKey(a.serverId),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function libraryClusterArtistDetail(args: {
|
||||
serversOrdered: string[];
|
||||
serverId: string;
|
||||
entityId: string;
|
||||
}): Promise<LibraryClusterArtistDetailResponse> {
|
||||
return invoke<LibraryClusterArtistDetailResponse>('library_cluster_artist_detail', {
|
||||
request: {
|
||||
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
|
||||
serverId: serverIndexKeyForId(args.serverId),
|
||||
entityId: args.entityId,
|
||||
},
|
||||
}).then(resp => ({
|
||||
...resp,
|
||||
artist: { ...resp.artist, serverId: mapServerIdFromIndexKey(resp.artist.serverId) },
|
||||
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
|
||||
topTracks: mapTracksServerId(resp.topTracks),
|
||||
ownerServerId: mapServerIdFromIndexKey(resp.ownerServerId),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Cluster-mode search — dedup by cluster_key + priority. */
|
||||
export function librarySearchCluster(args: {
|
||||
query: string;
|
||||
|
||||
@@ -26,6 +26,8 @@ export interface SubsonicAlbum {
|
||||
displayArtist?: string;
|
||||
/** OpenSubsonic: per-disc subtitles (e.g. "Sessions" on CD 3 of a deluxe edition). */
|
||||
discTitles?: SubsonicDiscTitle[];
|
||||
/** Psysonic cluster: originating server for merged browse rows (client-only). */
|
||||
clusterSeedServerId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicDiscTitle {
|
||||
@@ -144,6 +146,8 @@ export interface SubsonicArtist {
|
||||
starred?: string;
|
||||
/** Present on some servers (e.g. OpenSubsonic) for artist-level rating. */
|
||||
userRating?: number;
|
||||
/** Psysonic cluster: originating server for merged browse rows (client-only). */
|
||||
clusterSeedServerId?: string;
|
||||
}
|
||||
|
||||
export interface SubsonicGenre {
|
||||
|
||||
@@ -88,7 +88,7 @@ function AlbumCard({
|
||||
|
||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||
navigateToAlbum(album.id, { search: linkQuery });
|
||||
navigateToAlbum(album.id, { search: linkQuery, seedServerId: album.clusterSeedServerId });
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -23,7 +23,10 @@ export default function ArtistCardLocal({ artist, linkQuery, libraryResolve = fa
|
||||
return (
|
||||
<div
|
||||
className="artist-card"
|
||||
onClick={() => navigateToArtist(artist.id, linkQuery ? { search: linkQuery } : undefined)}
|
||||
onClick={() => navigateToArtist(artist.id, {
|
||||
search: linkQuery,
|
||||
seedServerId: artist.clusterSeedServerId,
|
||||
})}
|
||||
>
|
||||
<div className="artist-card-avatar">
|
||||
{coverRef ? (
|
||||
|
||||
@@ -16,6 +16,7 @@ import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
|
||||
import { isClusterMode } from '../utils/serverCluster/clusterScope';
|
||||
import { serverShareBaseUrl } from '../utils/server/serverEndpoint';
|
||||
import { copyTextToClipboard } from '../utils/server/serverMagicString';
|
||||
import { encodeSharePayload } from '../utils/share/shareLink';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useThemeStore } from '../store/themeStore';
|
||||
import { useLyricsStore } from '../store/lyricsStore';
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getAlbum } from '../api/subsonicLibrary';
|
||||
import { getArtist } from '../api/subsonicArtists';
|
||||
import type { SubsonicAlbum } from '../api/subsonicTypes';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { loadClusterAlbumDetail } from '../utils/serverCluster/clusterDetail';
|
||||
import { isClusterMode } from '../utils/serverCluster/clusterScope';
|
||||
import { readClusterSeedServerId } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
type AlbumPayload = Awaited<ReturnType<typeof getAlbum>>;
|
||||
type AlbumPayload = { album: SubsonicAlbum; songs: import('../api/subsonicTypes').SubsonicSong[] };
|
||||
|
||||
interface UseAlbumDetailDataResult {
|
||||
album: AlbumPayload | null;
|
||||
@@ -21,13 +25,12 @@ interface UseAlbumDetailDataResult {
|
||||
* a follow-up call so the related-albums grid can render without blocking
|
||||
* the initial paint.
|
||||
*
|
||||
* On every id change we reset `relatedAlbums` to an empty array so the
|
||||
* grid doesn't briefly show the previous album's neighbours while the
|
||||
* new fetch is in flight. The two starred state pieces (`isStarred`,
|
||||
* `starredSongs`) are seeded from the response so optimistic toggles
|
||||
* have a baseline to revert to.
|
||||
* In cluster mode, loads a virtual aggregate from the merged local index
|
||||
* (spec §4) — never falls back to a single-server `getAlbum`.
|
||||
*/
|
||||
export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataResult {
|
||||
const location = useLocation();
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const [album, setAlbum] = useState<AlbumPayload | null>(null);
|
||||
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -36,23 +39,56 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setRelatedAlbums([]);
|
||||
getAlbum(id).then(async data => {
|
||||
setAlbum(data);
|
||||
setIsStarred(!!data.album.starred);
|
||||
const initialStarred = new Set<string>();
|
||||
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
||||
setStarredSongs(initialStarred);
|
||||
setLoading(false);
|
||||
try {
|
||||
const artistData = await getArtist(data.album.artistId);
|
||||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch related albums', e);
|
||||
|
||||
(async () => {
|
||||
if (isClusterMode()) {
|
||||
const seedServerId = readClusterSeedServerId(location.state) ?? activeServerId ?? '';
|
||||
const clusterData = await loadClusterAlbumDetail({ albumId: id, seedServerId });
|
||||
if (cancelled) return;
|
||||
if (!clusterData) {
|
||||
setAlbum(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const payload: AlbumPayload = { album: clusterData.album, songs: clusterData.songs };
|
||||
setAlbum(payload);
|
||||
setIsStarred(!!clusterData.album.starred);
|
||||
const initialStarred = new Set<string>();
|
||||
clusterData.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
||||
setStarredSongs(initialStarred);
|
||||
setRelatedAlbums(clusterData.relatedAlbums);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}).catch(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
try {
|
||||
const data = await getAlbum(id);
|
||||
if (cancelled) return;
|
||||
setAlbum(data);
|
||||
setIsStarred(!!data.album.starred);
|
||||
const initialStarred = new Set<string>();
|
||||
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
|
||||
setStarredSongs(initialStarred);
|
||||
setLoading(false);
|
||||
try {
|
||||
const { getArtist } = await import('../api/subsonicArtists');
|
||||
const artistData = await getArtist(data.album.artistId);
|
||||
if (!cancelled) {
|
||||
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch related albums', e);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, location.state, activeServerId]);
|
||||
|
||||
return { album, setAlbum, relatedAlbums, loading, isStarred, setIsStarred, starredSongs, setStarredSongs };
|
||||
}
|
||||
|
||||
@@ -15,6 +15,22 @@ import type { SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||
|
||||
vi.mock('../api/subsonicArtists');
|
||||
vi.mock('../api/subsonicSearch');
|
||||
vi.mock('../utils/serverCluster/clusterScope', () => ({
|
||||
isClusterMode: () => false,
|
||||
}));
|
||||
vi.mock('react-router-dom', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('react-router-dom')>();
|
||||
return {
|
||||
...actual,
|
||||
useLocation: () => ({
|
||||
pathname: '/artist/test',
|
||||
search: '',
|
||||
hash: '',
|
||||
state: null,
|
||||
key: 'default',
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { search } from '../api/subsonicSearch';
|
||||
import { getArtist, getArtistInfo, getTopSongs } from '../api/subsonicArtists';
|
||||
import type {
|
||||
@@ -7,6 +8,9 @@ import type {
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { runLocalArtistLosslessBrowse } from '../utils/library/browseTextSearch';
|
||||
import { isLosslessSuffix } from '../utils/library/losslessFormats';
|
||||
import { loadClusterArtistDetail } from '../utils/serverCluster/clusterDetail';
|
||||
import { isClusterMode } from '../utils/serverCluster/clusterScope';
|
||||
import { readClusterSeedServerId } from '../utils/navigation/albumDetailNavigation';
|
||||
|
||||
export interface UseArtistDetailDataOptions {
|
||||
/** When true, albums and top tracks are limited to lossless containers (local index preferred). */
|
||||
@@ -45,6 +49,7 @@ export function useArtistDetailData(
|
||||
options: UseArtistDetailDataOptions = {},
|
||||
): ArtistDetailDataResult {
|
||||
const losslessOnly = options.losslessOnly ?? false;
|
||||
const location = useLocation();
|
||||
const serverId = useAuthStore(s => s.activeServerId);
|
||||
const audiomuseNavidromeEnabled = useAuthStore(
|
||||
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
|
||||
@@ -71,6 +76,29 @@ export function useArtistDetailData(
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (isClusterMode()) {
|
||||
const seedServerId = readClusterSeedServerId(location.state) ?? serverId ?? '';
|
||||
const clusterData = await loadClusterArtistDetail({ artistId: id, seedServerId });
|
||||
if (cancelled) return;
|
||||
if (!clusterData) {
|
||||
setArtist(null);
|
||||
setAlbums([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let nextAlbums = clusterData.albums;
|
||||
let nextSongs = clusterData.topSongs;
|
||||
if (losslessOnly) {
|
||||
({ albums: nextAlbums, songs: nextSongs } = filterNetworkArtistToLossless(nextAlbums, nextSongs));
|
||||
}
|
||||
setArtist(clusterData.artist);
|
||||
setAlbums(nextAlbums);
|
||||
setTopSongs(nextSongs);
|
||||
setIsStarred(!!clusterData.artist.starred);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (losslessOnly && serverId) {
|
||||
const local = await runLocalArtistLosslessBrowse(serverId, id);
|
||||
if (cancelled) return;
|
||||
@@ -112,7 +140,7 @@ export function useArtistDetailData(
|
||||
})();
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [id, losslessOnly, serverId]);
|
||||
}, [id, losslessOnly, serverId, location.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useNavigateToAlbum() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return useCallback(
|
||||
(albumId: string, opts?: { search?: string }) => {
|
||||
(albumId: string, opts?: { search?: string; seedServerId?: string }) => {
|
||||
navigateToAlbumDetail(navigate, location, albumId, opts);
|
||||
},
|
||||
[navigate, location],
|
||||
|
||||
@@ -7,7 +7,7 @@ export function useNavigateToArtist() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
return useCallback(
|
||||
(artistId: string, opts?: { search?: string }) => {
|
||||
(artistId: string, opts?: { search?: string; seedServerId?: string }) => {
|
||||
navigateToArtistDetail(navigate, location, artistId, opts);
|
||||
},
|
||||
[navigate, location],
|
||||
|
||||
@@ -238,6 +238,7 @@ export function albumToAlbum(a: LibraryAlbumDto): SubsonicAlbum {
|
||||
};
|
||||
const merged = mergeAlbumRawJson(base, raw as Partial<SubsonicAlbum>);
|
||||
if (albumIsCompilation(merged)) merged.isCompilation = true;
|
||||
merged.clusterSeedServerId = a.serverId;
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -249,7 +250,7 @@ export function artistToArtist(ar: LibraryArtistDto): SubsonicArtist {
|
||||
albumCount: ar.albumCount ?? undefined,
|
||||
coverArt: ar.id,
|
||||
};
|
||||
return { ...base, ...(raw as Partial<SubsonicArtist>) };
|
||||
return { ...base, ...(raw as Partial<SubsonicArtist>), clusterSeedServerId: ar.serverId };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type LibrarySearchSurface,
|
||||
} from './libraryDevLog';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import { clusterBrowseArtistsPage, clusterBrowseTextSearchPage } from '../serverCluster/clusterBrowse';
|
||||
import { clusterBrowseArtistsPage, clusterBrowseTextSearchPage, clusterBrowseTracksPage } from '../serverCluster/clusterBrowse';
|
||||
import { raceSearchSources, type SearchRaceWinner } from './searchRace';
|
||||
|
||||
export type { LibrarySearchSurface };
|
||||
|
||||
@@ -15,6 +15,8 @@ import {
|
||||
|
||||
export type AlbumDetailLocationState = {
|
||||
returnTo?: string;
|
||||
/** Cluster mode: server that owns the seed entity id (from merged browse). */
|
||||
clusterSeedServerId?: string;
|
||||
};
|
||||
|
||||
export type AlbumsBrowseRestoreLocationState = {
|
||||
@@ -24,6 +26,12 @@ export type AlbumsBrowseRestoreLocationState = {
|
||||
advancedSearchRestore?: boolean;
|
||||
};
|
||||
|
||||
export function readClusterSeedServerId(state: unknown): string | null {
|
||||
const seed = (state as AlbumDetailLocationState | null)?.clusterSeedServerId;
|
||||
if (typeof seed !== 'string' || seed.length === 0) return null;
|
||||
return seed;
|
||||
}
|
||||
|
||||
export function readAlbumDetailReturnTo(state: unknown): string | null {
|
||||
const returnTo = (state as AlbumDetailLocationState | null)?.returnTo;
|
||||
if (typeof returnTo !== 'string' || returnTo.length === 0) return null;
|
||||
@@ -173,26 +181,30 @@ export function navigateToAlbumDetail(
|
||||
navigate: NavigateFunction,
|
||||
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
|
||||
albumId: string,
|
||||
opts?: { search?: string },
|
||||
opts?: { search?: string; seedServerId?: string },
|
||||
): void {
|
||||
saveSearchLeaveIfNeeded(location);
|
||||
const returnTo = buildReturnTo(location);
|
||||
const raw = opts?.search ?? '';
|
||||
const qs = raw ? (raw.startsWith('?') ? raw : `?${raw}`) : '';
|
||||
navigate(`/album/${albumId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
|
||||
const state: AlbumDetailLocationState = { returnTo };
|
||||
if (opts?.seedServerId) state.clusterSeedServerId = opts.seedServerId;
|
||||
navigate(`/album/${albumId}${qs}`, { state });
|
||||
}
|
||||
|
||||
export function navigateToArtistDetail(
|
||||
navigate: NavigateFunction,
|
||||
location: Pick<Location, 'pathname' | 'search' | 'hash' | 'state'>,
|
||||
artistId: string,
|
||||
opts?: { search?: string },
|
||||
opts?: { search?: string; seedServerId?: string },
|
||||
): void {
|
||||
saveSearchLeaveIfNeeded(location);
|
||||
const returnTo = buildReturnTo(location);
|
||||
const raw = opts?.search ?? '';
|
||||
const qs = raw ? (raw.startsWith('?') ? raw : `?${raw}`) : '';
|
||||
navigate(`/artist/${artistId}${qs}`, { state: { returnTo } satisfies AlbumDetailLocationState });
|
||||
const state: AlbumDetailLocationState = { returnTo };
|
||||
if (opts?.seedServerId) state.clusterSeedServerId = opts.seedServerId;
|
||||
navigate(`/artist/${artistId}${qs}`, { state });
|
||||
}
|
||||
|
||||
export function navigateToComposerDetail(
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Cluster-mode virtual aggregate detail pages (spec §4 — `/album/:id`, `/artist/:id`).
|
||||
*/
|
||||
import {
|
||||
libraryClusterAlbumDetail,
|
||||
libraryClusterArtistDetail,
|
||||
type LibraryAlbumDto,
|
||||
type LibraryTrackDto,
|
||||
} from '../../api/library';
|
||||
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
import { albumToAlbum, artistToArtist, trackToSong } from '../library/advancedSearchLocal';
|
||||
import { resolveClusterBrowseMembers } from './clusterBrowse';
|
||||
|
||||
export async function loadClusterAlbumDetail(args: {
|
||||
albumId: string;
|
||||
seedServerId: string;
|
||||
}): Promise<{ album: SubsonicAlbum; songs: SubsonicSong[]; relatedAlbums: SubsonicAlbum[] } | null> {
|
||||
const members = await resolveClusterBrowseMembers();
|
||||
if (!members?.length) return null;
|
||||
try {
|
||||
const resp = await libraryClusterAlbumDetail({
|
||||
serversOrdered: members,
|
||||
serverId: args.seedServerId,
|
||||
entityId: args.albumId,
|
||||
});
|
||||
return {
|
||||
album: albumToAlbum(resp.album),
|
||||
songs: resp.tracks.map(trackToSong),
|
||||
relatedAlbums: resp.relatedAlbums.map(albumToAlbum),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadClusterArtistDetail(args: {
|
||||
artistId: string;
|
||||
seedServerId: string;
|
||||
}): Promise<{
|
||||
artist: SubsonicArtist;
|
||||
albums: SubsonicAlbum[];
|
||||
topSongs: SubsonicSong[];
|
||||
} | null> {
|
||||
const members = await resolveClusterBrowseMembers();
|
||||
if (!members?.length) return null;
|
||||
try {
|
||||
const resp = await libraryClusterArtistDetail({
|
||||
serversOrdered: members,
|
||||
serverId: args.seedServerId,
|
||||
entityId: args.artistId,
|
||||
});
|
||||
return {
|
||||
artist: artistToArtist(resp.artist),
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
topSongs: resp.topTracks.map(trackToSong),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Map merged library rows when callers need raw DTO access. */
|
||||
export function mapClusterAlbumDto(a: LibraryAlbumDto): SubsonicAlbum {
|
||||
return albumToAlbum(a);
|
||||
}
|
||||
|
||||
export function mapClusterTrackDto(t: LibraryTrackDto): SubsonicSong {
|
||||
return trackToSong(t);
|
||||
}
|
||||
@@ -34,7 +34,7 @@ export async function clusterFanOutScrobbleSubmission(
|
||||
const targets = await allCandidateTrackIds(browseServerId, trackId);
|
||||
const toWrite = syncAll ? targets : targets.slice(0, 1);
|
||||
await Promise.allSettled(
|
||||
toWrite.map(async ({ serverId, tid }) => {
|
||||
toWrite.map(async ({ serverId, trackId: tid }) => {
|
||||
await apiForServer(serverId, 'scrobble.view', { id: tid, submission: true, time });
|
||||
patchLibraryTrackOnUse(serverId, tid, { playedAt: time });
|
||||
}),
|
||||
@@ -50,7 +50,7 @@ export async function clusterFanOutStar(
|
||||
if (!isClusterMode()) return;
|
||||
const targets = await allCandidateTrackIds(browseServerId, trackId);
|
||||
await Promise.allSettled(
|
||||
targets.map(async ({ serverId, tid }) => {
|
||||
targets.map(async ({ serverId, trackId: tid }) => {
|
||||
const params = { id: tid };
|
||||
await apiForServer(serverId, star ? 'star.view' : 'unstar.view', params);
|
||||
patchLibraryTrackOnUse(serverId, tid, { starredAt: star ? Date.now() : null });
|
||||
@@ -67,7 +67,7 @@ export async function clusterFanOutRating(
|
||||
if (!isClusterMode()) return;
|
||||
const targets = await allCandidateTrackIds(browseServerId, trackId);
|
||||
await Promise.allSettled(
|
||||
targets.map(async ({ serverId, tid }) => {
|
||||
targets.map(async ({ serverId, trackId: tid }) => {
|
||||
await apiForServer(serverId, 'setRating.view', { id: tid, rating });
|
||||
patchLibraryTrackOnUse(serverId, tid, { userRating: rating });
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user