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:
Maxim Isaev
2026-06-05 17:02:25 +03:00
parent e601b5888a
commit 3447cf9fdd
20 changed files with 1164 additions and 35 deletions
@@ -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,
+2
View File
@@ -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,
+58
View File
@@ -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;
+4
View File
@@ -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 {
+1 -1
View File
@@ -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 (
+4 -1
View File
@@ -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 ? (
+1
View File
@@ -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';
+57 -21
View File
@@ -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 };
}
+16
View File
@@ -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';
+29 -1
View File
@@ -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;
+1 -1
View File
@@ -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],
+1 -1
View File
@@ -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],
+2 -1
View File
@@ -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 };
}
/**
+1 -1
View File
@@ -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 };
+16 -4
View File
@@ -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(
+69
View File
@@ -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 });
}),