From ba635c5d4e9ec7a700970b3f790015597bbf5a98 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:17:12 +0300 Subject: [PATCH] feat(lucky-mix): select AudioMuse libraries across servers --- .../hooks/useLuckyMixAvailable.test.ts | 22 ++++++++ .../randomMix/hooks/useLuckyMixAvailable.ts | 17 +++--- src/features/randomMix/utils/luckyMix.ts | 31 +++++++---- .../utils/luckyMixLibraryPick.test.ts | 38 ++++++++++++++ .../randomMix/utils/luckyMixLibraryPick.ts | 52 +++++++++++++++++++ 5 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 src/features/randomMix/hooks/useLuckyMixAvailable.test.ts diff --git a/src/features/randomMix/hooks/useLuckyMixAvailable.test.ts b/src/features/randomMix/hooks/useLuckyMixAvailable.test.ts new file mode 100644 index 00000000..4543c5dc --- /dev/null +++ b/src/features/randomMix/hooks/useLuckyMixAvailable.test.ts @@ -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); + }); +}); diff --git a/src/features/randomMix/hooks/useLuckyMixAvailable.ts b/src/features/randomMix/hooks/useLuckyMixAvailable.ts index 7f1d4a2c..e6fd8bcb 100644 --- a/src/features/randomMix/hooks/useLuckyMixAvailable.ts +++ b/src/features/randomMix/hooks/useLuckyMixAvailable.ts @@ -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; 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 }); } diff --git a/src/features/randomMix/utils/luckyMix.ts b/src/features/randomMix/utils/luckyMix.ts index ff4db889..662d487b 100644 --- a/src/features/randomMix/utils/luckyMix.ts +++ b/src/features/randomMix/utils/luckyMix.ts @@ -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 { 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 { 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 { } }; - 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) { diff --git a/src/features/randomMix/utils/luckyMixLibraryPick.test.ts b/src/features/randomMix/utils/luckyMixLibraryPick.test.ts index f54a2a41..92b90ef9 100644 --- a/src/features/randomMix/utils/luckyMixLibraryPick.test.ts +++ b/src/features/randomMix/utils/luckyMixLibraryPick.test.ts @@ -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', + }); + }); +}); diff --git a/src/features/randomMix/utils/luckyMixLibraryPick.ts b/src/features/randomMix/utils/luckyMixLibraryPick.ts index e3737158..e7ee410f 100644 --- a/src/features/randomMix/utils/luckyMixLibraryPick.ts +++ b/src/features/randomMix/utils/luckyMixLibraryPick.ts @@ -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>; + libraryBrowseSelectionByServer: Record; + audiomuseByServer: Record; +} + +/** 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 { + 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 }; +}