mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(cluster): Tier 1 browse — merged albums, artists, tracks, search
Add Rust list_merged_albums/artists commands and wire cluster-mode reads into tracks, albums, artists catalog, and text search browse paths via clusterBrowse helpers.
This commit is contained in:
@@ -22,7 +22,7 @@ use crate::dto::{
|
||||
count_local_tracks, local_tracks_max_updated_ms, track_index_nonempty, ArtifactInputDto,
|
||||
FactInputDto, LibraryAdvancedSearchRequest, LibraryAdvancedSearchResponse,
|
||||
LibraryClusterListTracksRequest, LibraryClusterResolveRequest,
|
||||
LibraryClusterResolveResponse,
|
||||
LibraryClusterResolveResponse, LibraryClusterAlbumsResponse, LibraryClusterArtistsResponse,
|
||||
LibraryCrossServerSearchResponse, LibraryLiveSearchRequest, LibraryLiveSearchResponse, LibraryTrackDto,
|
||||
LibraryTracksEnvelope, OfflinePathDto, PlaySessionDayDetailDto, PlaySessionHeatmapDayDto,
|
||||
PlaySessionInputDto, PlaySessionRecentDayDto, PlaySessionYearBoundsDto, PlaySessionYearSummaryDto, PurgeReportDto, SyncJobDto, SyncStateDto,
|
||||
@@ -563,6 +563,36 @@ pub async fn library_cluster_list_tracks(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_list_albums(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterListTracksRequest,
|
||||
) -> Result<LibraryClusterAlbumsResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let limit = request.limit.unwrap_or(100);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::list_merged_albums(&store, &servers_ordered, limit, offset)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_list_artists(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
request: LibraryClusterListTracksRequest,
|
||||
) -> Result<LibraryClusterArtistsResponse, String> {
|
||||
let store = Arc::clone(&runtime.store);
|
||||
let servers_ordered = request.servers_ordered;
|
||||
let limit = request.limit.unwrap_or(100);
|
||||
let offset = request.offset.unwrap_or(0);
|
||||
library_spawn_blocking(move || {
|
||||
crate::server_cluster::list_merged_artists(&store, &servers_ordered, limit, offset)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn library_cluster_resolve_candidates(
|
||||
runtime: State<'_, LibraryRuntime>,
|
||||
|
||||
@@ -681,6 +681,22 @@ pub struct LibraryClusterListTracksRequest {
|
||||
pub offset: Option<u32>,
|
||||
}
|
||||
|
||||
/// Merged album browse response for cluster scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterAlbumsResponse {
|
||||
pub albums: Vec<LibraryAlbumDto>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// Merged artist browse response for cluster scope.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LibraryClusterArtistsResponse {
|
||||
pub artists: Vec<LibraryArtistDto>,
|
||||
pub has_more: bool,
|
||||
}
|
||||
|
||||
/// `library_cluster_resolve_candidates` request — provide cluster_key OR seed track.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Merged album listing for cluster scope (spec §4 Tier 1 — dedup by `album_key`).
|
||||
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{LibraryAlbumDto, LibraryClusterAlbumsResponse};
|
||||
use crate::search::PAGE_LIMIT_MAX;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
use super::db::ATTACH_ALIAS;
|
||||
use super::priority::{in_list_sql, priority_case_sql};
|
||||
|
||||
pub fn list_merged_albums(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<LibraryClusterAlbumsResponse, String> {
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(LibraryClusterAlbumsResponse {
|
||||
albums: vec![],
|
||||
has_more: false,
|
||||
});
|
||||
}
|
||||
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
|
||||
let offset = offset.min(i32::MAX as u32) as i32;
|
||||
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
|
||||
let sql = format!(
|
||||
"WITH candidates AS (
|
||||
SELECT
|
||||
t.rowid AS tid,
|
||||
t.server_id,
|
||||
t.album_id,
|
||||
k.album_key,
|
||||
({priority_sql}) AS priority_rank
|
||||
FROM track t
|
||||
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||
WHERE t.deleted = 0
|
||||
AND t.server_id IN ({in_placeholders})
|
||||
AND t.album_id IS NOT NULL AND t.album_id != ''
|
||||
),
|
||||
partitioned AS (
|
||||
SELECT c.tid,
|
||||
CASE
|
||||
WHEN c.album_key IS NULL THEN 'solo:' || c.server_id || ':' || c.album_id
|
||||
ELSE c.album_key
|
||||
END AS merge_key,
|
||||
c.priority_rank
|
||||
FROM candidates c
|
||||
),
|
||||
winners AS (
|
||||
SELECT tid,
|
||||
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
|
||||
FROM partitioned
|
||||
)
|
||||
SELECT
|
||||
t.server_id,
|
||||
t.album_id,
|
||||
COALESCE(a.name, t.album),
|
||||
COALESCE(a.artist, t.artist),
|
||||
COALESCE(a.artist_id, t.artist_id),
|
||||
COALESCE(a.song_count, (
|
||||
SELECT COUNT(*) FROM track c
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
|
||||
)),
|
||||
COALESCE(a.duration_sec, (
|
||||
SELECT COALESCE(SUM(c.duration_sec), 0) FROM track c
|
||||
WHERE c.server_id = t.server_id AND c.album_id = t.album_id AND c.deleted = 0
|
||||
)),
|
||||
COALESCE(a.year, t.year),
|
||||
COALESCE(a.genre, t.genre),
|
||||
COALESCE(a.cover_art_id, t.cover_art_id),
|
||||
COALESCE(a.starred_at, t.starred_at),
|
||||
COALESCE(a.synced_at, t.synced_at),
|
||||
a.raw_json
|
||||
FROM winners w
|
||||
JOIN track t ON t.rowid = w.tid
|
||||
LEFT JOIN album a ON a.server_id = t.server_id AND a.id = t.album_id
|
||||
WHERE w.rn = 1
|
||||
ORDER BY COALESCE(a.name, t.album) COLLATE NOCASE, t.server_id, t.album_id
|
||||
LIMIT ? OFFSET ?",
|
||||
);
|
||||
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
params.push(SqlValue::Integer(offset as i64));
|
||||
|
||||
let albums: Vec<LibraryAlbumDto> = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_album_row)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})?;
|
||||
|
||||
let has_more = albums.len() as u32 == limit;
|
||||
Ok(LibraryClusterAlbumsResponse { albums, has_more })
|
||||
}
|
||||
|
||||
fn map_album_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryAlbumDto> {
|
||||
let raw: Option<String> = r.get(12)?;
|
||||
Ok(LibraryAlbumDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
artist: r.get(3)?,
|
||||
artist_id: r.get(4)?,
|
||||
song_count: r.get(5)?,
|
||||
duration_sec: r.get(6)?,
|
||||
year: r.get(7)?,
|
||||
genre: r.get(8)?,
|
||||
cover_art_id: r.get(9)?,
|
||||
starred_at: r.get(10)?,
|
||||
synced_at: r.get(11)?,
|
||||
raw_json: raw
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
|
||||
|
||||
fn track(
|
||||
server: &str,
|
||||
id: &str,
|
||||
title: &str,
|
||||
artist: &str,
|
||||
album: &str,
|
||||
album_id: &str,
|
||||
) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server.into(),
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
title_sort: None,
|
||||
artist: Some(artist.into()),
|
||||
artist_id: Some(format!("art-{server}")),
|
||||
album: album.into(),
|
||||
album_id: Some(album_id.into()),
|
||||
album_artist: Some(artist.into()),
|
||||
duration_sec: 200,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: Some(2020),
|
||||
genre: None,
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: None,
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: None,
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_collapses_same_album_key_by_priority() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[
|
||||
track("s1", "t1", "A", "Band", "LP", "alb1"),
|
||||
track("s2", "t2", "B", "Band", "LP", "alb2"),
|
||||
])
|
||||
.unwrap();
|
||||
rebuild_all_cluster_keys(&store).unwrap();
|
||||
|
||||
let resp = list_merged_albums(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
|
||||
assert_eq!(resp.albums.len(), 1);
|
||||
assert_eq!(resp.albums[0].server_id, "s1");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
//! Merged artist listing for cluster scope (spec §4 Tier 1 — dedup by `artist_key`).
|
||||
|
||||
use rusqlite::types::Value as SqlValue;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::dto::{LibraryArtistDto, LibraryClusterArtistsResponse};
|
||||
use crate::search::PAGE_LIMIT_MAX;
|
||||
use crate::store::LibraryStore;
|
||||
|
||||
use super::db::ATTACH_ALIAS;
|
||||
use super::priority::{in_list_sql, priority_case_sql};
|
||||
|
||||
pub fn list_merged_artists(
|
||||
store: &LibraryStore,
|
||||
servers_ordered: &[String],
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<LibraryClusterArtistsResponse, String> {
|
||||
if servers_ordered.is_empty() {
|
||||
return Ok(LibraryClusterArtistsResponse {
|
||||
artists: vec![],
|
||||
has_more: false,
|
||||
});
|
||||
}
|
||||
let limit = limit.clamp(1, PAGE_LIMIT_MAX);
|
||||
let offset = offset.min(i32::MAX as u32) as i32;
|
||||
let (in_placeholders, mut in_params) = in_list_sql(servers_ordered);
|
||||
let (priority_sql, mut priority_params) = priority_case_sql("t.server_id", servers_ordered);
|
||||
|
||||
let sql = format!(
|
||||
"WITH candidates AS (
|
||||
SELECT
|
||||
t.rowid AS tid,
|
||||
t.server_id,
|
||||
COALESCE(NULLIF(t.artist_id, ''), t.artist) AS artist_ref,
|
||||
k.artist_key,
|
||||
({priority_sql}) AS priority_rank
|
||||
FROM track t
|
||||
LEFT JOIN {ATTACH_ALIAS}.track_cluster_key k
|
||||
ON k.server_id = t.server_id AND k.track_id = t.id
|
||||
WHERE t.deleted = 0
|
||||
AND t.server_id IN ({in_placeholders})
|
||||
AND COALESCE(t.artist, '') != ''
|
||||
),
|
||||
partitioned AS (
|
||||
SELECT c.tid,
|
||||
CASE
|
||||
WHEN c.artist_key IS NULL THEN 'solo:' || c.server_id || ':' || c.artist_ref
|
||||
ELSE c.artist_key
|
||||
END AS merge_key,
|
||||
c.priority_rank
|
||||
FROM candidates c
|
||||
),
|
||||
winners AS (
|
||||
SELECT tid,
|
||||
ROW_NUMBER() OVER (PARTITION BY merge_key ORDER BY priority_rank) AS rn
|
||||
FROM partitioned
|
||||
)
|
||||
SELECT
|
||||
t.server_id,
|
||||
COALESCE(NULLIF(t.artist_id, ''), t.artist),
|
||||
COALESCE(ar.name, t.artist),
|
||||
COALESCE(ar.album_count, (
|
||||
SELECT COUNT(DISTINCT c.album_id) FROM track c
|
||||
WHERE c.server_id = t.server_id
|
||||
AND c.deleted = 0
|
||||
AND c.album_id IS NOT NULL
|
||||
AND (c.artist_id = t.artist_id OR c.artist = t.artist)
|
||||
)),
|
||||
COALESCE(ar.synced_at, t.synced_at),
|
||||
ar.raw_json
|
||||
FROM winners w
|
||||
JOIN track t ON t.rowid = w.tid
|
||||
LEFT JOIN artist ar ON ar.server_id = t.server_id
|
||||
AND ar.id = COALESCE(NULLIF(t.artist_id, ''), t.artist)
|
||||
WHERE w.rn = 1
|
||||
ORDER BY COALESCE(ar.name, t.artist) COLLATE NOCASE, t.server_id
|
||||
LIMIT ? OFFSET ?",
|
||||
);
|
||||
|
||||
let mut params: Vec<SqlValue> = Vec::new();
|
||||
params.append(&mut priority_params);
|
||||
params.append(&mut in_params);
|
||||
params.push(SqlValue::Integer(limit as i64));
|
||||
params.push(SqlValue::Integer(offset as i64));
|
||||
|
||||
let artists: Vec<LibraryArtistDto> = store.with_read_conn(|conn| {
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), map_artist_row)?;
|
||||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
})?;
|
||||
|
||||
let has_more = artists.len() as u32 == limit;
|
||||
Ok(LibraryClusterArtistsResponse { artists, has_more })
|
||||
}
|
||||
|
||||
fn map_artist_row(r: &rusqlite::Row<'_>) -> rusqlite::Result<LibraryArtistDto> {
|
||||
let raw: Option<String> = r.get(5)?;
|
||||
Ok(LibraryArtistDto {
|
||||
server_id: r.get(0)?,
|
||||
id: r.get(1)?,
|
||||
name: r.get(2)?,
|
||||
album_count: r.get(3)?,
|
||||
synced_at: r.get(4)?,
|
||||
raw_json: raw
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::repos::{TrackRepository, TrackRow};
|
||||
use crate::server_cluster::rebuild::rebuild_all_cluster_keys;
|
||||
|
||||
fn track(server: &str, id: &str, artist: &str) -> TrackRow {
|
||||
TrackRow {
|
||||
server_id: server.into(),
|
||||
id: id.into(),
|
||||
title: "Song".into(),
|
||||
title_sort: None,
|
||||
artist: Some(artist.into()),
|
||||
artist_id: Some(format!("art-{server}")),
|
||||
album: "LP".into(),
|
||||
album_id: Some("alb1".into()),
|
||||
album_artist: Some(artist.into()),
|
||||
duration_sec: 200,
|
||||
track_number: Some(1),
|
||||
disc_number: Some(1),
|
||||
year: None,
|
||||
genre: None,
|
||||
suffix: None,
|
||||
bit_rate: None,
|
||||
size_bytes: None,
|
||||
cover_art_id: None,
|
||||
starred_at: None,
|
||||
user_rating: None,
|
||||
play_count: None,
|
||||
played_at: None,
|
||||
server_path: None,
|
||||
library_id: None,
|
||||
isrc: None,
|
||||
mbid_recording: None,
|
||||
bpm: None,
|
||||
replay_gain_track_db: None,
|
||||
replay_gain_album_db: None,
|
||||
content_hash: None,
|
||||
server_updated_at: None,
|
||||
server_created_at: None,
|
||||
deleted: false,
|
||||
synced_at: 1,
|
||||
raw_json: "{}".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_collapses_same_artist_key_by_priority() {
|
||||
let store = LibraryStore::open_in_memory();
|
||||
TrackRepository::new(&store)
|
||||
.upsert_batch(&[track("s1", "t1", "Band"), track("s2", "t2", "Band")])
|
||||
.unwrap();
|
||||
rebuild_all_cluster_keys(&store).unwrap();
|
||||
|
||||
let resp = list_merged_artists(&store, &["s1".into(), "s2".into()], 50, 0).unwrap();
|
||||
assert_eq!(resp.artists.len(), 1);
|
||||
assert_eq!(resp.artists[0].server_id, "s1");
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
mod db;
|
||||
mod keys;
|
||||
mod list;
|
||||
mod list_albums;
|
||||
mod list_artists;
|
||||
mod merge;
|
||||
mod norm;
|
||||
mod priority;
|
||||
@@ -18,6 +20,8 @@ pub use db::{
|
||||
};
|
||||
pub use keys::{compute_track_cluster_keys, TrackClusterKeys};
|
||||
pub use list::list_merged_tracks;
|
||||
pub use list_albums::list_merged_albums;
|
||||
pub use list_artists::list_merged_artists;
|
||||
pub use merge::DURATION_TOLERANCE_SEC;
|
||||
pub use norm::norm_field;
|
||||
pub use rebuild::{
|
||||
|
||||
@@ -713,6 +713,8 @@ pub fn run() {
|
||||
psysonic_library::commands::library_get_artist_lossless_browse,
|
||||
psysonic_library::commands::library_search_cross_server,
|
||||
psysonic_library::commands::library_cluster_list_tracks,
|
||||
psysonic_library::commands::library_cluster_list_albums,
|
||||
psysonic_library::commands::library_cluster_list_artists,
|
||||
psysonic_library::commands::library_cluster_resolve_candidates,
|
||||
psysonic_library::commands::library_search_cluster,
|
||||
psysonic_library::commands::library_get_track,
|
||||
|
||||
@@ -499,6 +499,50 @@ export function libraryClusterListTracks(args: {
|
||||
}));
|
||||
}
|
||||
|
||||
export interface LibraryClusterAlbumsResponse {
|
||||
albums: LibraryAlbumDto[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export interface LibraryClusterArtistsResponse {
|
||||
artists: LibraryArtistDto[];
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
export function libraryClusterListAlbums(args: {
|
||||
serversOrdered: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<LibraryClusterAlbumsResponse> {
|
||||
return invoke<LibraryClusterAlbumsResponse>('library_cluster_list_albums', {
|
||||
request: {
|
||||
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
},
|
||||
}).then(resp => ({
|
||||
...resp,
|
||||
albums: resp.albums.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function libraryClusterListArtists(args: {
|
||||
serversOrdered: string[];
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<LibraryClusterArtistsResponse> {
|
||||
return invoke<LibraryClusterArtistsResponse>('library_cluster_list_artists', {
|
||||
request: {
|
||||
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
|
||||
limit: args.limit,
|
||||
offset: args.offset,
|
||||
},
|
||||
}).then(resp => ({
|
||||
...resp,
|
||||
artists: resp.artists.map(a => ({ ...a, serverId: mapServerIdFromIndexKey(a.serverId) })),
|
||||
}));
|
||||
}
|
||||
|
||||
export function libraryClusterResolveCandidates(args: {
|
||||
serversOrdered: string[];
|
||||
clusterKey?: string;
|
||||
|
||||
@@ -27,6 +27,11 @@ import { fetchAlbumBrowseNetwork } from './albumBrowseNetwork';
|
||||
import type { AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
import { resolveAlbumYearBounds } from './albumYearFilter';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import {
|
||||
clusterBrowseTextSearch,
|
||||
clusterBrowseTextSearchPage,
|
||||
clusterBrowseTracksPage,
|
||||
} from '../serverCluster/clusterBrowse';
|
||||
import { logLibrarySearch, timed } from './libraryDevLog';
|
||||
import { isLosslessSuffix } from './losslessFormats';
|
||||
import { albumIsCompilation } from './albumCompilation';
|
||||
@@ -369,6 +374,8 @@ export async function runLocalSongBrowse(
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
const clusterPage = await clusterBrowseTracksPage(offset, pageSize);
|
||||
if (clusterPage) return clusterPage;
|
||||
if (!serverId) return null;
|
||||
if (!(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
@@ -399,6 +406,14 @@ export async function loadMoreLocalSongs(
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[]> {
|
||||
const q = opts.query.trim();
|
||||
if (q) {
|
||||
const clusterHits = await clusterBrowseTextSearchPage(q, offset, pageSize);
|
||||
if (clusterHits) return clusterHits;
|
||||
} else {
|
||||
const clusterPage = await clusterBrowseTracksPage(offset, pageSize);
|
||||
if (clusterPage) return clusterPage;
|
||||
}
|
||||
const req = buildRequest(serverId, opts, ['track'], pageSize, offset, true);
|
||||
const resp = await libraryAdvancedSearch(req);
|
||||
return resp.tracks.map(trackToSong);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { albumSortClauses, sortSubsonicAlbums } from './albumBrowseSort';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from './albumBrowseTypes';
|
||||
import { GENRE_ALBUM_FETCH_LIMIT } from './albumBrowseTypes';
|
||||
import { canUseClusterAlbumBrowse, clusterBrowseAlbumsPage } from '../serverCluster/clusterBrowse';
|
||||
|
||||
function markServerStarredAlbums(albums: SubsonicAlbum[]) {
|
||||
return albums.map(a => ({ ...a, starred: a.starred ?? 'true' }));
|
||||
@@ -21,6 +22,10 @@ export async function runLocalAlbumBrowse(
|
||||
pageSize: number,
|
||||
restrictAlbumIds?: string[],
|
||||
): Promise<AlbumBrowsePageResult | null> {
|
||||
if (canUseClusterAlbumBrowse(query, restrictAlbumIds)) {
|
||||
const clusterPage = await clusterBrowseAlbumsPage(offset, pageSize);
|
||||
if (clusterPage) return clusterPage;
|
||||
}
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
|
||||
const scope = libraryScopeForServer(serverId) ?? undefined;
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type LibrarySearchSurface,
|
||||
} from './libraryDevLog';
|
||||
import { libraryIsReady } from './libraryReady';
|
||||
import { clusterBrowseArtistsPage, clusterBrowseTextSearchPage } from '../serverCluster/clusterBrowse';
|
||||
import { raceSearchSources, type SearchRaceWinner } from './searchRace';
|
||||
|
||||
export type { LibrarySearchSurface };
|
||||
@@ -299,8 +300,12 @@ export async function runLocalBrowseSongPage(
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
const q = query.trim();
|
||||
if (q) {
|
||||
const clusterPage = await clusterBrowseTextSearchPage(q, offset, pageSize);
|
||||
if (clusterPage) return clusterPage;
|
||||
}
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
if (!q) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
@@ -564,6 +569,8 @@ export async function fetchLocalArtistCatalogChunk(
|
||||
offset: number,
|
||||
chunkSize: number,
|
||||
): Promise<ArtistCatalogChunkResult | null> {
|
||||
const clusterPage = await clusterBrowseArtistsPage(offset, chunkSize);
|
||||
if (clusterPage) return clusterPage;
|
||||
if (!serverId || !(await libraryIsReady(serverId))) return null;
|
||||
try {
|
||||
const resp = await libraryAdvancedSearch({
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockListTracks = vi.fn();
|
||||
const mockListAlbums = vi.fn();
|
||||
const mockListArtists = vi.fn();
|
||||
const mockSearchCluster = vi.fn();
|
||||
const mockGetMembers = vi.fn();
|
||||
|
||||
vi.mock('../../api/library', () => ({
|
||||
libraryClusterListTracks: (...args: unknown[]) => mockListTracks(...args),
|
||||
libraryClusterListAlbums: (...args: unknown[]) => mockListAlbums(...args),
|
||||
libraryClusterListArtists: (...args: unknown[]) => mockListArtists(...args),
|
||||
librarySearchCluster: (...args: unknown[]) => mockSearchCluster(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./clusterScope', () => ({
|
||||
isClusterMode: vi.fn(() => true),
|
||||
getActiveClusterId: vi.fn(() => 'c1'),
|
||||
}));
|
||||
|
||||
vi.mock('./representative', () => ({
|
||||
getClusterMergeMemberIds: (...args: unknown[]) => mockGetMembers(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../library/advancedSearchLocal', () => ({
|
||||
trackToSong: (t: { id: string }) => ({ id: t.id, title: t.id }),
|
||||
albumToAlbum: (a: { id: string }) => ({ id: a.id, name: a.id }),
|
||||
artistToArtist: (a: { id: string }) => ({ id: a.id, name: a.id }),
|
||||
}));
|
||||
|
||||
import {
|
||||
canUseClusterAlbumBrowse,
|
||||
clusterBrowseTracksPage,
|
||||
} from './clusterBrowse';
|
||||
|
||||
describe('clusterBrowse', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetMembers.mockResolvedValue(['s1', 's2']);
|
||||
});
|
||||
|
||||
it('clusterBrowseTracksPage calls merged list API', async () => {
|
||||
mockListTracks.mockResolvedValue({
|
||||
tracks: [{ id: 't1', serverId: 's1', title: 't1' }],
|
||||
total: 1,
|
||||
});
|
||||
const songs = await clusterBrowseTracksPage(0, 50);
|
||||
expect(songs).toEqual([{ id: 't1', title: 't1' }]);
|
||||
expect(mockListTracks).toHaveBeenCalledWith({
|
||||
serversOrdered: ['s1', 's2'],
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('canUseClusterAlbumBrowse rejects filtered queries', () => {
|
||||
expect(
|
||||
canUseClusterAlbumBrowse(
|
||||
{
|
||||
genres: ['Rock'],
|
||||
losslessOnly: false,
|
||||
starredOnly: false,
|
||||
compFilter: 'all',
|
||||
sort: 'alphabeticalByName',
|
||||
},
|
||||
undefined,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Cluster-mode browse helpers — merged index reads when `activeClusterId` is set.
|
||||
*/
|
||||
import {
|
||||
libraryClusterListAlbums,
|
||||
libraryClusterListArtists,
|
||||
libraryClusterListTracks,
|
||||
librarySearchCluster,
|
||||
} from '../../api/library';
|
||||
import { getActiveClusterId, isClusterMode } from './clusterScope';
|
||||
import { getClusterMergeMemberIds } from './representative';
|
||||
import { albumToAlbum, artistToArtist, trackToSong } from '../library/advancedSearchLocal';
|
||||
import type { AlbumBrowsePageResult, AlbumBrowseQuery } from '../library/albumBrowseTypes';
|
||||
import { albumBrowseHasServerFilters } from '../library/albumBrowseFilters';
|
||||
import type { SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes';
|
||||
|
||||
export async function resolveClusterBrowseMembers(): Promise<string[] | null> {
|
||||
if (!isClusterMode()) return null;
|
||||
const clusterId = getActiveClusterId();
|
||||
if (!clusterId) return null;
|
||||
const ids = await getClusterMergeMemberIds(clusterId);
|
||||
return ids.length > 0 ? ids : null;
|
||||
}
|
||||
|
||||
export function canUseClusterAlbumBrowse(
|
||||
query: AlbumBrowseQuery,
|
||||
restrictAlbumIds?: string[],
|
||||
): boolean {
|
||||
if (!isClusterMode()) return false;
|
||||
if (restrictAlbumIds != null) return false;
|
||||
if (albumBrowseHasServerFilters(query)) return false;
|
||||
if (query.compFilter !== 'all') return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function clusterBrowseTracksPage(
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
const members = await resolveClusterBrowseMembers();
|
||||
if (!members) return null;
|
||||
try {
|
||||
const env = await libraryClusterListTracks({
|
||||
serversOrdered: members,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
return env.tracks.map(trackToSong);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clusterBrowseAlbumsPage(
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<AlbumBrowsePageResult | null> {
|
||||
const members = await resolveClusterBrowseMembers();
|
||||
if (!members) return null;
|
||||
try {
|
||||
const resp = await libraryClusterListAlbums({
|
||||
serversOrdered: members,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
return {
|
||||
albums: resp.albums.map(albumToAlbum),
|
||||
hasMore: resp.hasMore,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clusterBrowseArtistsPage(
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<{ artists: SubsonicArtist[]; hasMore: boolean } | null> {
|
||||
const members = await resolveClusterBrowseMembers();
|
||||
if (!members) return null;
|
||||
try {
|
||||
const resp = await libraryClusterListArtists({
|
||||
serversOrdered: members,
|
||||
limit: pageSize,
|
||||
offset,
|
||||
});
|
||||
const artists = resp.artists.map(artistToArtist);
|
||||
return { artists, hasMore: resp.hasMore };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clusterBrowseTextSearch(
|
||||
query: string,
|
||||
limit: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
const members = await resolveClusterBrowseMembers();
|
||||
if (!members) return null;
|
||||
const q = query.trim();
|
||||
if (!q) return null;
|
||||
try {
|
||||
const resp = await librarySearchCluster({
|
||||
query: q,
|
||||
limit,
|
||||
serversOrdered: members,
|
||||
});
|
||||
return resp.hits.map(trackToSong);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Paginated cluster text search (fetch-through then slice — no Rust offset yet). */
|
||||
export async function clusterBrowseTextSearchPage(
|
||||
query: string,
|
||||
offset: number,
|
||||
pageSize: number,
|
||||
): Promise<SubsonicSong[] | null> {
|
||||
const all = await clusterBrowseTextSearch(query, offset + pageSize);
|
||||
if (!all) return null;
|
||||
return all.slice(offset, offset + pageSize);
|
||||
}
|
||||
Reference in New Issue
Block a user