mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user