feat(cluster): auth store cluster model and library API wrappers (step 3)

Add persisted clusters/activeClusterId, cluster CRUD actions, scope helpers,
representative recompute hook, library_cluster_* API wrappers, and vitest
coverage for cluster store rules and delete guard.
This commit is contained in:
Maxim Isaev
2026-06-05 16:23:38 +03:00
parent f3d4a06726
commit 9813c79c91
10 changed files with 429 additions and 3 deletions
+75
View File
@@ -464,6 +464,81 @@ export function librarySearchCrossServer(args: {
}));
}
export interface LibraryClusterCandidateDto {
serverId: string;
trackId: string;
durationSec: number;
priorityRank: number;
isWinner: boolean;
}
export interface LibraryClusterResolveResponse {
candidates: LibraryClusterCandidateDto[];
clusterKey?: string | null;
}
function mapServersOrderedToIndexKeys(serverIds: string[]): string[] {
return serverIds.map(serverIndexKeyForId);
}
/** Merged track list for cluster scope (ordered members = priority). */
export function libraryClusterListTracks(args: {
serversOrdered: string[];
limit?: number;
offset?: number;
}): Promise<LibraryTracksEnvelope> {
return invoke<LibraryTracksEnvelope>('library_cluster_list_tracks', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
limit: args.limit,
offset: args.offset,
},
}).then(env => ({
...env,
tracks: mapTracksServerId(env.tracks),
}));
}
export function libraryClusterResolveCandidates(args: {
serversOrdered: string[];
clusterKey?: string;
serverId?: string;
trackId?: string;
}): Promise<LibraryClusterResolveResponse> {
return invoke<LibraryClusterResolveResponse>('library_cluster_resolve_candidates', {
request: {
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
clusterKey: args.clusterKey,
serverId: args.serverId ? serverIndexKeyForId(args.serverId) : undefined,
trackId: args.trackId,
},
}).then(response => ({
...response,
candidates: response.candidates.map(c => ({
...c,
serverId: mapServerIdFromIndexKey(c.serverId),
})),
}));
}
/** Cluster-mode search — dedup by cluster_key + priority. */
export function librarySearchCluster(args: {
query: string;
limit?: number;
serversOrdered: string[];
}): Promise<LibraryCrossServerSearchResponse> {
return invoke<LibraryCrossServerSearchResponse>('library_search_cluster', {
query: args.query,
limit: args.limit,
serversOrdered: mapServersOrderedToIndexKeys(args.serversOrdered),
}).then(response => ({
...response,
hits: mapTracksServerId(response.hits),
fuzzy: mapTracksServerId(response.fuzzy),
serversSearched: response.serversSearched.map(id => mapServerIdFromIndexKey(id)),
}));
}
export function libraryGetTrack(
serverId: string,
trackId: string,
+114
View File
@@ -0,0 +1,114 @@
import type { AuthState } from './authStoreTypes';
import type { ServerCluster } from '../utils/serverCluster/types';
import { generateId } from './authStoreHelpers';
import { recomputeClusterRepresentative } from '../utils/serverCluster/representative';
type SetState = (
partial: Partial<AuthState> | ((state: AuthState) => Partial<AuthState>),
) => void;
type GetState = () => AuthState;
function touchCluster(set: SetState, get: GetState, clusterId: string | null) {
if (clusterId) {
recomputeClusterRepresentative(clusterId);
} else if (get().activeClusterId) {
recomputeClusterRepresentative(get().activeClusterId!);
}
}
export function createClusterActions(
set: SetState,
get: GetState,
): Pick<
AuthState,
| 'createCluster'
| 'renameCluster'
| 'setClusterOrder'
| 'addServerToCluster'
| 'removeServerFromCluster'
| 'setClusterSyncPlayCounts'
| 'deleteCluster'
| 'setActiveCluster'
> {
return {
createCluster: (name, serverIds) => {
if (serverIds.length < 2) {
throw new Error('Cluster requires at least two servers');
}
const id = generateId();
const cluster: ServerCluster = {
id,
name: name.trim() || 'Cluster',
serverIds: [...serverIds],
clusterSyncPlayCounts: true,
};
set(s => ({ clusters: [...s.clusters, cluster] }));
return id;
},
renameCluster: (id, name) => {
set(s => ({
clusters: s.clusters.map(c =>
c.id === id ? { ...c, name: name.trim() || c.name } : c,
),
}));
},
setClusterOrder: (id, serverIds) => {
set(s => ({
clusters: s.clusters.map(c => (c.id === id ? { ...c, serverIds: [...serverIds] } : c)),
}));
touchCluster(set, get, id);
},
addServerToCluster: (id, serverId) => {
set(s => ({
clusters: s.clusters.map(c => {
if (c.id !== id || c.serverIds.includes(serverId)) return c;
return { ...c, serverIds: [...c.serverIds, serverId] };
}),
}));
touchCluster(set, get, id);
},
removeServerFromCluster: (id, serverId) => {
set(s => {
const clusters = s.clusters
.map(c => {
if (c.id !== id) return c;
const next = c.serverIds.filter(sid => sid !== serverId);
return { ...c, serverIds: next };
})
.filter(c => c.serverIds.length > 0);
const activeClusterId =
s.activeClusterId === id && !clusters.some(c => c.id === id)
? null
: s.activeClusterId;
return { clusters, activeClusterId };
});
touchCluster(set, get, id);
},
setClusterSyncPlayCounts: (id, enabled) => {
set(s => ({
clusters: s.clusters.map(c =>
c.id === id ? { ...c, clusterSyncPlayCounts: enabled } : c,
),
}));
},
deleteCluster: id => {
set(s => ({
clusters: s.clusters.filter(c => c.id !== id),
activeClusterId: s.activeClusterId === id ? null : s.activeClusterId,
}));
},
setActiveCluster: id => {
set({ activeClusterId: id });
if (id) {
void recomputeClusterRepresentative(id);
}
},
};
}
+8 -2
View File
@@ -15,7 +15,9 @@ type SetState = (
* the active id to the next available server (or null) so the rest of
* the app doesn't end up reading stale state.
*/
export function createServerProfileActions(set: SetState): Pick<
type GetState = () => AuthState;
export function createServerProfileActions(set: SetState, get: GetState): Pick<
AuthState,
| 'addServer'
| 'updateServer'
@@ -41,6 +43,10 @@ export function createServerProfileActions(set: SetState): Pick<
},
removeServer: (id) => {
const inClusters = get().clusters.some(c => c.serverIds.includes(id));
if (inClusters) {
throw new Error('SERVER_IN_CLUSTER');
}
// queueServerId is the canonical index key (B1); resolve the
// canonical id back to a server UUID before comparing so a profile
// delete still clears the matching queue binding.
@@ -70,7 +76,7 @@ export function createServerProfileActions(set: SetState): Pick<
},
setServers: (servers) => set({ servers }),
setActiveServer: (id) => set({ activeServerId: id, musicFolders: [] }),
setActiveServer: (id) => set({ activeServerId: id, activeClusterId: null, musicFolders: [] }),
setLoggedIn: (v) => set({ isLoggedIn: v }),
setConnecting: (v) => set({ isConnecting: v }),
setConnectionError: (e) => set({ connectionError: e }),
+90
View File
@@ -0,0 +1,90 @@
/**
* Server cluster model — create/order/remove, active scope, delete guard.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from './authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { clustersContainingServer, isClusterMode } from '@/utils/serverCluster/clusterScope';
vi.mock('@/utils/library/libraryReady', () => ({
libraryIsReady: vi.fn(async () => true),
}));
vi.mock('@/utils/server/serverEndpoint', () => ({
getCachedConnectBaseUrl: vi.fn(() => 'https://cached.test'),
}));
function addTwoServers(): { a: string; b: string } {
const a = useAuthStore.getState().addServer({
name: 'A',
url: 'https://a.test',
username: 'u',
password: 'p',
});
const b = useAuthStore.getState().addServer({
name: 'B',
url: 'https://b.test',
username: 'u',
password: 'p',
});
return { a, b };
}
beforeEach(() => {
resetAuthStore();
});
describe('createCluster', () => {
it('requires at least two members', () => {
const { a } = addTwoServers();
expect(() => useAuthStore.getState().createCluster('X', [a])).toThrow(/two servers/i);
});
it('persists cluster with default clusterSyncPlayCounts true', () => {
const { a, b } = addTwoServers();
const id = useAuthStore.getState().createCluster(' Home ', [a, b]);
const cluster = useAuthStore.getState().clusters.find(c => c.id === id);
expect(cluster?.name).toBe('Home');
expect(cluster?.serverIds).toEqual([a, b]);
expect(cluster?.clusterSyncPlayCounts).toBe(true);
});
});
describe('setActiveCluster / setActiveServer', () => {
it('setActiveCluster enables cluster mode', () => {
const { a, b } = addTwoServers();
const id = useAuthStore.getState().createCluster('C', [a, b]);
useAuthStore.getState().setActiveCluster(id);
expect(isClusterMode()).toBe(true);
expect(useAuthStore.getState().activeClusterId).toBe(id);
});
it('setActiveServer clears activeClusterId', () => {
const { a, b } = addTwoServers();
const id = useAuthStore.getState().createCluster('C', [a, b]);
useAuthStore.getState().setActiveCluster(id);
useAuthStore.getState().setActiveServer(a);
expect(useAuthStore.getState().activeClusterId).toBeNull();
});
});
describe('removeServerFromCluster', () => {
it('deletes cluster when last member removed', () => {
const { a, b } = addTwoServers();
const id = useAuthStore.getState().createCluster('C', [a, b]);
useAuthStore.getState().setActiveCluster(id);
useAuthStore.getState().removeServerFromCluster(id, b);
useAuthStore.getState().removeServerFromCluster(id, a);
expect(useAuthStore.getState().clusters).toHaveLength(0);
expect(useAuthStore.getState().activeClusterId).toBeNull();
});
});
describe('removeServer guard', () => {
it('throws when server is in any cluster', () => {
const { a, b } = addTwoServers();
useAuthStore.getState().createCluster('C', [a, b]);
expect(() => useAuthStore.getState().removeServer(a)).toThrow('SERVER_IN_CLUSTER');
expect(clustersContainingServer(a)).toHaveLength(1);
});
});
+5 -1
View File
@@ -10,6 +10,7 @@ import { createMusicLibraryActions } from './authMusicLibraryActions';
import { createPerServerCapabilityActions } from './authPerServerCapabilityActions';
import { createPlumbingSettingsActions } from './authPlumbingActions';
import { createServerProfileActions } from './authServerProfileActions';
import { createClusterActions } from './authClusterActions';
import { createSkipStarActions } from './authSkipStarActions';
import { createTrackPreviewActions } from './authTrackPreviewActions';
import { createUiAppearanceActions } from './authUiAppearanceActions';
@@ -31,6 +32,8 @@ export const useAuthStore = create<AuthState>()(
(set, get) => ({
servers: [],
activeServerId: null,
activeClusterId: null,
clusters: [],
lastfmApiKey: '',
lastfmApiSecret: '',
lastfmSessionKey: '',
@@ -130,7 +133,8 @@ export const useAuthStore = create<AuthState>()(
connectionError: null,
lastfmSessionError: false,
...createServerProfileActions(set),
...createServerProfileActions(set, get),
...createClusterActions(set, get),
...createAuthLastfmActions(set),
...createAudioSettingsActions(set),
...createCacheStorageActions(set),
+13
View File
@@ -3,6 +3,7 @@ import type {
InstantMixProbeResult,
SubsonicServerIdentity,
} from '../utils/server/subsonicServerIdentity';
import type { ServerCluster } from '../utils/serverCluster/types';
export interface ServerProfile {
id: string;
@@ -74,6 +75,9 @@ export interface AuthState {
// Multi-server
servers: ServerProfile[];
activeServerId: string | null;
/** When set, browse/merge scope is this cluster (`activeServerId` = representative). */
activeClusterId: string | null;
clusters: ServerCluster[];
// Last.fm (global)
lastfmApiKey: string;
@@ -377,6 +381,15 @@ export interface AuthState {
nowPlayingAtTop: boolean;
setNowPlayingAtTop: (v: boolean) => void;
createCluster: (name: string, serverIds: string[]) => string;
renameCluster: (id: string, name: string) => void;
setClusterOrder: (id: string, serverIds: string[]) => void;
addServerToCluster: (id: string, serverId: string) => void;
removeServerFromCluster: (id: string, serverId: string) => void;
setClusterSyncPlayCounts: (id: string, enabled: boolean) => void;
deleteCluster: (id: string) => void;
setActiveCluster: (id: string | null) => void;
logout: () => void;
// Derived
@@ -0,0 +1,29 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useAuthStore } from '../../store/authStore';
import { resetAuthStore } from '@/test/helpers/storeReset';
import { getClusterMemberProfiles } from './clusterScope';
beforeEach(() => {
resetAuthStore();
});
describe('getClusterMemberProfiles', () => {
it('returns members in priority order and skips deleted servers', () => {
const a = useAuthStore.getState().addServer({
name: 'A',
url: 'https://a.test',
username: 'u',
password: 'p',
});
const b = useAuthStore.getState().addServer({
name: 'B',
url: 'https://b.test',
username: 'u',
password: 'p',
});
const id = useAuthStore.getState().createCluster('C', [b, a]);
const cluster = useAuthStore.getState().clusters.find(c => c.id === id)!;
const profiles = getClusterMemberProfiles(cluster);
expect(profiles.map(p => p.id)).toEqual([b, a]);
});
});
+37
View File
@@ -0,0 +1,37 @@
import { useAuthStore } from '../../store/authStore';
import type { ServerCluster } from './types';
import type { ServerProfile } from '../../store/authStoreTypes';
/** Active cluster scope, or null when a single server is active. */
export function getActiveClusterId(): string | null {
return useAuthStore.getState().activeClusterId;
}
export function isClusterMode(): boolean {
return getActiveClusterId() != null;
}
export function getActiveCluster(): ServerCluster | null {
const { activeClusterId, clusters } = useAuthStore.getState();
if (!activeClusterId) return null;
return clusters.find(c => c.id === activeClusterId) ?? null;
}
/** Ordered member profiles that still exist in `servers`. */
export function getClusterMemberProfiles(cluster: ServerCluster): ServerProfile[] {
const { servers } = useAuthStore.getState();
return cluster.serverIds
.map(id => servers.find(s => s.id === id))
.filter((s): s is ServerProfile => s != null);
}
/** Member server ids for an active cluster (empty when not in cluster mode). */
export function getActiveClusterMemberIds(): string[] {
const cluster = getActiveCluster();
return cluster?.serverIds ?? [];
}
/** Clusters referencing a server (for delete guard). */
export function clustersContainingServer(serverId: string): ServerCluster[] {
return useAuthStore.getState().clusters.filter(c => c.serverIds.includes(serverId));
}
+49
View File
@@ -0,0 +1,49 @@
import { getCachedConnectBaseUrl } from '../server/serverEndpoint';
import { libraryIsReady } from '../library/libraryReady';
import { getClusterMemberProfiles } from './clusterScope';
/** Best-effort sync reachability (sticky connect cache populated by probes). */
export function isServerLikelyReachable(serverId: string): boolean {
return getCachedConnectBaseUrl(serverId) != null;
}
/**
* Pick priority-1 among available members; set `activeServerId` representative.
*/
export async function recomputeClusterRepresentative(clusterId: string): Promise<void> {
const { useAuthStore } = await import('../../store/authStore');
const cluster = useAuthStore.getState().clusters.find(c => c.id === clusterId);
if (!cluster) return;
const members = getClusterMemberProfiles(cluster);
for (const server of members) {
if (!isServerLikelyReachable(server.id)) continue;
if (!(await libraryIsReady(server.id))) continue;
const { activeServerId, setActiveServer } = useAuthStore.getState();
if (activeServerId !== server.id) {
setActiveServer(server.id);
}
return;
}
const fallback = members[0];
if (fallback) {
const { activeServerId, setActiveServer } = useAuthStore.getState();
if (activeServerId !== fallback.id) {
setActiveServer(fallback.id);
}
}
}
/** Available + index-ready members in priority order (async). */
export async function getClusterMergeMemberIds(clusterId: string): Promise<string[]> {
const { useAuthStore } = await import('../../store/authStore');
const cluster = useAuthStore.getState().clusters.find(c => c.id === clusterId);
if (!cluster) return [];
const out: string[] = [];
for (const server of getClusterMemberProfiles(cluster)) {
if (!isServerLikelyReachable(server.id)) continue;
if (!(await libraryIsReady(server.id))) continue;
out.push(server.id);
}
return out;
}
+9
View File
@@ -0,0 +1,9 @@
/** Persisted server cluster definition (spec §3.2). */
export interface ServerCluster {
id: string;
name: string;
/** Ordered member server profile ids; index 0 = highest priority. */
serverIds: string[];
/** Fan-out play-count scrobble submission=true to all members (default ON). */
clusterSyncPlayCounts: boolean;
}