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:
@@ -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