revert: multi-server library scope (#1309) (#1310)

This commit is contained in:
cucadmuh
2026-07-16 13:56:08 +03:00
committed by GitHub
parent 599ac31306
commit 25b8b57328
421 changed files with 2030 additions and 12081 deletions
+1 -1
View File
@@ -11,7 +11,7 @@ psysonic-core = { path = "../psysonic-core" }
psysonic-integration = { path = "../psysonic-integration" }
tauri = { version = "2" }
specta = { version = "=2.0.0-rc.25", features = ["derive", "function"] }
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
rusqlite = { version = "0.40", features = ["bundled"] }
@@ -194,24 +194,14 @@ pub fn run_advanced_search(
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
)?;
);
// Any >1-library scope dedups album/artist rows via cluster keys, including
// the Layer-1 same-server path — build keys first so dedup works on a cold
// index (idempotent; only rebuilds when needed).
let pair_reader_required = multi_library_merge_enabled(&scope_pairs)
|| scope_pairs
.first()
.is_some_and(|pair| pair.server_id != req.server_id);
if pair_reader_required {
for server_id in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::<HashSet<_>>() {
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
if multi_library_merge_enabled(&scope_pairs) {
crate::identity::ensure_cluster_keys_built(store, &req.server_id)?;
}
if scoped_layer1_eligible(&scope_pairs)
&& scope_pairs
.first()
.is_some_and(|pair| pair.server_id == req.server_id)
{
if scoped_layer1_eligible(&scope_pairs) {
return run_advanced_search_layer1_scope(
store,
req,
@@ -223,7 +213,7 @@ pub fn run_advanced_search(
skip_totals,
);
}
if pair_reader_required {
if multi_library_merge_enabled(&scope_pairs) {
return run_advanced_search_multi_scope(
store,
req,
@@ -239,7 +229,7 @@ pub fn run_advanced_search(
let mut legacy = req.clone();
if legacy.library_scope.is_none() {
if let Some(pair) = scope_pairs.first() {
legacy.library_scope = pair.library_id.clone();
legacy.library_scope = Some(pair.library_id.clone());
}
}
@@ -1150,12 +1140,11 @@ fn push_artist_library_scope_pairs(
pairs: &[LibraryScopePair],
applied: &mut BTreeSet<String>,
) {
if pairs.iter().any(|p| p.library_id.is_none()) {
return;
}
// Pairs may carry profile or index `server_id`; this query is already pinned to
// one server via `ar.server_id = ?`, so only drop empty library ids.
let scoped: Vec<&LibraryScopePair> = pairs
.iter()
.filter(|p| p.server_id == _server_id)
.filter(|p| !p.library_id.trim().is_empty())
.collect();
if scoped.is_empty() {
return;
@@ -1166,7 +1155,7 @@ fn push_artist_library_scope_pairs(
let clause = library_scope_sargable_equals_sql("t");
w.push_params(
&format!("{exists_prefix}{clause})"),
vec![SqlValue::Text(scoped[0].library_id.clone().unwrap_or_default())],
vec![SqlValue::Text(scoped[0].library_id.clone())],
);
} else {
let in_clause = library_scope_in_sql("t", scoped.len());
@@ -1174,7 +1163,7 @@ fn push_artist_library_scope_pairs(
&format!("{exists_prefix}{in_clause})"),
scoped
.iter()
.map(|p| SqlValue::Text(p.library_id.clone().unwrap_or_default()))
.map(|p| SqlValue::Text(p.library_id.clone()))
.collect(),
);
}
@@ -1186,7 +1175,7 @@ fn push_artist_library_scope(w: &mut WhereBuilder, req: &LibraryAdvancedSearchRe
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
).unwrap_or_default();
);
push_artist_library_scope_pairs(w, &req.server_id, &pairs, applied);
}
@@ -2125,7 +2114,6 @@ struct AlbumOrderCols {
name: &'static str,
artist: String,
year: &'static str,
synced: &'static str,
}
impl AlbumOrderCols {
@@ -2136,7 +2124,6 @@ impl AlbumOrderCols {
name: "MAX(t.album) COLLATE NOCASE",
artist: sql_display_artist_from("MAX(t.artist)", "MAX(t.album_artist)"),
year: "MAX(t.year)",
synced: "MAX(t.synced_at)",
}
}
@@ -2146,7 +2133,6 @@ impl AlbumOrderCols {
name: "album COLLATE NOCASE",
artist: sql_display_artist_from("artist", "album_artist"),
year: "year",
synced: "synced_at",
}
}
}
@@ -2158,7 +2144,6 @@ fn album_order_sql(sort: &[LibrarySortClause], cols: &AlbumOrderCols) -> Option<
"name" => cols.name.to_string(),
"artist" => format!("{} COLLATE NOCASE", cols.artist),
"year" => cols.year.to_string(),
"synced" => cols.synced.to_string(),
"random" => "RANDOM()".to_string(),
_ => continue,
};
@@ -2194,7 +2179,6 @@ pub(crate) fn sort_column(field: &str, entity: EntityKind) -> Option<&'static st
("name", EntityKind::Album) => Some("a.name COLLATE NOCASE"),
("year", EntityKind::Album) => Some("a.year"),
("artist", EntityKind::Album) => Some("a.artist COLLATE NOCASE"),
("synced", EntityKind::Album) => Some("a.synced_at"),
("name", EntityKind::Artist) => Some("COALESCE(ar.name_sort, ar.name) COLLATE NOCASE"),
// SQLite built-in: ORDER BY RANDOM() LIMIT N — fast pseudo-random sample,
// no index scan needed beyond the row-id range. Direction is ignored.
@@ -3611,7 +3595,7 @@ mod tests {
fn scope_pair(server: &str, lib: &str) -> crate::dto::LibraryScopePair {
crate::dto::LibraryScopePair {
server_id: server.into(),
library_id: Some(lib.into()),
library_id: lib.into(),
}
}
@@ -4098,27 +4082,4 @@ mod tests {
assert_eq!(legacy_resp.albums, scoped_resp.albums);
assert_eq!(legacy_resp.totals, scoped_resp.totals);
}
#[test]
fn single_exact_pair_on_another_server_uses_pair_server() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store)
.upsert_batch(&[
scoped_track(
"s1", "fallback", "Song", "Artist", "Fallback", "al1", "lib",
None, None, None,
),
scoped_track(
"s2", "selected", "Song", "Artist", "Selected", "al2", "lib",
None, None, None,
),
])
.unwrap();
let mut request = req("s1", &[EntityKind::Track]);
request.library_scopes = Some(vec![scope_pair("s2", "lib")]);
let response = run_advanced_search(&store, &request).unwrap();
assert_eq!(response.tracks.len(), 1);
assert_eq!(response.tracks[0].id, "selected");
assert_eq!(response.tracks[0].server_id, "s2");
}
}
@@ -2,8 +2,7 @@
use crate::dto::{
LibraryAlbumDto, LibraryArtistLosslessBrowseRequest, LibraryArtistLosslessBrowseResponse,
LibraryScopeArtistDetailRequest, LibraryTrackDto, multi_library_merge_enabled,
ordered_library_scope_pairs,
LibraryTrackDto,
};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::{
@@ -40,61 +39,9 @@ pub fn get_artist_lossless_browse(
store: &LibraryStore,
req: &LibraryArtistLosslessBrowseRequest,
) -> Result<LibraryArtistLosslessBrowseResponse, String> {
let scope_pairs = ordered_library_scope_pairs(
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
)?;
if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? {
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(empty_response());
}
let use_pair_reader = scope_pairs.len() > 1
|| scope_pairs.first().is_some_and(|pair| {
pair.server_id != req.server_id || pair.library_id.is_none()
});
if use_pair_reader {
if multi_library_merge_enabled(&scope_pairs) {
for server_id in scope_pairs
.iter()
.map(|p| p.server_id.as_str())
.collect::<std::collections::HashSet<_>>()
{
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
}
let detail = crate::scope_merge::artist_detail(
store,
&LibraryScopeArtistDetailRequest {
scopes: scope_pairs,
artist_id: req.artist_id.clone(),
server_id: req.server_id.clone(),
},
)?;
let tracks: Vec<_> = detail
.tracks
.into_iter()
.filter(|track| {
track
.suffix
.as_deref()
.is_some_and(|suffix| crate::lossless_formats::LOSSLESS_SUFFIXES.contains(&suffix.to_ascii_lowercase().as_str()))
})
.collect();
let album_ids: std::collections::HashSet<(&str, &str)> = tracks
.iter()
.filter_map(|track| track.album_id.as_deref().map(|id| (track.server_id.as_str(), id)))
.collect();
let albums = detail
.albums
.into_iter()
.filter(|album| album_ids.contains(&(album.server_id.as_str(), album.id.as_str())))
.collect();
return Ok(LibraryArtistLosslessBrowseResponse {
albums,
tracks,
source: "local".to_string(),
});
}
let lossless_sql = track_is_lossless_sql("t");
let mut track_where = vec![
@@ -108,11 +55,8 @@ pub fn get_artist_lossless_browse(
SqlValue::Text(req.artist_id.clone()),
];
let scope_ids = scope_pairs
.first()
.and_then(|pair| pair.library_id.clone())
.map(|library_id| vec![library_id])
.unwrap_or_else(|| combined_scope_library_ids(req.library_scope.as_deref(), None));
let scope_ids =
combined_scope_library_ids(req.library_scope.as_deref(), req.library_scopes.as_deref());
push_library_scope_filter(&mut track_where, &mut track_params, &scope_ids);
let track_where_sql = track_where.join(" AND ");
@@ -7,7 +7,6 @@ use tauri::State;
use crate::dto::CatalogYearBoundsDto;
use crate::dto::GenreAlbumCountDto;
use crate::dto::LibraryAlbumDto;
use crate::dto::LibraryScopePair;
use crate::runtime::LibraryRuntime;
use crate::store::LibraryStore;
use crate::sync::mapping::format_iso_ms_z;
@@ -258,39 +257,6 @@ pub fn library_get_catalog_year_bounds(
result
}
pub(crate) fn catalog_year_bounds_for_scopes(
store: &LibraryStore,
scopes: &[LibraryScopePair],
) -> Result<CatalogYearBoundsDto, String> {
let scopes = crate::scope_merge::normalize_scope_pairs(scopes)?;
if scopes.is_empty() {
return Ok(CatalogYearBoundsDto { min_year: None, max_year: None });
}
let (cte, binds) = crate::scope_merge::scope_cte_sql(&scopes);
let sql = format!(
"{cte} SELECT MIN(t.year), MAX(t.year) \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
WHERE t.deleted = 0 AND t.year IS NOT NULL AND t.year > 0"
);
store.with_read_conn(|conn| {
conn.query_row(&sql, rusqlite::params_from_iter(binds.iter()), |row| {
Ok(CatalogYearBoundsDto {
min_year: row.get::<_, Option<i64>>(0)?.map(|year| year as i32),
max_year: row.get::<_, Option<i64>>(1)?.map(|year| year as i32),
})
})
}).map_err(|e| e.to_string())
}
#[tauri::command]
#[specta::specta]
pub fn library_scope_catalog_year_bounds(
runtime: State<'_, LibraryRuntime>,
scopes: Vec<LibraryScopePair>,
) -> Result<CatalogYearBoundsDto, String> {
catalog_year_bounds_for_scopes(&runtime.store, &scopes)
}
pub(crate) fn genre_album_counts_for_server(
store: &LibraryStore,
server_id: &str,
@@ -338,55 +304,6 @@ pub(crate) fn genre_album_counts_for_server(
.map_err(|e| e.to_string())
}
pub(crate) fn genre_album_counts_for_scopes(
store: &LibraryStore,
scopes: &[LibraryScopePair],
) -> Result<Vec<GenreAlbumCountDto>, String> {
let scopes = crate::scope_merge::normalize_scope_pairs(scopes)?;
if scopes.is_empty() {
return Ok(Vec::new());
}
if crate::dto::multi_library_merge_enabled(&scopes) {
for server_id in scopes
.iter()
.map(|pair| pair.server_id.as_str())
.collect::<std::collections::HashSet<_>>()
{
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
}
let (cte, binds) = crate::scope_merge::scope_cte_sql(&scopes);
let album_key = crate::scope_merge::ALBUM_DEDUP_KEY;
let track_key = crate::scope_merge::TRACK_DEDUP_KEY;
let sql = format!(
"{cte}, genre_rows AS ( \
SELECT tg.genre, {album_key} AS album_dedup, {track_key} AS track_dedup \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
INNER JOIN track_genre tg ON tg.server_id = t.server_id AND tg.track_id = t.id \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 AND tg.album_id IS NOT NULL AND tg.album_id != '' \
) \
SELECT genre, COUNT(DISTINCT album_dedup), COUNT(DISTINCT track_dedup) \
FROM genre_rows GROUP BY genre COLLATE NOCASE HAVING COUNT(DISTINCT album_dedup) > 0 \
ORDER BY 2 DESC, genre COLLATE NOCASE ASC"
);
store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt
.query_map(rusqlite::params_from_iter(binds.iter()), |r| {
Ok(GenreAlbumCountDto {
value: r.get(0)?,
album_count: r.get::<_, i64>(1)?.max(0) as u32,
song_count: r.get::<_, i64>(2)?.max(0) as u32,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())
}
/// Distinct album counts per track genre — same grouping as genre album browse.
#[tauri::command]
#[specta::specta]
@@ -394,21 +311,19 @@ pub fn library_get_genre_album_counts(
runtime: State<'_, LibraryRuntime>,
server_id: String,
library_scope: Option<String>,
library_scopes: Option<Vec<LibraryScopePair>>,
library_scopes: Option<Vec<String>>,
) -> Result<Vec<GenreAlbumCountDto>, String> {
let trace = psysonic_core::logging::should_log_albums_browse_trace();
let scopes = crate::dto::ordered_library_scope_pairs(
&server_id,
library_scope.as_deref(),
library_scopes.as_deref(),
)?;
let scopes = if let Some(scopes) = library_scopes {
normalized_library_scopes(&scopes)
} else if let Some(scope) = library_scope.as_deref().filter(|s| !s.trim().is_empty()) {
vec![scope.to_string()]
} else {
vec![]
};
let trace_scopes = scopes.clone();
let t0 = std::time::Instant::now();
let result = if scopes.is_empty() {
genre_album_counts_for_server(&runtime.store, &server_id, &[])
} else {
genre_album_counts_for_scopes(&runtime.store, &scopes)
};
let result = genre_album_counts_for_server(&runtime.store, &server_id, &scopes);
if trace {
let step_ms = t0.elapsed().as_millis();
let genre_count = result.as_ref().map(|rows| rows.len()).unwrap_or(0);
@@ -439,8 +354,7 @@ mod tests {
use crate::store::LibraryStore;
use super::{
apply_album_patch, catalog_year_bounds_for_server, genre_album_counts_for_scopes,
genre_album_counts_for_server, catalog_year_bounds_for_scopes,
apply_album_patch, catalog_year_bounds_for_server, genre_album_counts_for_server,
overlay_album_level_starred_at, reconcile_album_stars, StarredAlbumReconcileItem,
};
use crate::dto::LibraryAlbumDto;
@@ -538,27 +452,6 @@ mod tests {
assert!(!raw.contains("starred"));
}
#[test]
fn catalog_year_bounds_respect_cross_server_scope() {
let store = LibraryStore::open_in_memory();
let mut old = make_row("s1", "t1", "al1", 1);
old.library_id = Some("a".into());
old.year = Some(1990);
let mut recent = make_row("s2", "t2", "al2", 1);
recent.library_id = Some("b".into());
recent.year = Some(2024);
let mut excluded = make_row("s2", "t3", "al3", 1);
excluded.library_id = Some("other".into());
excluded.year = Some(2030);
TrackRepository::new(&store).upsert_batch(&[old, recent, excluded]).unwrap();
let bounds = catalog_year_bounds_for_scopes(&store, &[
crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) },
crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: Some("b".into()) },
]).unwrap();
assert_eq!(bounds.min_year, Some(1990));
assert_eq!(bounds.max_year, Some(2024));
}
#[test]
fn apply_album_patch_clears_stale_starred_in_raw_json() {
let store = Arc::new(LibraryStore::open_in_memory());
@@ -737,34 +630,6 @@ mod tests {
assert_eq!(counts[1].song_count, 1);
}
#[test]
fn genre_album_counts_include_cross_server_whole_sources_and_empty_library_rows() {
let store = LibraryStore::open_in_memory();
let mut first = make_row("s1", "t1", "al1", 1);
first.library_id = Some("lib-a".into());
first.genre = Some("Rock".into());
first.title = "Shared Track".into();
first.album = "Shared Album".into();
let mut second = make_row("s2", "t2", "al2", 1);
second.library_id = Some(String::new());
second.genre = Some("Rock".into());
second.title = "Shared Track".into();
second.album = "Shared Album".into();
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
let counts = genre_album_counts_for_scopes(
&store,
&[
crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: None },
crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: None },
],
)
.unwrap();
let rock = counts.iter().find(|row| row.value == "Rock").unwrap();
assert_eq!(rock.album_count, 1);
assert_eq!(rock.song_count, 1);
}
#[test]
fn genre_album_counts_respect_library_scope() {
let store = Arc::new(LibraryStore::open_in_memory());
@@ -23,7 +23,6 @@ use crate::dto::{
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse,
LibraryEntitySourceDto, LibraryResolveEntitySourcesRequest,
LibraryScopeAlbumDetailRequest, LibraryScopeAlbumDetailResponse, LibraryScopeArtistDetailRequest,
LibraryScopeArtistDetailResponse, LibraryScopeListRequest, LibraryScopeSearchRequest,
LibraryTrackDto,
@@ -687,16 +686,6 @@ pub fn library_cluster_rebuild(
crate::identity::rebuild_cluster_keys(&runtime.store, server_id)
}
#[tauri::command]
#[specta::specta]
pub async fn library_resolve_entity_sources(
runtime: State<'_, LibraryRuntime>,
request: LibraryResolveEntitySourcesRequest,
) -> Result<Vec<LibraryEntitySourceDto>, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || scope_merge::resolve_entity_sources(&store, &request)).await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_scope_list_albums(
@@ -747,36 +736,6 @@ pub async fn library_scope_artist_detail(
library_spawn_blocking(move || scope_merge::artist_detail(&store, &request)).await
}
#[tauri::command]
#[specta::specta]
pub async fn library_scope_catalog_statistics(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryScopeCatalogStatisticsRequest,
) -> Result<crate::dto::LibraryScopeCatalogStatisticsDto, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::scope_statistics::catalog_statistics(&store, &request)).await
}
// NOT specta-collected: nested album DTO carries `raw_json: Value`.
#[tauri::command]
pub async fn library_scope_most_played_albums(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryScopeMostPlayedRequest,
) -> Result<Vec<crate::dto::LibraryScopeMostPlayedAlbumDto>, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::scope_statistics::most_played_albums(&store, &request)).await
}
// NOT specta-collected: artist DTO carries `raw_json: Value`.
#[tauri::command]
pub async fn library_scope_list_artists_by_role(
runtime: State<'_, LibraryRuntime>,
request: crate::dto::LibraryScopeArtistRoleRequest,
) -> Result<Vec<crate::dto::LibraryArtistDto>, String> {
let store = Arc::clone(&runtime.store);
library_spawn_blocking(move || crate::scope_statistics::artists_by_role(&store, &request)).await
}
// NOT specta-collected: returns a DTO carrying `raw_json: Value` (LibraryTrack/Album/ArtistDto) — specta rc.25 can't export serde_json::Value. Stays hand-written on generate_handler!.
#[tauri::command]
pub async fn library_get_artist_lossless_browse(
+22 -109
View File
@@ -391,59 +391,6 @@ pub struct CatalogYearBoundsDto {
pub max_year: Option<i32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeCatalogStatisticsRequest {
pub scopes: Vec<LibraryScopePair>,
#[serde(default)]
pub format_sample_limit: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeFormatCountDto {
pub format: String,
pub count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeCatalogStatisticsDto {
pub artist_count: u32,
pub album_count: u32,
pub track_count: u32,
pub duration_sec: i64,
pub genres: Vec<GenreAlbumCountDto>,
pub formats: Vec<LibraryScopeFormatCountDto>,
pub format_sample_size: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeMostPlayedRequest {
pub scopes: Vec<LibraryScopePair>,
#[serde(default)]
pub limit: Option<u32>,
#[serde(default)]
pub offset: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeMostPlayedAlbumDto {
pub album: LibraryAlbumDto,
pub play_count: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopeArtistRoleRequest {
pub scopes: Vec<LibraryScopePair>,
pub role: String,
#[serde(default)]
pub limit: Option<u32>,
}
/// Per-genre album/track totals from the local track catalog (Genres cloud + browse).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
@@ -695,10 +642,10 @@ pub struct LibraryLosslessAlbumsRequest {
pub server_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Ordered server/library sources; takes precedence over the legacy single
/// `library_scope` when present. `library_id: None` means the whole server.
/// Ordered library ids for a multi-library selection; takes precedence over
/// the legacy single `library_scope` when present.
#[serde(default)]
pub library_scopes: Option<Vec<LibraryScopePair>>,
pub library_scopes: Option<Vec<String>>,
#[serde(default = "default_lossless_limit")]
pub limit: u32,
#[serde(default)]
@@ -726,10 +673,10 @@ pub struct LibraryArtistLosslessBrowseRequest {
pub artist_id: String,
#[serde(default)]
pub library_scope: Option<String>,
/// Ordered server/library sources; takes precedence over the legacy single
/// `library_scope` when present. `library_id: None` means the whole server.
/// Ordered library ids for a multi-library selection; takes precedence over
/// the legacy single `library_scope` when present.
#[serde(default)]
pub library_scopes: Option<Vec<LibraryScopePair>>,
pub library_scopes: Option<Vec<String>>,
}
/// Lossless albums + tracks for one artist (local index).
@@ -746,73 +693,37 @@ pub struct LibraryArtistLosslessBrowseResponse {
// ──────────────────────────────────────────────────────────────────────
/// One `(server_id, library_id)` pair in priority order (index 0 = highest).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct LibraryScopePair {
pub server_id: String,
/// `None` means every indexed library on this server. `Some("")` is the
/// concrete implicit library id and must not be treated as whole-server.
pub library_id: Option<String>,
}
/// Entity kind accepted by `library_resolve_entity_sources`.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum LibrarySourceEntityType {
Track,
Album,
Artist,
}
/// Resolve one concrete browse entity to every matching concrete source in an
/// explicitly ordered scope.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryResolveEntitySourcesRequest {
pub entity_type: LibrarySourceEntityType,
pub anchor_server_id: String,
pub anchor_id: String,
pub scopes: Vec<LibraryScopePair>,
}
/// Concrete source metadata for one browse identity partition. Identity keys
/// remain internal so the frontend contract cannot persist raw cluster hashes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LibraryEntitySourceDto {
pub server_id: String,
pub id: String,
pub library_id: String,
pub priority: u32,
pub duration_sec: Option<i64>,
pub suffix: Option<String>,
pub bit_rate: Option<i64>,
pub size_bytes: Option<i64>,
pub starred_at: Option<i64>,
pub user_rating: Option<i64>,
}
/// Derive ordered `(server_id, library_id)` pairs from request fields.
/// List order is merge priority (index 0 wins). Duplicate pairs keep their first
/// occurrence; mixing whole-server and exact-library sources for one server is rejected.
/// List order is merge priority (index 0 wins). Empty = all libraries on the server.
pub(crate) fn ordered_library_scope_pairs(
server_id: &str,
library_scope: Option<&str>,
library_scopes: Option<&[LibraryScopePair]>,
) -> Result<Vec<LibraryScopePair>, String> {
) -> Vec<LibraryScopePair> {
if let Some(scopes) = library_scopes {
let pairs = crate::scope_merge::normalize_scope_pairs(scopes)?;
let pairs: Vec<LibraryScopePair> = scopes
.iter()
.filter(|p| !p.server_id.trim().is_empty() && !p.library_id.trim().is_empty())
.cloned()
.collect();
if !pairs.is_empty() {
return Ok(pairs);
return pairs;
}
}
if let Some(scope) = library_scope.map(str::trim).filter(|s| !s.is_empty()) {
return Ok(vec![LibraryScopePair {
return vec![LibraryScopePair {
server_id: server_id.to_string(),
library_id: Some(scope.to_string()),
}]);
library_id: scope.to_string(),
}];
}
Ok(Vec::new())
Vec::new()
}
/// Layer-2 dedup runs only when the ordered scope has more than one pair.
@@ -825,7 +736,9 @@ pub(crate) fn scoped_layer1_eligible(scopes: &[LibraryScopePair]) -> bool {
let Some(first) = scopes.first() else {
return false;
};
scopes.iter().all(|p| p.server_id == first.server_id)
scopes
.iter()
.all(|p| p.server_id == first.server_id && !p.library_id.trim().is_empty())
}
/// Paginated album/artist browse over an ordered multi-library scope.
@@ -88,6 +88,15 @@ pub fn list_albums_by_genre(
store: &LibraryStore,
req: &LibraryGenreAlbumsRequest,
) -> Result<LibraryGenreAlbumsResponse, String> {
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryGenreAlbumsResponse {
albums: Vec::new(),
has_more: false,
total: None,
source: "local".to_string(),
});
}
let genre = req.genre.trim();
if genre.is_empty() {
return Ok(LibraryGenreAlbumsResponse {
@@ -105,23 +114,13 @@ pub fn list_albums_by_genre(
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
)?;
if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(LibraryGenreAlbumsResponse {
albums: Vec::new(),
has_more: false,
total: None,
source: "local".to_string(),
});
}
);
// Any >1-library scope collapses duplicates via cluster keys — including the
// Layer-1 same-server path, whose genre `EXISTS` sets `merge_by_album_key`.
// Build keys first so dedup works on a cold index (not only after a prior
// search / sync-idle rebuild happened to populate them).
if multi_library_merge_enabled(&scope_pairs) {
for server_id in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::<std::collections::HashSet<_>>() {
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
crate::identity::ensure_cluster_keys_built(store, &req.server_id)?;
}
if scoped_layer1_eligible(&scope_pairs) {
return list_albums_by_genre_layer1_scope(store, req, &scope_pairs, genre, limit, offset);
@@ -133,7 +132,7 @@ pub fn list_albums_by_genre(
let mut legacy = req.clone();
if legacy.library_scope.is_none() {
if let Some(pair) = scope_pairs.first() {
legacy.library_scope = pair.library_id.clone();
legacy.library_scope = Some(pair.library_id.clone());
}
}
@@ -368,40 +367,6 @@ mod tests {
}
}
#[test]
fn cross_server_whole_scope_genre_browse_merges_duplicate_albums() {
let store = LibraryStore::open_in_memory();
let mut first = track("s1", "t1", "al1", "Rock");
first.library_id = Some("lib-a".into());
first.album = "Shared Album".into();
let mut second = track("s2", "t2", "al2", "Rock");
second.library_id = Some(String::new());
second.album = "Shared Album".into();
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
crate::identity::rebuild_cluster_keys(&store, None).unwrap();
let response = list_albums_by_genre(
&store,
&LibraryGenreAlbumsRequest {
server_id: "s1".into(),
genre: "Rock".into(),
library_scope: None,
library_scopes: Some(vec![
LibraryScopePair { server_id: "s2".into(), library_id: None },
LibraryScopePair { server_id: "s1".into(), library_id: None },
]),
sort: vec![],
limit: 50,
offset: 0,
include_total: true,
},
)
.unwrap();
assert_eq!(response.albums.len(), 1);
assert_eq!(response.albums[0].server_id, "s2");
assert_eq!(response.total, Some(1));
}
#[test]
fn list_albums_by_genre_respects_library_scope_and_total() {
let store = LibraryStore::open_in_memory();
@@ -11,7 +11,6 @@ pub use attach::{
remove_cluster_files_for_library, CLUSTER_DB_FILENAME, CLUSTER_SCHEMA,
};
pub use norm::NORM_VERSION;
pub(crate) use norm::norm_part;
pub use rebuild::{cluster_rebuild_needed, ensure_cluster_keys_built, rebuild_cluster_keys};
pub use keys::{build_track_cluster_keys, TrackClusterKeys};
@@ -39,7 +39,6 @@ pub mod payload;
pub mod repos;
pub mod runtime;
pub mod scope_merge;
pub mod scope_statistics;
pub mod search;
pub mod store;
pub mod sync;
@@ -46,15 +46,9 @@ pub fn run_live_search(
});
}
let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes)?;
let pair_reader_required = multi_library_merge_enabled(&scope_pairs)
|| scope_pairs
.first()
.is_some_and(|pair| pair.server_id != server_id);
if pair_reader_required {
for scope_server in scope_pairs.iter().map(|p| p.server_id.as_str()).collect::<HashSet<_>>() {
crate::identity::ensure_cluster_keys_built(store, scope_server)?;
}
let scope_pairs = ordered_library_scope_pairs(server_id, library_scope, library_scopes);
if multi_library_merge_enabled(&scope_pairs) {
crate::identity::ensure_cluster_keys_built(store, server_id)?;
return run_live_search_multi_scope(
store,
&scope_pairs,
@@ -69,7 +63,7 @@ pub fn run_live_search(
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| scope_pairs.first().and_then(|p| p.library_id.clone()));
.or_else(|| scope_pairs.first().map(|p| p.library_id.clone()));
store.with_read_conn(|conn| {
let scopes = scopes_from_option(effective_scope.as_deref());
@@ -875,11 +869,11 @@ mod tests {
let scopes = vec![
LibraryScopePair {
server_id: "s1".into(),
library_id: Some("lib-a".into()),
library_id: "lib-a".into(),
},
LibraryScopePair {
server_id: "s1".into(),
library_id: Some("lib-b".into()),
library_id: "lib-b".into(),
},
];
let resp = run_live_search(
@@ -901,50 +895,6 @@ mod tests {
assert_eq!(resp.tracks[0].id, "t-a");
}
#[test]
fn cross_server_whole_scope_live_search_keeps_priority_and_empty_library_rows() {
use crate::dto::LibraryScopePair;
use crate::identity::rebuild_cluster_keys;
let store = LibraryStore::open_in_memory();
let mut first = track("s1", "t-a", "Shared Song", "Shared Artist", "Shared Album", "al-a", "ar-a");
first.library_id = Some("lib-a".into());
let mut second = track("s2", "t-b", "Shared Song", "Shared Artist", "Shared Album", "al-b", "ar-b");
second.library_id = Some(String::new());
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
rebuild_cluster_keys(&store, None).unwrap();
let scopes = vec![
LibraryScopePair { server_id: "s2".into(), library_id: None },
LibraryScopePair { server_id: "s1".into(), library_id: None },
];
let resp = run_live_search(&store, "s1", "shared", None, Some(&scopes), 5, 5, 10).unwrap();
assert_eq!(resp.tracks.len(), 1);
assert_eq!(resp.tracks[0].server_id, "s2");
assert_eq!(resp.tracks[0].id, "t-b");
}
#[test]
fn single_exact_pair_on_another_server_does_not_use_fallback_server() {
use crate::dto::LibraryScopePair;
let store = LibraryStore::open_in_memory();
let mut fallback = track("s1", "fallback", "Shared Song", "Artist", "Album", "al1", "ar1");
fallback.library_id = Some("lib".into());
let mut selected = track("s2", "selected", "Shared Song", "Artist", "Album", "al2", "ar2");
selected.library_id = Some("lib".into());
TrackRepository::new(&store).upsert_batch(&[fallback, selected]).unwrap();
let scopes = vec![LibraryScopePair {
server_id: "s2".into(),
library_id: Some("lib".into()),
}];
let response = run_live_search(&store, "s1", "shared", None, Some(&scopes), 5, 5, 10).unwrap();
assert_eq!(response.tracks.len(), 1);
assert_eq!(response.tracks[0].id, "selected");
assert_eq!(response.tracks[0].server_id, "s2");
}
/// Manual: `cargo test -p psysonic-library bench_disk_live_search --release -- --ignored --nocapture`
#[test]
#[ignore]
@@ -2,10 +2,7 @@
//!
//! Mirrors the frontend allowlist in `src/utils/library/losslessFormats.ts`.
use crate::dto::{
LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse,
multi_library_merge_enabled, ordered_library_scope_pairs,
};
use crate::dto::{LibraryAlbumDto, LibraryLosslessAlbumsRequest, LibraryLosslessAlbumsResponse};
use crate::lossless_formats::track_is_lossless_sql;
use crate::search::{combined_scope_library_ids, library_scope_in_sql, library_scope_sargable_equals_sql};
use crate::store::LibraryStore;
@@ -41,47 +38,13 @@ pub fn list_lossless_albums(
store: &LibraryStore,
req: &LibraryLosslessAlbumsRequest,
) -> Result<LibraryLosslessAlbumsResponse, String> {
if !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(empty_response());
}
let limit = req.limit.max(1);
let offset = req.offset;
let lossless_sql = track_is_lossless_sql("t");
let scope_pairs = ordered_library_scope_pairs(
&req.server_id,
req.library_scope.as_deref(),
req.library_scopes.as_deref(),
)?;
if scope_pairs.is_empty() && !crate::dto::track_index_nonempty(store, &req.server_id)? {
return Ok(empty_response());
}
let use_pair_reader = scope_pairs.len() > 1
|| scope_pairs.first().is_some_and(|pair| {
pair.server_id != req.server_id || pair.library_id.is_none()
});
if use_pair_reader {
if multi_library_merge_enabled(&scope_pairs) {
for server_id in scope_pairs
.iter()
.map(|p| p.server_id.as_str())
.collect::<std::collections::HashSet<_>>()
{
crate::identity::ensure_cluster_keys_built(store, server_id)?;
}
}
let (albums, _) = crate::scope_merge::list_albums_filtered(
store,
&scope_pairs,
&lossless_sql,
&[],
"ORDER BY album COLLATE NOCASE ASC, album_id ASC",
limit,
offset,
true,
)?;
return Ok(LibraryLosslessAlbumsResponse {
has_more: albums.len() as u32 == limit,
albums,
source: "local".to_string(),
});
}
let mut where_clauses = vec![
"t.deleted = 0".to_string(),
@@ -91,11 +54,8 @@ pub fn list_lossless_albums(
];
let mut params: Vec<SqlValue> = vec![SqlValue::Text(req.server_id.clone())];
let scope_ids = scope_pairs
.first()
.and_then(|pair| pair.library_id.clone())
.map(|library_id| vec![library_id])
.unwrap_or_else(|| combined_scope_library_ids(req.library_scope.as_deref(), None));
let scope_ids =
combined_scope_library_ids(req.library_scope.as_deref(), req.library_scopes.as_deref());
push_library_scope_filter(&mut where_clauses, &mut params, &scope_ids);
let where_sql = where_clauses.join(" AND ");
@@ -344,16 +304,7 @@ mod tests {
.unwrap();
let mut scoped = req("s1", 50, 0);
scoped.library_scopes = Some(vec![
crate::dto::LibraryScopePair {
server_id: "s1".into(),
library_id: Some("lib1".into()),
},
crate::dto::LibraryScopePair {
server_id: "s1".into(),
library_id: Some("lib2".into()),
},
]);
scoped.library_scopes = Some(vec!["lib1".into(), "lib2".into()]);
let resp = list_lossless_albums(&store, &scoped).unwrap();
let mut ids: Vec<_> = resp.albums.iter().map(|a| a.id.clone()).collect();
ids.sort();
@@ -379,24 +330,4 @@ mod tests {
assert_eq!(page2.albums.len(), 1);
assert!(!page2.has_more);
}
#[test]
fn cross_server_whole_scope_lossless_browse_uses_priority_owner() {
let store = LibraryStore::open_in_memory();
let mut first = track_with_suffix("s1", "t1", "al1", "Shared", "flac", 16);
first.library_id = Some("lib-a".into());
let mut second = track_with_suffix("s2", "t2", "al2", "Shared", "flac", 24);
second.library_id = Some(String::new());
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
crate::identity::rebuild_cluster_keys(&store, None).unwrap();
let mut request = req("s1", 50, 0);
request.library_scopes = Some(vec![
crate::dto::LibraryScopePair { server_id: "s2".into(), library_id: None },
crate::dto::LibraryScopePair { server_id: "s1".into(), library_id: None },
]);
let response = list_lossless_albums(&store, &request).unwrap();
assert_eq!(response.albums.len(), 1);
assert_eq!(response.albums[0].server_id, "s2");
}
}
File diff suppressed because it is too large Load Diff
@@ -1,425 +0,0 @@
//! Scope-aware catalog aggregates and play-session identity views.
use rusqlite::types::Value as SqlValue;
use rusqlite::params_from_iter;
use serde_json::Value;
use crate::album_compilation_filter::pick_album_group_artist;
use crate::browse_support::overlay_album_starred_at_rows;
use crate::dto::{
LibraryAlbumDto, LibraryScopeCatalogStatisticsDto,
LibraryScopeCatalogStatisticsRequest, LibraryScopeFormatCountDto,
LibraryScopeMostPlayedAlbumDto, LibraryScopeMostPlayedRequest,
LibraryArtistDto, LibraryScopeArtistRoleRequest,
};
use crate::identity::norm_part;
use crate::scope_merge::{
ensure_cluster_keys_for_scopes, normalize_scope_pairs, scope_cte_sql, ALBUM_DEDUP_KEY,
TRACK_DEDUP_KEY,
};
use crate::store::LibraryStore;
const ARTIST_DEDUP_KEY: &str = "CASE WHEN ck.artist_key IS NOT NULL THEN ck.artist_key \
ELSE ('null:' || t.server_id || ':' || COALESCE(NULLIF(t.artist_id, ''), t.id)) END";
fn validated_scopes(
scopes: &[crate::dto::LibraryScopePair],
) -> Result<Vec<crate::dto::LibraryScopePair>, String> {
let scopes = normalize_scope_pairs(scopes)?;
if scopes.is_empty() {
return Err("scopes must not be empty".into());
}
Ok(scopes)
}
pub fn catalog_statistics(
store: &LibraryStore,
request: &LibraryScopeCatalogStatisticsRequest,
) -> Result<LibraryScopeCatalogStatisticsDto, String> {
let scopes = validated_scopes(&request.scopes)?;
ensure_cluster_keys_for_scopes(store, &scopes)?;
let sample_limit = request.format_sample_limit.unwrap_or(500).clamp(1, 5_000);
let (cte, binds) = scope_cte_sql(&scopes);
let sql = format!(
"{cte}, base AS ( \
SELECT t.rowid, t.server_id, t.id, t.duration_sec, t.suffix, t.album_id, t.artist_id, \
s.pr, {TRACK_DEDUP_KEY} AS track_dedup, {ALBUM_DEDUP_KEY} AS album_dedup, \
{ARTIST_DEDUP_KEY} AS artist_dedup \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 \
), winners AS ( \
SELECT rowid, server_id, id, duration_sec, suffix, album_id, artist_id, \
album_dedup, artist_dedup, MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _pick \
FROM base GROUP BY track_dedup \
) \
SELECT COUNT(*), COALESCE(SUM(duration_sec), 0), \
COUNT(DISTINCT CASE WHEN album_id IS NOT NULL AND album_id != '' THEN album_dedup END), \
COUNT(DISTINCT CASE WHEN artist_id IS NOT NULL AND artist_id != '' THEN artist_dedup END) \
FROM winners"
);
let (track_count, duration_sec, album_count, artist_count) = store
.with_read_conn(|conn| {
conn.query_row(&sql, params_from_iter(binds.iter()), |row| {
Ok((
row.get::<_, i64>(0)?.max(0) as u32,
row.get::<_, i64>(1)?.max(0),
row.get::<_, i64>(2)?.max(0) as u32,
row.get::<_, i64>(3)?.max(0) as u32,
))
})
})
.map_err(|e| e.to_string())?;
let genres = crate::browse_support::genre_album_counts_for_scopes(store, &scopes)?;
let format_sql = format!(
"{cte}, base AS ( \
SELECT t.server_id, t.id, t.suffix, s.pr, {TRACK_DEDUP_KEY} AS track_dedup \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 \
), winners AS ( \
SELECT suffix, MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _pick \
FROM base GROUP BY track_dedup ORDER BY RANDOM() LIMIT ? \
) \
SELECT COALESCE(NULLIF(UPPER(TRIM(suffix)), ''), 'UNKNOWN'), COUNT(*) \
FROM winners GROUP BY 1 ORDER BY 2 DESC, 1 ASC"
);
let mut format_binds = binds;
format_binds.push(SqlValue::Integer(i64::from(sample_limit)));
let formats = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&format_sql)?;
let rows = stmt.query_map(params_from_iter(format_binds.iter()), |row| {
Ok(LibraryScopeFormatCountDto {
format: row.get(0)?,
count: row.get::<_, i64>(1)?.max(0) as u32,
})
})?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())?;
let format_sample_size = formats.iter().map(|row| row.count).sum();
Ok(LibraryScopeCatalogStatisticsDto {
artist_count,
album_count,
track_count,
duration_sec,
genres,
formats,
format_sample_size,
})
}
pub fn most_played_albums(
store: &LibraryStore,
request: &LibraryScopeMostPlayedRequest,
) -> Result<Vec<LibraryScopeMostPlayedAlbumDto>, String> {
let scopes = validated_scopes(&request.scopes)?;
ensure_cluster_keys_for_scopes(store, &scopes)?;
let limit = request.limit.unwrap_or(50).clamp(1, 500);
let offset = request.offset.unwrap_or(0);
let (cte, mut binds) = scope_cte_sql(&scopes);
let sql = format!(
"{cte}, base AS ( \
SELECT t.server_id, t.id, t.album_id, t.album, t.artist, t.artist_id, t.album_artist, \
t.year, t.genre, t.cover_art_id, t.synced_at, t.duration_sec, s.pr, \
{ALBUM_DEDUP_KEY} AS album_dedup, {TRACK_DEDUP_KEY} AS track_dedup \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 AND t.album_id IS NOT NULL AND t.album_id != '' \
), track_winners AS ( \
SELECT server_id, id, album_id, album, artist, artist_id, album_artist, year, genre, \
cover_art_id, synced_at, duration_sec, pr, album_dedup, track_dedup, \
MIN(printf('%08d|%s|%s', pr, server_id, id)) AS _track_pick \
FROM base GROUP BY album_dedup, track_dedup \
), album_totals AS ( \
SELECT album_dedup, COUNT(*) AS song_count, SUM(duration_sec) AS duration_total \
FROM track_winners GROUP BY album_dedup \
), played AS ( \
SELECT b.*, ps.id AS session_id \
FROM base b INNER JOIN play_session ps ON ps.server_id = b.server_id AND ps.track_id = b.id \
), played_albums AS ( \
SELECT server_id, album_id, album, artist, artist_id, album_artist, year, genre, cover_art_id, \
synced_at, album_dedup, COUNT(session_id) AS play_count, \
MIN(printf('%08d|%s|%s', pr, server_id, album_id)) AS _pick \
FROM played GROUP BY album_dedup \
) \
SELECT p.server_id, p.album_id, p.album, p.artist, p.artist_id, p.album_artist, \
a.song_count, a.duration_total, p.year, p.genre, p.cover_art_id, p.synced_at, \
p.play_count, \
p._pick \
FROM played_albums p INNER JOIN album_totals a ON a.album_dedup = p.album_dedup \
ORDER BY p.play_count DESC, p.album COLLATE NOCASE ASC, p.album_id ASC LIMIT ? OFFSET ?"
);
binds.push(SqlValue::Integer(i64::from(limit)));
binds.push(SqlValue::Integer(i64::from(offset)));
let mut rows = store
.with_read_conn(|conn| {
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_from_iter(binds.iter()), |row| {
let track_artist: Option<String> = row.get(3)?;
let album_artist: Option<String> = row.get(5)?;
Ok(LibraryScopeMostPlayedAlbumDto {
album: LibraryAlbumDto {
server_id: row.get(0)?,
id: row.get(1)?,
name: row.get(2)?,
artist: pick_album_group_artist(track_artist, album_artist),
artist_id: row.get(4)?,
song_count: Some(row.get(6)?),
duration_sec: Some(row.get(7)?),
year: row.get(8)?,
genre: row.get(9)?,
cover_art_id: row.get(10)?,
starred_at: None,
synced_at: row.get(11)?,
raw_json: Value::Null,
},
play_count: row.get::<_, i64>(12)?.max(0) as u32,
})
})?.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
})
.map_err(|e| e.to_string())?;
store
.with_read_conn(|conn| {
let mut albums = rows.iter_mut().map(|row| &mut row.album).collect::<Vec<_>>();
for album in albums.iter_mut() {
let one = std::slice::from_mut(&mut **album);
overlay_album_starred_at_rows(conn, one);
}
Ok(())
})
.map_err(|e| e.to_string())?;
Ok(rows)
}
pub fn artists_by_role(
store: &LibraryStore,
request: &LibraryScopeArtistRoleRequest,
) -> Result<Vec<LibraryArtistDto>, String> {
let scopes = validated_scopes(&request.scopes)?;
ensure_cluster_keys_for_scopes(store, &scopes)?;
let role = request.role.trim().to_ascii_lowercase();
if role.is_empty() {
return Err("role must not be empty".into());
}
let limit = request.limit.unwrap_or(10_000).clamp(1, 20_000);
let (cte, mut binds) = scope_cte_sql(&scopes);
let rows_sql = format!(
"{cte}, role_rows AS ( \
SELECT t.server_id, json_extract(j.value, '$.artist.id') AS artist_id, \
json_extract(j.value, '$.artist.name') AS artist_name, \
COALESCE(ck.album_key, 'null:' || t.server_id || ':' || COALESCE(t.album_id, t.id)) AS album_dedup, \
t.synced_at, s.pr, \
MIN(printf('%08d|%s|%s', s.pr, t.server_id, json_extract(j.value, '$.artist.id'))) AS _pick \
FROM scoped_track s CROSS JOIN track t ON t.rowid = s.rowid \
JOIN json_each(CASE WHEN json_valid(t.raw_json) THEN t.raw_json ELSE '{{}}' END, '$.contributors') j \
LEFT JOIN cluster.track_cluster_key ck ON ck.server_id = t.server_id AND ck.track_id = t.id \
WHERE t.deleted = 0 AND LOWER(COALESCE(json_extract(j.value, '$.role'), '')) = ? \
AND json_extract(j.value, '$.artist.id') IS NOT NULL \
GROUP BY t.server_id, artist_id, artist_name, album_dedup, t.synced_at, s.pr \
) \
SELECT server_id, artist_id, artist_name, album_dedup, synced_at, pr \
FROM role_rows ORDER BY pr ASC, server_id ASC, artist_id ASC"
);
binds.push(SqlValue::Text(role));
store.with_read_conn(|conn| {
let mut stmt = conn.prepare(&rows_sql)?;
let rows = stmt.query_map(params_from_iter(binds.iter()), |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
row.get::<_, i64>(4)?,
))
})?.collect::<rusqlite::Result<Vec<_>>>()?;
let mut grouped: std::collections::HashMap<String, (LibraryArtistDto, std::collections::HashSet<String>)> =
std::collections::HashMap::new();
for (server_id, artist_id, name, album_dedup, synced_at) in rows {
let key = norm_part(&name)
.unwrap_or_else(|| format!("null:{server_id}:{artist_id}"));
let entry = grouped.entry(key).or_insert_with(|| {
(LibraryArtistDto {
server_id,
id: artist_id,
name: name.clone(),
name_sort: Some(crate::artist_sort::sort_key_for_display_name(
&name,
crate::artist_sort::DEFAULT_IGNORED_ARTICLES,
)),
album_count: Some(0),
synced_at,
raw_json: Value::Null,
}, std::collections::HashSet::new())
});
if let Some(album_dedup) = album_dedup {
entry.1.insert(album_dedup);
}
}
let mut artists = grouped.into_values().map(|(mut artist, album_ids)| {
artist.album_count = Some(album_ids.len() as i64);
artist
}).collect::<Vec<_>>();
artists.sort_by(|a, b| a.name_sort.cmp(&b.name_sort).then_with(|| a.id.cmp(&b.id)));
artists.truncate(limit as usize);
Ok(artists)
}).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use crate::dto::{
LibraryScopeArtistRoleRequest, LibraryScopeCatalogStatisticsRequest,
LibraryScopeMostPlayedRequest, LibraryScopePair,
};
use crate::repos::{PlaySessionRepository, TrackRepository, TrackRow};
use crate::store::LibraryStore;
use super::{artists_by_role, catalog_statistics, most_played_albums};
fn row(server: &str, id: &str, album: &str, library: &str, duration: i64) -> TrackRow {
TrackRow {
server_id: server.into(), id: id.into(), title: "Song".into(), title_sort: None,
artist: Some("Artist".into()), artist_id: Some("artist".into()), album: album.into(),
album_id: Some(album.into()), album_artist: Some("Artist".into()), duration_sec: duration,
track_number: Some(1), disc_number: Some(1), year: Some(2024), genre: Some("Rock".into()),
suffix: Some("flac".into()), bit_rate: None, size_bytes: None, cover_art_id: Some(album.into()),
starred_at: None, user_rating: None, play_count: None, played_at: None, server_path: None,
library_id: Some(library.into()), isrc: Some("same-isrc".into()), mbid_recording: None,
bpm: None, replay_gain_track_db: None, replay_gain_album_db: None, replay_gain_peak: None,
content_hash: None, server_updated_at: None, server_created_at: None, deleted: false,
synced_at: 1, raw_json: "{}".into(),
}
}
fn contributor_row(
server: &str,
id: &str,
album: &str,
library: &str,
contributors: serde_json::Value,
) -> TrackRow {
let mut track = row(server, id, album, library, 200);
track.raw_json = serde_json::json!({ "contributors": contributors }).to_string();
track
}
fn scopes() -> Vec<LibraryScopePair> {
vec![
LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) },
LibraryScopePair { server_id: "s2".into(), library_id: Some("b".into()) },
]
}
#[test]
fn catalog_totals_dedup_shared_recording_and_album_by_priority() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store).upsert_batch(&[
row("s1", "t1", "shared-album", "a", 200),
row("s2", "t2", "shared-album", "b", 201),
]).unwrap();
let stats = catalog_statistics(&store, &LibraryScopeCatalogStatisticsRequest {
scopes: scopes(), format_sample_limit: Some(500),
}).unwrap();
assert_eq!(stats.track_count, 1);
assert_eq!(stats.album_count, 1);
assert_eq!(stats.artist_count, 1);
assert_eq!(stats.duration_sec, 200);
assert_eq!(stats.format_sample_size, 1);
}
#[test]
fn most_played_counts_concrete_sessions_once_but_displays_priority_identity() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store).upsert_batch(&[
row("s1", "t1", "shared-album", "a", 200),
row("s2", "t2", "shared-album", "b", 201),
]).unwrap();
let repo = PlaySessionRepository::new(&store);
for (server_id, track_id, started_at_ms) in [("s1", "t1", 1000), ("s2", "t2", 2000)] {
repo.insert(&crate::dto::PlaySessionInputDto {
server_id: server_id.into(), track_id: track_id.into(), started_at_ms,
listened_sec: 30.0, position_max_sec: 30.0, end_reason: "next".into(),
duration_sec_hint: None,
}).unwrap();
}
let rows = most_played_albums(&store, &LibraryScopeMostPlayedRequest {
scopes: scopes(), limit: Some(10), offset: None,
}).unwrap();
assert_eq!(rows.len(), 1);
assert_eq!(rows[0].play_count, 2);
assert_eq!(rows[0].album.server_id, "s1");
assert_eq!(rows[0].album.id, "shared-album");
assert_eq!(rows[0].album.song_count, Some(1));
assert_eq!(rows[0].album.duration_sec, Some(200));
}
#[test]
fn most_played_song_count_does_not_collide_equal_raw_ids_across_servers() {
let store = LibraryStore::open_in_memory();
let mut first = row("s1", "same-id", "shared-album", "a", 200);
first.title = "First".into();
first.isrc = Some("first-isrc".into());
let mut second = row("s2", "same-id", "shared-album", "b", 240);
second.title = "Second".into();
second.isrc = Some("second-isrc".into());
TrackRepository::new(&store).upsert_batch(&[first, second]).unwrap();
let repo = PlaySessionRepository::new(&store);
for (server_id, started_at_ms) in [("s1", 1000), ("s2", 2000)] {
repo.insert(&crate::dto::PlaySessionInputDto {
server_id: server_id.into(), track_id: "same-id".into(), started_at_ms,
listened_sec: 30.0, position_max_sec: 30.0, end_reason: "next".into(),
duration_sec_hint: None,
}).unwrap();
}
let rows = most_played_albums(&store, &LibraryScopeMostPlayedRequest {
scopes: scopes(), limit: Some(10), offset: None,
}).unwrap();
assert_eq!(rows[0].album.song_count, Some(2));
assert_eq!(rows[0].album.duration_sec, Some(440));
}
#[test]
fn contributor_roles_keep_distinct_people_on_one_track() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store).upsert_batch(&[contributor_row(
"s1", "t1", "album-one", "a",
serde_json::json!([
{ "role": "composer", "artist": { "id": "c1", "name": "Composer One" } },
{ "role": "composer", "artist": { "id": "c2", "name": "Composer Two" } }
]),
)]).unwrap();
let artists = artists_by_role(&store, &LibraryScopeArtistRoleRequest {
scopes: vec![LibraryScopePair { server_id: "s1".into(), library_id: Some("a".into()) }],
role: "composer".into(), limit: Some(10),
}).unwrap();
assert_eq!(artists.iter().map(|artist| artist.name.as_str()).collect::<Vec<_>>(),
vec!["Composer One", "Composer Two"]);
}
#[test]
fn contributor_roles_merge_same_person_across_servers_by_contributor_identity() {
let store = LibraryStore::open_in_memory();
TrackRepository::new(&store).upsert_batch(&[
contributor_row(
"s1", "t1", "shared-album", "a",
serde_json::json!([{ "role": "composer", "artist": { "id": "composer-a", "name": "Béla Bartók" } }]),
),
contributor_row(
"s2", "t2", "shared-album", "b",
serde_json::json!([{ "role": "composer", "artist": { "id": "composer-b", "name": "Bela Bartok" } }]),
),
]).unwrap();
let artists = artists_by_role(&store, &LibraryScopeArtistRoleRequest {
scopes: scopes(), role: "composer".into(), limit: Some(10),
}).unwrap();
assert_eq!(artists.len(), 1);
assert_eq!(artists[0].server_id, "s1");
assert_eq!(artists[0].id, "composer-a");
assert_eq!(artists[0].album_count, Some(1));
}
}
-10
View File
@@ -106,7 +106,6 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
.commands(tauri_specta::collect_commands![
crate::lib_commands::app_api::core::greet,
psysonic_library::browse_support::library_get_catalog_year_bounds,
psysonic_library::browse_support::library_scope_catalog_year_bounds,
psysonic_library::browse_support::library_get_genre_album_counts,
// psysonic-library — remaining typeable commands. Excluded (stay on
// generate_handler! only): the 10 search/browse/track reads whose envelopes
@@ -124,8 +123,6 @@ fn specta_builder() -> tauri_specta::Builder<tauri::Wry> {
psysonic_library::commands::library_genre_tags_inspect,
psysonic_library::commands::library_genre_tags_run,
psysonic_library::commands::library_cluster_rebuild,
psysonic_library::commands::library_resolve_entity_sources,
psysonic_library::commands::library_scope_catalog_statistics,
psysonic_library::commands::library_sync_bind_session,
psysonic_library::commands::library_sync_clear_session,
psysonic_library::commands::library_set_playback_hint,
@@ -1046,15 +1043,11 @@ pub fn run() {
psysonic_library::commands::library_genre_tags_inspect,
psysonic_library::commands::library_genre_tags_run,
psysonic_library::commands::library_cluster_rebuild,
psysonic_library::commands::library_resolve_entity_sources,
psysonic_library::commands::library_scope_list_albums,
psysonic_library::commands::library_scope_list_artists,
psysonic_library::commands::library_scope_search_tracks,
psysonic_library::commands::library_scope_album_detail,
psysonic_library::commands::library_scope_artist_detail,
psysonic_library::commands::library_scope_catalog_statistics,
psysonic_library::commands::library_scope_most_played_albums,
psysonic_library::commands::library_scope_list_artists_by_role,
psysonic_library::commands::library_get_artist_lossless_browse,
psysonic_library::commands::library_search_cross_server,
psysonic_library::commands::library_get_track,
@@ -1077,7 +1070,6 @@ pub fn run() {
psysonic_library::browse_support::library_patch_album,
psysonic_library::browse_support::library_reconcile_album_stars,
psysonic_library::browse_support::library_get_catalog_year_bounds,
psysonic_library::browse_support::library_scope_catalog_year_bounds,
psysonic_library::browse_support::library_get_genre_album_counts,
psysonic_library::commands::library_put_artifact,
psysonic_library::commands::library_put_fact,
@@ -1301,8 +1293,6 @@ mod specta_export {
"library_scope_artist_detail",
"library_scope_list_albums",
"library_scope_list_artists",
"library_scope_list_artists_by_role",
"library_scope_most_played_albums",
"library_scope_search_tracks",
"library_search",
"library_search_cross_server",