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:
Maxim Isaev
2026-06-05 16:32:30 +03:00
parent a7effbc271
commit 4f37fb8752
12 changed files with 679 additions and 2 deletions
+44
View File
@@ -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;
+15
View File
@@ -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);
+5
View File
@@ -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;
+8 -1
View File
@@ -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);
});
});
+123
View File
@@ -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);
}