feat(lucky-mix): select AudioMuse libraries across servers

This commit is contained in:
cucadmuh
2026-07-17 01:17:12 +03:00
parent aa4cc67f80
commit ba635c5d4e
5 changed files with 143 additions and 17 deletions
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { isLuckyMixAvailable } from '@/features/randomMix/hooks/useLuckyMixAvailable';
describe('isLuckyMixAvailable', () => {
it('keeps Lucky Mix available when a selected non-active server supports AudioMuse', () => {
expect(isLuckyMixAvailable({
activeServerId: 'plain',
libraryBrowseServerIds: ['plain', 'audio'],
audiomuseByServer: { plain: false, audio: true },
showLuckyMixMenu: true,
})).toBe(true);
});
it('does not expose Lucky Mix for an AudioMuse server outside the selected scope', () => {
expect(isLuckyMixAvailable({
activeServerId: 'audio',
libraryBrowseServerIds: ['plain'],
audiomuseByServer: { plain: false, audio: true },
showLuckyMixMenu: true,
})).toBe(false);
});
});
@@ -8,8 +8,8 @@ import { useAuthStore } from '@/store/authStore';
* the sidebarNavReorder filter. All call sites share the same three-way
* predicate:
* 1. User hasn't hidden it via the Settings toggle.
* 2. AudioMuse is enabled for the active server (feature depends on
* audiomuse-backed similar-track quality).
* 2. AudioMuse is enabled for at least one server in the selected library
* scope (feature depends on audiomuse-backed similar-track quality).
* 3. An active server exists at all.
*
* Callers that additionally care about the "split vs hub" navigation mode
@@ -18,23 +18,28 @@ import { useAuthStore } from '@/store/authStore';
*/
export function isLuckyMixAvailable(args: {
activeServerId: string | null | undefined;
libraryBrowseServerIds?: string[];
audiomuseByServer: Record<string, boolean>;
showLuckyMixMenu: boolean;
}): boolean {
const { activeServerId, audiomuseByServer, showLuckyMixMenu } = args;
const { activeServerId, libraryBrowseServerIds = [], audiomuseByServer, showLuckyMixMenu } = args;
if (!showLuckyMixMenu) return false;
if (!activeServerId) return false;
return Boolean(audiomuseByServer[activeServerId]);
const eligibleServers = libraryBrowseServerIds.length > 0
? libraryBrowseServerIds
: [activeServerId];
return eligibleServers.some(serverId => audiomuseByServer[serverId]);
}
/**
* React hook form — subscribes to the three authStore slices the predicate
* depends on, so any user-facing change (toggle flip, server switch, AudioMuse
* depends on, so any user-facing change (toggle flip, selected server, AudioMuse
* toggle on/off) re-renders the caller automatically.
*/
export function useLuckyMixAvailable(): boolean {
const activeServerId = useAuthStore(s => s.activeServerId);
const libraryBrowseServerIds = useAuthStore(s => s.libraryBrowseServerIds);
const audiomuseByServer = useAuthStore(s => s.audiomuseNavidromeByServer);
const showLuckyMixMenu = useAuthStore(s => s.showLuckyMixMenu);
return isLuckyMixAvailable({ activeServerId, audiomuseByServer, showLuckyMixMenu });
return isLuckyMixAvailable({ activeServerId, libraryBrowseServerIds, audiomuseByServer, showLuckyMixMenu });
}
+20 -11
View File
@@ -5,7 +5,6 @@ import type { QueueItemRef } from '@/lib/media/trackTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { frontendDebugLog } from '@/lib/api/debugLog';
import i18n from '@/lib/i18n';
import { librarySelectionForServer } from '@/lib/api/subsonicClient';
import { runWithLuckyMixLibraryScope } from '@/lib/library/luckyMixScopeOverride';
import { useAuthStore } from '@/store/authStore';
import { pushQueueUndoFromGetter } from '@/features/playback/store/queueUndo';
@@ -37,9 +36,10 @@ import {
pickGoodRatedSongs,
} from '@/features/randomMix/utils/luckyMixHelpers';
import {
isPartialMultiLibrarySelection,
pickLuckyMixTargetLibrary,
luckyMixLibraryCandidates,
pickLuckyMixTarget,
} from '@/features/randomMix/utils/luckyMixLibraryPick';
import { switchActiveServer } from '@/utils/server/switchActiveServer';
/**
* Sentinel thrown inside the build loop when `useLuckyMixStore.cancelRequested`
@@ -74,6 +74,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
const available = isLuckyMixAvailable({
activeServerId,
audiomuseByServer: auth.audiomuseNavidromeByServer,
libraryBrowseServerIds: auth.libraryBrowseServerIds,
showLuckyMixMenu: auth.showLuckyMixMenu,
});
const mixRatingCfg = getMixMinRatingsConfigFromAuth();
@@ -120,6 +121,21 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
const unsubPlayer: { current: (() => void) | null } = { current: null };
let startedPlayback = false;
try {
const candidates = luckyMixLibraryCandidates({
servers: auth.servers,
libraryBrowseServerIds: auth.libraryBrowseServerIds,
musicFoldersByServer: auth.musicFoldersByServer,
libraryBrowseSelectionByServer: auth.libraryBrowseSelectionByServer,
audiomuseByServer: auth.audiomuseNavidromeByServer,
});
if (candidates.length === 0) throw new Error('no-audiomuse-library');
const target = await pickLuckyMixTarget(candidates);
logStep('library_scope_pick', { candidates, target });
if (target.serverId !== activeServerId) {
const server = auth.servers.find(entry => entry.id === target.serverId);
if (!server || !(await switchActiveServer(server))) throw new Error('lucky-mix-server-switch-failed');
}
// Browsed server ≠ queue server: stop A's stream so Now Playing does not call
// ensurePlaybackServerActive() and revert the UI mid-build.
if (shouldHandoffQueueToActiveServer()) {
@@ -367,14 +383,7 @@ export async function buildAndPlayLuckyMix(): Promise<void> {
}
};
if (activeServerId && isPartialMultiLibrarySelection(activeServerId)) {
const candidates = librarySelectionForServer(activeServerId);
const targetLibraryId = await pickLuckyMixTargetLibrary(activeServerId, candidates);
logStep('library_scope_pick', { candidates, targetLibraryId });
await runWithLuckyMixLibraryScope(targetLibraryId, runBuild);
} else {
await runBuild();
}
await runWithLuckyMixLibraryScope(target.libraryId, runBuild);
} catch (err) {
// Cancellation is a user-initiated path, not an error. Silent teardown.
if (err instanceof LuckyMixCancelled) {
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/store/authStore';
import {
isPartialMultiLibrarySelection,
luckyMixLibraryCandidates,
pickLuckyMixTarget,
pickLuckyMixTargetLibrary,
LUCKY_MIX_LARGE_LIBRARY_TRACK_THRESHOLD,
} from '@/features/randomMix/utils/luckyMixLibraryPick';
@@ -105,3 +107,39 @@ describe('pickLuckyMixTargetLibrary', () => {
).resolves.toBe('mid');
});
});
describe('multi-server Lucky Mix candidates', () => {
const source = {
servers: [{ id: 'audio-a' }, { id: 'plain-b' }, { id: 'audio-c' }],
libraryBrowseServerIds: ['audio-a', 'plain-b', 'audio-c'],
musicFoldersByServer: {
'audio-a': [{ id: 'a1' }, { id: 'a2' }],
'plain-b': [{ id: 'b1' }],
'audio-c': [{ id: 'c1' }],
},
libraryBrowseSelectionByServer: {
'audio-a': ['a2'],
'plain-b': ['b1'],
'audio-c': [],
},
audiomuseByServer: { 'audio-a': true, 'plain-b': false, 'audio-c': true },
};
it('uses selected libraries from AudioMuse-capable servers only', () => {
expect(luckyMixLibraryCandidates(source)).toEqual([
{ serverId: 'audio-a', libraryId: 'a2' },
{ serverId: 'audio-c', libraryId: 'c1' },
]);
});
it('randomly picks among large libraries across server owners', async () => {
libraryGetStatusMock.mockImplementation(async (serverId: string, libraryId: string) => ({
localTrackCount: serverId === 'audio-a' && libraryId === 'a2' ? 5000 : 2000,
}));
vi.spyOn(Math, 'random').mockReturnValue(0.99);
await expect(pickLuckyMixTarget(luckyMixLibraryCandidates(source))).resolves.toEqual({
serverId: 'audio-c', libraryId: 'c1',
});
});
});
@@ -5,6 +5,35 @@ import { useAuthStore } from '@/store/authStore';
/** Libraries above this size are preferred; several large ones are picked at random. */
export const LUCKY_MIX_LARGE_LIBRARY_TRACK_THRESHOLD = 1000;
export interface LuckyMixLibraryCandidate {
serverId: string;
libraryId: string;
}
interface LuckyMixCandidateSource {
servers: Array<{ id: string }>;
libraryBrowseServerIds: string[];
musicFoldersByServer: Record<string, Array<{ id: string }>>;
libraryBrowseSelectionByServer: Record<string, string[]>;
audiomuseByServer: Record<string, boolean>;
}
/** Concrete selected library pairs on servers that can build AudioMuse mixes. */
export function luckyMixLibraryCandidates(source: LuckyMixCandidateSource): LuckyMixLibraryCandidate[] {
const selectedServers = new Set(source.libraryBrowseServerIds);
const candidates: LuckyMixLibraryCandidate[] = [];
for (const server of source.servers) {
if (!selectedServers.has(server.id) || !source.audiomuseByServer[server.id]) continue;
const folders = source.musicFoldersByServer[server.id] ?? [];
const selected = source.libraryBrowseSelectionByServer[server.id] ?? [];
const libraryIds = selected.length > 0 ? selected : folders.map(folder => folder.id);
for (const libraryId of libraryIds) {
if (libraryId) candidates.push({ serverId: server.id, libraryId });
}
}
return candidates;
}
/** True when the user selected several libraries but not the full server set. */
export function isPartialMultiLibrarySelection(serverId: string): boolean {
const selection = librarySelectionForServer(serverId);
@@ -46,3 +75,26 @@ export async function pickLuckyMixTargetLibrary(
const tier = counts.filter(c => c.count === maxCount);
return tier[Math.floor(Math.random() * tier.length)]!.libraryId;
}
/** Choose across selected `(server, library)` pairs using the same large-library policy. */
export async function pickLuckyMixTarget(
candidates: LuckyMixLibraryCandidate[],
): Promise<LuckyMixLibraryCandidate> {
if (candidates.length === 0) throw new Error('lucky-mix: no library candidates');
if (candidates.length === 1) return candidates[0]!;
const counts = await Promise.all(candidates.map(async candidate => {
try {
const status = await libraryGetStatus(candidate.serverId, candidate.libraryId);
return { ...candidate, count: Math.max(0, status.localTrackCount ?? 0) };
} catch {
return { ...candidate, count: 0 };
}
}));
const large = counts.filter(candidate => candidate.count > LUCKY_MIX_LARGE_LIBRARY_TRACK_THRESHOLD);
const tier = large.length > 0
? large
: counts.filter(candidate => candidate.count === Math.max(...counts.map(item => item.count)));
const picked = tier[Math.floor(Math.random() * tier.length)]!;
return { serverId: picked.serverId, libraryId: picked.libraryId };
}