mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-28 10:06:47 +00:00
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user