mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 23:35:44 +00:00
feat(lucky-mix): select AudioMuse libraries across servers
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user