mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(playback): preserve queue top-up ownership
This commit is contained in:
@@ -68,7 +68,7 @@ export function useCliBridge(navigate: NavigateFunction) {
|
||||
);
|
||||
if (shuffled.length > 0) {
|
||||
const aid = song.artistId?.trim() || undefined;
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid);
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid, serverId ?? undefined);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -96,6 +96,7 @@ describe('context-menu radio ownership', () => {
|
||||
expect(mocks.enqueueRadio).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ id: 'similar', serverId: 'srv-owner', radioAdded: true })],
|
||||
'artist-1',
|
||||
'srv-owner',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -116,6 +117,7 @@ describe('context-menu radio ownership', () => {
|
||||
expect.objectContaining({ id: 'new', serverId: 'srv-b' }),
|
||||
[expect.objectContaining({ id: 'new', serverId: 'srv-b' })],
|
||||
);
|
||||
expect(mocks.setRadioArtistId).toHaveBeenCalledWith('artist-b', 'srv-b');
|
||||
});
|
||||
|
||||
it('builds album downloads with the album owner', async () => {
|
||||
|
||||
@@ -64,7 +64,7 @@ export async function startRadio(
|
||||
: shuffleArray(
|
||||
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })),
|
||||
);
|
||||
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
|
||||
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId, ownerServerId);
|
||||
} catch (e) {
|
||||
console.error('Failed to load radio queue', e);
|
||||
}
|
||||
@@ -89,18 +89,18 @@ export async function startRadio(
|
||||
if (fallback.length === 0) return;
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio(fallback, artistId);
|
||||
state.enqueueRadio(fallback, artistId, ownerServerId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
state.setRadioArtistId(artistId, ownerServerId);
|
||||
playTrack(fallback[0], fallback);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const state = usePlayerStore.getState();
|
||||
if (state.currentTrack) {
|
||||
state.enqueueRadio([topTracks[0]], artistId);
|
||||
state.enqueueRadio([topTracks[0]], artistId, ownerServerId);
|
||||
} else {
|
||||
state.setRadioArtistId(artistId);
|
||||
state.setRadioArtistId(artistId, ownerServerId);
|
||||
playTrack(topTracks[0], [topTracks[0]]);
|
||||
}
|
||||
similarPromise.then(similar => {
|
||||
@@ -118,7 +118,7 @@ export async function startRadio(
|
||||
.slice(queueIndex + 1)
|
||||
.filter(r => r.radioAdded)
|
||||
.map(r => resolveQueueTrack(r));
|
||||
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId);
|
||||
usePlayerStore.getState().enqueueRadio([...pendingRadio, ...similarTracks], artistId, ownerServerId);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Failed to start radio', e);
|
||||
@@ -146,7 +146,7 @@ export async function startInstantMix(
|
||||
);
|
||||
if (shuffled.length > 0) {
|
||||
const aid = song.artistId?.trim() || undefined;
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid);
|
||||
usePlayerStore.getState().enqueueRadio(shuffled, aid, serverId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Instant mix failed', e);
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import {
|
||||
getSimilarSongs2ForServer,
|
||||
getTopSongsForServer,
|
||||
} from '@/lib/api/subsonicArtists';
|
||||
import { audioStop } from '@/lib/api/audio';
|
||||
import { buildInfiniteQueueCandidates } from '@/features/playback/utils/playback/buildInfiniteQueueCandidates';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { ensureQueueServerPinned } from '@/features/playback/utils/playback/playbackServer';
|
||||
import {
|
||||
ensureQueueServerPinned,
|
||||
playbackProfileIdForTrack,
|
||||
} from '@/features/playback/utils/playback/playbackServer';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { setIsAudioPaused } from '@/features/playback/store/engineState';
|
||||
import {
|
||||
getPlayGeneration,
|
||||
setIsAudioPaused,
|
||||
} from '@/features/playback/store/engineState';
|
||||
import {
|
||||
isInfiniteQueueFetching,
|
||||
setInfiniteQueueFetching,
|
||||
@@ -18,12 +27,18 @@ import { seedQueueResolver } from '@/features/playback/store/queueTrackResolver'
|
||||
import {
|
||||
addRadioSessionSeen,
|
||||
getCurrentRadioArtistId,
|
||||
getCurrentRadioServerId,
|
||||
hasRadioSessionSeen,
|
||||
isRadioFetching,
|
||||
setRadioFetching,
|
||||
} from '@/features/playback/store/radioSessionState';
|
||||
import { finalizePlayQueueAtTrackEnd } from '@/features/playback/store/queueSync';
|
||||
import { applySkipStarOnManualNext } from '@/features/playback/store/skipStarRating';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import {
|
||||
queueItemIdentityKey,
|
||||
queueTrackIdentityKey,
|
||||
} from '@/features/playback/utils/playback/queueIdentity';
|
||||
|
||||
type SetState = (
|
||||
partial: Partial<PlayerState> | ((state: PlayerState) => Partial<PlayerState>),
|
||||
@@ -36,12 +51,17 @@ type GetState = () => PlayerState;
|
||||
* resolve without a network round-trip), then play the first appended track at
|
||||
* its new tail index. Refs in / Track for the play call only — thin-state.
|
||||
*/
|
||||
function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]): void {
|
||||
function appendTracksAndPlayFirst(
|
||||
set: SetState,
|
||||
get: GetState,
|
||||
fresh: Track[],
|
||||
serverId: string,
|
||||
): void {
|
||||
if (fresh.length === 0) return;
|
||||
// Pin the server *before* reading state so the appended refs (and the
|
||||
// resolver seed) carry the canonical server key — otherwise queue rows for
|
||||
// the appended tracks render as the resolver placeholder. See PR #892.
|
||||
const serverId = ensureQueueServerPinned();
|
||||
ensureQueueServerPinned(fresh);
|
||||
const state = get();
|
||||
if (serverId) seedQueueResolver(serverId, fresh);
|
||||
const incoming: QueueItemRef[] = toQueueItemRefs(serverId, fresh);
|
||||
@@ -52,6 +72,23 @@ function appendTracksAndPlayFirst(set: SetState, get: GetState, fresh: Track[]):
|
||||
get().playTrack(fresh[0], undefined, false, false, playAt);
|
||||
}
|
||||
|
||||
function buildFreshRadioTracks(
|
||||
sourceList: SubsonicSong[],
|
||||
existingIdentities: Set<string>,
|
||||
serverId: string,
|
||||
): Track[] {
|
||||
const fresh: Track[] = [];
|
||||
for (const raw of sourceList) {
|
||||
if (fresh.length >= 10) break;
|
||||
const track = { ...songToTrack(raw), serverId };
|
||||
const identity = queueTrackIdentityKey(track.id, serverId);
|
||||
if (existingIdentities.has(identity) || hasRadioSessionSeen(identity)) continue;
|
||||
addRadioSessionSeen(identity);
|
||||
fresh.push({ ...track, radioAdded: true as const });
|
||||
}
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/** Repeat-off queue tail: stop transport and finalize server play queue at EOF. */
|
||||
function stopAtNaturalQueueEnd(set: SetState, get: GetState): void {
|
||||
const { currentTrack, queueItems } = get();
|
||||
@@ -91,6 +128,8 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
const nextRef = queueItems[nextIdx];
|
||||
const nextTrack = resolveQueueTrack(nextRef);
|
||||
get().playTrack(nextTrack, undefined, manual, false, nextIdx);
|
||||
const topUpGeneration = getPlayGeneration();
|
||||
const topUpServerId = playbackProfileIdForTrack(nextTrack, nextRef);
|
||||
// Proactively top up auto-added tracks when ≤ 2 remain ahead,
|
||||
// so the queue never runs dry without a visible loading pause.
|
||||
// Skipped while in Orbit — the host's queue is the source of
|
||||
@@ -102,20 +141,20 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
const remainingAuto = queueItems.slice(nextIdx + 1).filter(r => r.autoAdded).length;
|
||||
if (remainingAuto <= 2) {
|
||||
setInfiniteQueueFetching(true);
|
||||
const existingIds = new Set(get().queueItems.map(r => r.trackId));
|
||||
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
||||
const existingIdentities = new Set(get().queueItems.map(queueItemIdentityKey));
|
||||
buildInfiniteQueueCandidates(nextTrack, topUpServerId, existingIdentities, 5).then(newTracks => {
|
||||
// Re-check at resolution time — the user may have joined
|
||||
// an Orbit session between scheduling and resolving.
|
||||
if (isInOrbitSession()) return;
|
||||
if (isInOrbitSession() || getPlayGeneration() !== topUpGeneration) return;
|
||||
if (newTracks.length > 0) {
|
||||
// Pin before set so the appended refs carry the canonical server
|
||||
// key; without this the auto-added rows render as '…' / 0:00
|
||||
// when the queue was populated without a queue-replacing playTrack
|
||||
// (see PR #892).
|
||||
const serverId = ensureQueueServerPinned();
|
||||
ensureQueueServerPinned(newTracks);
|
||||
set(state => {
|
||||
if (serverId) seedQueueResolver(serverId, newTracks);
|
||||
const newItems = [...state.queueItems, ...toQueueItemRefs(serverId, newTracks)];
|
||||
if (topUpServerId) seedQueueResolver(topUpServerId, newTracks);
|
||||
const newItems = [...state.queueItems, ...toQueueItemRefs(topUpServerId, newTracks)];
|
||||
return { queueItems: newItems };
|
||||
});
|
||||
}
|
||||
@@ -130,41 +169,40 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
if (nextRef.radioAdded && !isRadioFetching() && !isInOrbitSession()) {
|
||||
const remainingRadio = queueItems.slice(nextIdx + 1).filter(r => r.radioAdded).length;
|
||||
if (remainingRadio <= 2) {
|
||||
// H2: nextTrack may be a placeholder if its ref is still cold — empty
|
||||
// artist/artistId would seed `getSimilarSongs2('')` and silently
|
||||
// return nothing, leaving radio dry. Prefer the just-played
|
||||
// currentTrack (always fully resolved in playerStore) and the stored
|
||||
// radio seed artist; fall back to nextTrack metadata only when those
|
||||
// are missing. Skip the top-up entirely when no stable seed exists
|
||||
// rather than firing a non-deterministic empty request.
|
||||
// nextTrack may be a placeholder if its ref is still cold. Prefer its
|
||||
// metadata, then the stored radio seed. Only borrow currentTrack
|
||||
// metadata when it belongs to the same owner as the new playing ref.
|
||||
const currentServerId = currentTrack
|
||||
? playbackProfileIdForTrack(currentTrack, queueItems[queueIndex])
|
||||
: '';
|
||||
const currentSharesOwner = currentServerId === topUpServerId;
|
||||
const storedSeedSharesOwner = getCurrentRadioServerId() === topUpServerId;
|
||||
const seedArtistId =
|
||||
currentTrack?.artistId
|
||||
?? getCurrentRadioArtistId()
|
||||
?? nextTrack.artistId
|
||||
nextTrack.artistId
|
||||
?? (storedSeedSharesOwner ? getCurrentRadioArtistId() : undefined)
|
||||
?? (currentSharesOwner ? currentTrack?.artistId : undefined)
|
||||
?? null;
|
||||
const seedArtistName = currentTrack?.artist || nextTrack.artist;
|
||||
const seedArtistName = nextTrack.artist
|
||||
|| (currentSharesOwner ? currentTrack?.artist : '')
|
||||
|| '';
|
||||
if (seedArtistId && seedArtistName) {
|
||||
setRadioFetching(true);
|
||||
Promise.all([getSimilarSongs2(seedArtistId), getTopSongs(seedArtistName)])
|
||||
Promise.all([
|
||||
getSimilarSongs2ForServer(topUpServerId, seedArtistId),
|
||||
getTopSongsForServer(topUpServerId, seedArtistName),
|
||||
])
|
||||
.then(([similar, top]) => {
|
||||
// Re-check — the user may have joined an Orbit session between
|
||||
// scheduling this fetch and its resolution (mirrors the
|
||||
// infinite-queue branch). The finally() still clears the flag.
|
||||
if (isInOrbitSession()) return;
|
||||
const existingIds = new Set(get().queueItems.map(r => r.trackId));
|
||||
if (isInOrbitSession() || getPlayGeneration() !== topUpGeneration) return;
|
||||
const existingIdentities = new Set(get().queueItems.map(queueItemIdentityKey));
|
||||
// Lead with similar (other artists) for variety; top tracks
|
||||
// of the upcoming artist are only a fallback when similar
|
||||
// is empty. Single-pass loop dedupes against the live queue,
|
||||
// the session seen-set, and intra-batch overlap (issue #500).
|
||||
const sourceList = similar.length > 0 ? similar : top;
|
||||
const fresh: Track[] = [];
|
||||
for (const raw of sourceList) {
|
||||
if (fresh.length >= 10) break;
|
||||
const t = songToTrack(raw);
|
||||
if (existingIds.has(t.id) || hasRadioSessionSeen(t.id)) continue;
|
||||
addRadioSessionSeen(t.id);
|
||||
fresh.push({ ...t, radioAdded: true as const });
|
||||
}
|
||||
const fresh = buildFreshRadioTracks(sourceList, existingIdentities, topUpServerId);
|
||||
if (fresh.length > 0) {
|
||||
// Trim played tracks from the front to keep the queue bounded.
|
||||
// Without trimming the queue grows unboundedly, making every
|
||||
@@ -173,13 +211,13 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
// navigate backwards a few songs. Trimmed ids stay in the seen-set.
|
||||
const HISTORY_KEEP = 5;
|
||||
// Pin before set; same reasoning as the infinite top-up above.
|
||||
const serverId = ensureQueueServerPinned();
|
||||
ensureQueueServerPinned(fresh);
|
||||
set(state => {
|
||||
if (serverId) seedQueueResolver(serverId, fresh);
|
||||
if (topUpServerId) seedQueueResolver(topUpServerId, fresh);
|
||||
const trimStart = Math.max(0, state.queueIndex - HISTORY_KEEP);
|
||||
const newItems = [
|
||||
...state.queueItems.slice(trimStart),
|
||||
...toQueueItemRefs(serverId, fresh),
|
||||
...toQueueItemRefs(topUpServerId, fresh),
|
||||
];
|
||||
return {
|
||||
queueItems: newItems,
|
||||
@@ -214,10 +252,18 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
// Queue exhausted. Check radio first (independent of infinite queue setting),
|
||||
// then infinite queue, then stop.
|
||||
if (currentTrack?.radioAdded && !isRadioFetching()) {
|
||||
const artistId = currentTrack.artistId ?? getCurrentRadioArtistId() ?? null;
|
||||
const currentRef = queueItems[queueIndex];
|
||||
const topUpServerId = playbackProfileIdForTrack(currentTrack, currentRef);
|
||||
const storedSeedSharesOwner = getCurrentRadioServerId() === topUpServerId;
|
||||
const artistId = currentTrack.artistId
|
||||
?? (storedSeedSharesOwner ? getCurrentRadioArtistId() : null);
|
||||
if (artistId) {
|
||||
const topUpGeneration = getPlayGeneration();
|
||||
setRadioFetching(true);
|
||||
Promise.all([getSimilarSongs2(artistId), getTopSongs(currentTrack.artist)])
|
||||
Promise.all([
|
||||
getSimilarSongs2ForServer(topUpServerId, artistId),
|
||||
getTopSongsForServer(topUpServerId, currentTrack.artist),
|
||||
])
|
||||
.then(([similar, top]) => {
|
||||
setRadioFetching(false);
|
||||
// The user may have joined an Orbit session while this
|
||||
@@ -228,26 +274,21 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
const existingIds = new Set(get().queueItems.map(r => r.trackId));
|
||||
if (getPlayGeneration() !== topUpGeneration) return;
|
||||
const existingIdentities = new Set(get().queueItems.map(queueItemIdentityKey));
|
||||
// Same source preference + dedup contract as the proactive
|
||||
// top-up: similar first, top only as a fallback (issue #500).
|
||||
const sourceList = similar.length > 0 ? similar : top;
|
||||
const fresh: Track[] = [];
|
||||
for (const raw of sourceList) {
|
||||
if (fresh.length >= 10) break;
|
||||
const t = songToTrack(raw);
|
||||
if (existingIds.has(t.id) || hasRadioSessionSeen(t.id)) continue;
|
||||
addRadioSessionSeen(t.id);
|
||||
fresh.push({ ...t, radioAdded: true as const });
|
||||
}
|
||||
const fresh = buildFreshRadioTracks(sourceList, existingIdentities, topUpServerId);
|
||||
if (fresh.length > 0) {
|
||||
appendTracksAndPlayFirst(set, get, fresh);
|
||||
appendTracksAndPlayFirst(set, get, fresh, topUpServerId);
|
||||
} else {
|
||||
stopAtNaturalQueueEnd(set, get);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setRadioFetching(false);
|
||||
if (getPlayGeneration() !== topUpGeneration) return;
|
||||
stopAtNaturalQueueEnd(set, get);
|
||||
});
|
||||
return;
|
||||
@@ -256,9 +297,14 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
const { infiniteQueueEnabled } = useAuthStore.getState();
|
||||
if (infiniteQueueEnabled && repeatMode === 'off') {
|
||||
if (isInfiniteQueueFetching()) return;
|
||||
const currentRef = queueItems[queueIndex];
|
||||
const topUpServerId = currentTrack
|
||||
? playbackProfileIdForTrack(currentTrack, currentRef)
|
||||
: '';
|
||||
const topUpGeneration = getPlayGeneration();
|
||||
setInfiniteQueueFetching(true);
|
||||
const existingIds = new Set(get().queueItems.map(r => r.trackId));
|
||||
buildInfiniteQueueCandidates(currentTrack, existingIds, 5).then(newTracks => {
|
||||
const existingIdentities = new Set(get().queueItems.map(queueItemIdentityKey));
|
||||
buildInfiniteQueueCandidates(currentTrack, topUpServerId, existingIdentities, 5).then(newTracks => {
|
||||
setInfiniteQueueFetching(false);
|
||||
// The user may have joined an Orbit session while this
|
||||
// fetch was in flight — bail without invoking playTrack.
|
||||
@@ -268,13 +314,15 @@ export function runNext(set: SetState, get: GetState, manual: boolean): void {
|
||||
set({ isPlaying: false, progress: 0, buffered: 0, currentTime: 0 });
|
||||
return;
|
||||
}
|
||||
if (getPlayGeneration() !== topUpGeneration) return;
|
||||
if (newTracks.length === 0) {
|
||||
stopAtNaturalQueueEnd(set, get);
|
||||
return;
|
||||
}
|
||||
appendTracksAndPlayFirst(set, get, newTracks);
|
||||
appendTracksAndPlayFirst(set, get, newTracks, topUpServerId);
|
||||
}).catch(() => {
|
||||
setInfiniteQueueFetching(false);
|
||||
if (getPlayGeneration() !== topUpGeneration) return;
|
||||
stopAtNaturalQueueEnd(set, get);
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Control points for the test.
|
||||
const { inOrbit, getSimilarSongs2, getTopSongs } = vi.hoisted(() => ({
|
||||
const {
|
||||
buildInfiniteQueueCandidates,
|
||||
generation,
|
||||
inOrbit,
|
||||
infiniteQueue,
|
||||
getSimilarSongs2ForServer,
|
||||
getTopSongsForServer,
|
||||
radioSeed,
|
||||
} = vi.hoisted(() => ({
|
||||
buildInfiniteQueueCandidates: vi.fn(() => Promise.resolve([])),
|
||||
generation: { value: 1 },
|
||||
inOrbit: { value: false },
|
||||
getSimilarSongs2: vi.fn(() => Promise.resolve([])),
|
||||
getTopSongs: vi.fn(() => Promise.resolve([])),
|
||||
infiniteQueue: { value: false },
|
||||
getSimilarSongs2ForServer: vi.fn(() => Promise.resolve([])),
|
||||
getTopSongsForServer: vi.fn(() => Promise.resolve([])),
|
||||
radioSeed: { artistId: null as string | null, serverId: null as string | null },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicArtists', () => ({ getSimilarSongs2, getTopSongs }));
|
||||
vi.mock('@/lib/api/subsonicArtists', () => ({ getSimilarSongs2ForServer, getTopSongsForServer }));
|
||||
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(() => Promise.resolve()) }));
|
||||
vi.mock('@/store/orbitRuntime', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('@/store/orbitRuntime')>()),
|
||||
isInOrbitSession: () => inOrbit.value,
|
||||
}));
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: { getState: () => ({ infiniteQueueEnabled: false }) },
|
||||
useAuthStore: { getState: () => ({ infiniteQueueEnabled: infiniteQueue.value }) },
|
||||
}));
|
||||
vi.mock('@/features/playback/store/radioSessionState', () => ({
|
||||
addRadioSessionSeen: vi.fn(),
|
||||
getCurrentRadioArtistId: () => null,
|
||||
getCurrentRadioArtistId: () => radioSeed.artistId,
|
||||
getCurrentRadioServerId: () => radioSeed.serverId,
|
||||
hasRadioSessionSeen: () => false,
|
||||
isRadioFetching: () => false,
|
||||
setRadioFetching: vi.fn(),
|
||||
@@ -27,60 +40,141 @@ vi.mock('@/features/playback/store/infiniteQueueState', () => ({
|
||||
isInfiniteQueueFetching: () => false,
|
||||
setInfiniteQueueFetching: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/features/playback/store/engineState', () => ({ setIsAudioPaused: vi.fn() }));
|
||||
vi.mock('@/features/playback/store/engineState', () => ({
|
||||
getPlayGeneration: () => generation.value,
|
||||
setIsAudioPaused: vi.fn(),
|
||||
}));
|
||||
vi.mock('@/features/playback/store/skipStarRating', () => ({ applySkipStarOnManualNext: vi.fn() }));
|
||||
vi.mock('@/features/playback/store/queueTrackView', () => ({
|
||||
resolveQueueTrack: (ref: { trackId: string }) => ({
|
||||
id: ref.trackId,
|
||||
artistId: 'a1',
|
||||
artist: 'Artist',
|
||||
artistId: ref.trackId === 't1' ? 'next-artist' : ref.trackId === 't-cold' ? undefined : 'current-artist',
|
||||
artist: ref.trackId === 't1' ? 'Next Artist' : ref.trackId === 't-cold' ? '' : 'Current Artist',
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/features/playback/utils/playback/buildInfiniteQueueCandidates', () => ({
|
||||
buildInfiniteQueueCandidates: vi.fn(() => Promise.resolve([])),
|
||||
buildInfiniteQueueCandidates,
|
||||
}));
|
||||
vi.mock('@/lib/media/songToTrack', () => ({ songToTrack: (s: unknown) => s }));
|
||||
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({ ensureQueueServerPinned: () => null }));
|
||||
vi.mock('@/features/playback/utils/playback/playbackServer', () => ({
|
||||
ensureQueueServerPinned: () => null,
|
||||
playbackProfileIdForTrack: (_track: unknown, ref: { serverId?: string }) => ref.serverId ?? 'srv-owner',
|
||||
}));
|
||||
vi.mock('@/features/playback/store/queueTrackResolver', () => ({ seedQueueResolver: vi.fn() }));
|
||||
vi.mock('@/features/playback/store/queueItemRef', () => ({ toQueueItemRefs: () => [] }));
|
||||
|
||||
import { runNext } from '@/features/playback/store/nextAction';
|
||||
|
||||
function fakeGet() {
|
||||
function fakeGet(nextServerId = 'srv-owner', nextTrackId = 't1') {
|
||||
// index 0 → next is the radioAdded ref at index 1; nothing radio ahead of it,
|
||||
// so the ≤2-remaining proactive top-up is eligible.
|
||||
const queueItems = [
|
||||
{ trackId: 't0', radioAdded: true },
|
||||
{ trackId: 't1', radioAdded: true },
|
||||
{ trackId: 't2', radioAdded: false },
|
||||
{ serverId: 'srv-owner', trackId: 't0', radioAdded: true },
|
||||
{ serverId: nextServerId, trackId: nextTrackId, radioAdded: true },
|
||||
{ serverId: 'srv-owner', trackId: 't2', radioAdded: false },
|
||||
];
|
||||
return {
|
||||
queueItems,
|
||||
queueIndex: 0,
|
||||
repeatMode: 'off' as const,
|
||||
currentTrack: { id: 't0', artistId: 'a1', artist: 'Artist', radioAdded: true },
|
||||
currentTrack: { id: 't0', artistId: 'current-artist', artist: 'Current Artist', radioAdded: true },
|
||||
playTrack: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
inOrbit.value = false;
|
||||
getSimilarSongs2.mockClear();
|
||||
getTopSongs.mockClear();
|
||||
infiniteQueue.value = false;
|
||||
generation.value = 1;
|
||||
radioSeed.artistId = null;
|
||||
radioSeed.serverId = null;
|
||||
buildInfiniteQueueCandidates.mockReset().mockResolvedValue([]);
|
||||
getSimilarSongs2ForServer.mockClear();
|
||||
getTopSongsForServer.mockClear();
|
||||
});
|
||||
|
||||
describe('runNext — radio proactive top-up Orbit lockout', () => {
|
||||
it('fires the radio top-up when not in an Orbit session', () => {
|
||||
const get = fakeGet as unknown as () => never;
|
||||
runNext(vi.fn(), get, /* manual */ false);
|
||||
expect(getSimilarSongs2).toHaveBeenCalledTimes(1);
|
||||
expect(getSimilarSongs2ForServer).toHaveBeenCalledWith('srv-owner', 'next-artist');
|
||||
});
|
||||
|
||||
it('skips the radio top-up while in an Orbit session', () => {
|
||||
inOrbit.value = true;
|
||||
const get = fakeGet as unknown as () => never;
|
||||
runNext(vi.fn(), get, /* manual */ false);
|
||||
expect(getSimilarSongs2).not.toHaveBeenCalled();
|
||||
expect(getTopSongs).not.toHaveBeenCalled();
|
||||
expect(getSimilarSongs2ForServer).not.toHaveBeenCalled();
|
||||
expect(getTopSongsForServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the upcoming ref owner and artist across a mixed-server transition', () => {
|
||||
const get = (() => fakeGet('srv-next')) as unknown as () => never;
|
||||
runNext(vi.fn(), get, /* manual */ false);
|
||||
|
||||
expect(getSimilarSongs2ForServer).toHaveBeenCalledWith('srv-next', 'next-artist');
|
||||
expect(getTopSongsForServer).toHaveBeenCalledWith('srv-next', 'Next Artist');
|
||||
});
|
||||
|
||||
it('does not reuse a stored seed from another owner for a cold upcoming ref', () => {
|
||||
radioSeed.artistId = 'old-artist';
|
||||
radioSeed.serverId = 'srv-owner';
|
||||
const get = (() => fakeGet('srv-next', 't-cold')) as unknown as () => never;
|
||||
|
||||
runNext(vi.fn(), get, /* manual */ false);
|
||||
|
||||
expect(getSimilarSongs2ForServer).not.toHaveBeenCalled();
|
||||
expect(getTopSongsForServer).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runNext — exhausted top-up generation guards', () => {
|
||||
it('does not stop newer playback when an exhausted radio fetch rejects', async () => {
|
||||
let rejectSimilar: ((reason?: unknown) => void) | undefined;
|
||||
getSimilarSongs2ForServer.mockImplementationOnce(() => new Promise((_resolve, reject) => {
|
||||
rejectSimilar = reject;
|
||||
}));
|
||||
const stop = vi.fn();
|
||||
const state = {
|
||||
queueItems: [{ serverId: 'srv-owner', trackId: 't0', radioAdded: true }],
|
||||
queueIndex: 0,
|
||||
repeatMode: 'off' as const,
|
||||
currentTrack: {
|
||||
id: 't0', artistId: 'current-artist', artist: 'Current Artist', radioAdded: true,
|
||||
},
|
||||
stop,
|
||||
};
|
||||
|
||||
runNext(vi.fn(), (() => state) as unknown as () => never, false);
|
||||
generation.value = 2;
|
||||
rejectSimilar?.(new Error('offline'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not stop newer playback when an exhausted infinite fetch rejects', async () => {
|
||||
infiniteQueue.value = true;
|
||||
let rejectCandidates: ((reason?: unknown) => void) | undefined;
|
||||
buildInfiniteQueueCandidates.mockImplementationOnce(() => new Promise((_resolve, reject) => {
|
||||
rejectCandidates = reject;
|
||||
}));
|
||||
const stop = vi.fn();
|
||||
const state = {
|
||||
queueItems: [{ serverId: 'srv-owner', trackId: 't0' }],
|
||||
queueIndex: 0,
|
||||
repeatMode: 'off' as const,
|
||||
currentTrack: { id: 't0', artistId: 'current-artist', artist: 'Current Artist' },
|
||||
stop,
|
||||
};
|
||||
|
||||
runNext(vi.fn(), (() => state) as unknown as () => never, false);
|
||||
generation.value = 2;
|
||||
rejectCandidates?.(new Error('offline'));
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(stop).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -105,8 +105,8 @@ export interface PlayerState {
|
||||
* pushes any earlier Play-Next items down (default). Falls back to
|
||||
* `playTrack` when nothing is currently playing. */
|
||||
playNext: (tracks: Track[]) => void;
|
||||
enqueueRadio: (tracks: Track[], artistId?: string) => void;
|
||||
setRadioArtistId: (artistId: string) => void;
|
||||
enqueueRadio: (tracks: Track[], artistId?: string, serverId?: string) => void;
|
||||
setRadioArtistId: (artistId: string, serverId?: string) => void;
|
||||
/** For Lucky Mix: drop upcoming tail; keep the currently playing item only.
|
||||
* When `skipQueueUndo` is true, callers must push undo separately (macro rebuild). */
|
||||
pruneUpcomingToCurrent: (skipQueueUndo?: boolean) => void;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
clearRadioSessionSeenIds,
|
||||
deleteRadioSessionSeen,
|
||||
getCurrentRadioArtistId,
|
||||
getCurrentRadioServerId,
|
||||
hasRadioSessionSeen,
|
||||
setCurrentRadioArtistId,
|
||||
} from '@/features/playback/store/radioSessionState';
|
||||
@@ -154,19 +155,24 @@ export function createQueueMutationActions(set: SetState, get: GetState): Pick<
|
||||
});
|
||||
},
|
||||
|
||||
setRadioArtistId: (artistId) => {
|
||||
if (artistId !== getCurrentRadioArtistId()) {
|
||||
setRadioArtistId: (artistId, serverId) => {
|
||||
const ownerServerId = serverId ?? useAuthStore.getState().activeServerId ?? null;
|
||||
if (artistId !== getCurrentRadioArtistId() || ownerServerId !== getCurrentRadioServerId()) {
|
||||
clearRadioSessionSeenIds();
|
||||
}
|
||||
setCurrentRadioArtistId(artistId);
|
||||
setCurrentRadioArtistId(artistId, ownerServerId);
|
||||
},
|
||||
|
||||
enqueueRadio: (tracks, artistId) => {
|
||||
enqueueRadio: (tracks, artistId, serverId) => {
|
||||
if (artistId !== undefined) {
|
||||
if (artistId !== getCurrentRadioArtistId()) {
|
||||
const ownerServerId = serverId
|
||||
?? tracks.find(track => track.serverId)?.serverId
|
||||
?? useAuthStore.getState().activeServerId
|
||||
?? null;
|
||||
if (artistId !== getCurrentRadioArtistId() || ownerServerId !== getCurrentRadioServerId()) {
|
||||
clearRadioSessionSeenIds();
|
||||
}
|
||||
setCurrentRadioArtistId(artistId);
|
||||
setCurrentRadioArtistId(artistId, ownerServerId);
|
||||
}
|
||||
pushQueueUndoFromGetter(get);
|
||||
ensureQueueServerPinned(tracks);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
clearRadioSessionSeenIds,
|
||||
deleteRadioSessionSeen,
|
||||
getCurrentRadioArtistId,
|
||||
getCurrentRadioServerId,
|
||||
hasRadioSessionSeen,
|
||||
isRadioFetching,
|
||||
setCurrentRadioArtistId,
|
||||
@@ -25,13 +26,16 @@ describe('radioFetching', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('currentRadioArtistId', () => {
|
||||
it('starts null + round-trips', () => {
|
||||
describe('current radio seed', () => {
|
||||
it('round-trips artist and owner together', () => {
|
||||
expect(getCurrentRadioArtistId()).toBeNull();
|
||||
setCurrentRadioArtistId('artist-1');
|
||||
expect(getCurrentRadioServerId()).toBeNull();
|
||||
setCurrentRadioArtistId('artist-1', 'server-a');
|
||||
expect(getCurrentRadioArtistId()).toBe('artist-1');
|
||||
expect(getCurrentRadioServerId()).toBe('server-a');
|
||||
setCurrentRadioArtistId(null);
|
||||
expect(getCurrentRadioArtistId()).toBeNull();
|
||||
expect(getCurrentRadioServerId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,13 +68,14 @@ describe('radioSessionSeenIds', () => {
|
||||
});
|
||||
|
||||
describe('_resetRadioSessionStateForTest', () => {
|
||||
it('resets all three pieces of state', () => {
|
||||
it('resets all session state', () => {
|
||||
setRadioFetching(true);
|
||||
setCurrentRadioArtistId('artist-1');
|
||||
setCurrentRadioArtistId('artist-1', 'server-a');
|
||||
addRadioSessionSeen('t1');
|
||||
_resetRadioSessionStateForTest();
|
||||
expect(isRadioFetching()).toBe(false);
|
||||
expect(getCurrentRadioArtistId()).toBeNull();
|
||||
expect(getCurrentRadioServerId()).toBeNull();
|
||||
expect(hasRadioSessionSeen('t1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* when both `next()` and the proactive top-up path fire close
|
||||
* together.
|
||||
*
|
||||
* - **currentRadioArtistId** — the seed artist that started the
|
||||
* current radio session. Survives track advances so subsequent
|
||||
* top-ups can resolve a new tail even when the now-playing track
|
||||
* has no `artistId` of its own.
|
||||
* - **currentRadioArtistId / currentRadioServerId** — the owned seed
|
||||
* artist that started the current radio session. Survives track
|
||||
* advances so subsequent top-ups can resolve a new tail even when
|
||||
* the now-playing track has no `artistId` of its own.
|
||||
*
|
||||
* - **radioSessionSeenIds** — every id the current radio session has
|
||||
* enqueued so far, *including* entries that were trimmed off the
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
let radioFetching = false;
|
||||
let currentRadioArtistId: string | null = null;
|
||||
let currentRadioServerId: string | null = null;
|
||||
let radioSessionSeenIds = new Set<string>();
|
||||
|
||||
export function isRadioFetching(): boolean {
|
||||
@@ -37,8 +38,13 @@ export function getCurrentRadioArtistId(): string | null {
|
||||
return currentRadioArtistId;
|
||||
}
|
||||
|
||||
export function setCurrentRadioArtistId(id: string | null): void {
|
||||
export function getCurrentRadioServerId(): string | null {
|
||||
return currentRadioServerId;
|
||||
}
|
||||
|
||||
export function setCurrentRadioArtistId(id: string | null, serverId: string | null = null): void {
|
||||
currentRadioArtistId = id;
|
||||
currentRadioServerId = id ? serverId : null;
|
||||
}
|
||||
|
||||
export function hasRadioSessionSeen(id: string): boolean {
|
||||
@@ -58,9 +64,10 @@ export function clearRadioSessionSeenIds(): void {
|
||||
radioSessionSeenIds = new Set();
|
||||
}
|
||||
|
||||
/** Test-only: reset all three pieces of state. */
|
||||
/** Test-only: reset all session state. */
|
||||
export function _resetRadioSessionStateForTest(): void {
|
||||
radioFetching = false;
|
||||
currentRadioArtistId = null;
|
||||
currentRadioServerId = null;
|
||||
radioSessionSeenIds = new Set();
|
||||
}
|
||||
|
||||
@@ -92,6 +92,26 @@ describe('enrichSongsForMixRatingFilter', () => {
|
||||
expect(out[0].artistUserRating).toBe(5);
|
||||
expect(passesMixMinRatings(out[0], enabledArtist2)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses and stamps the explicit owner instead of the active server', async () => {
|
||||
vi.mocked(resolveEntityUserRatings).mockResolvedValue(new Map([
|
||||
['server-b\u0001artist\u0001art-1', 1],
|
||||
]));
|
||||
|
||||
const out = await enrichSongsForMixRatingFilter(
|
||||
[song({ id: '1' })],
|
||||
enabledArtist2,
|
||||
'server-b',
|
||||
);
|
||||
|
||||
expect(resolveEntityUserRatings).toHaveBeenCalledWith([
|
||||
{ serverId: 'server-b', entityKind: 'artist', entityId: 'art-1' },
|
||||
]);
|
||||
expect(out[0]).toEqual(expect.objectContaining({
|
||||
serverId: 'server-b',
|
||||
artistUserRating: 1,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterTopArtistsForMixRatings', () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getRandomSongs } from '@/lib/api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { ownedOverrideValue } from '@/lib/util/ownedEntityKey';
|
||||
|
||||
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
||||
export const RANDOM_MIX_TARGET_SIZE = 50;
|
||||
@@ -67,9 +68,15 @@ function numRating(v: unknown): number | undefined {
|
||||
}
|
||||
|
||||
/** Optimistic stars from the UI (`setUserRatingOverride`) take precedence over API payloads. */
|
||||
function mixRatingOverrideForEntity(entityId: string | undefined): number | undefined {
|
||||
function mixRatingOverrideForEntity(
|
||||
entityId: string | undefined,
|
||||
serverId?: string,
|
||||
): number | undefined {
|
||||
if (!entityId) return undefined;
|
||||
const o = usePlayerStore.getState().userRatingOverrides[entityId];
|
||||
const overrides = usePlayerStore.getState().userRatingOverrides;
|
||||
const o = serverId
|
||||
? ownedOverrideValue(overrides, { id: entityId, serverId })
|
||||
: overrides[entityId];
|
||||
if (o === undefined || o <= 0) return undefined;
|
||||
return o;
|
||||
}
|
||||
@@ -123,7 +130,7 @@ function artistEntityIdForMixRating(song: SubsonicSong): string | undefined {
|
||||
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
|
||||
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
|
||||
const prefer = artistEntityIdForMixRating(song);
|
||||
const fromOverride = mixRatingOverrideForEntity(prefer);
|
||||
const fromOverride = mixRatingOverrideForEntity(prefer, song.serverId);
|
||||
if (fromOverride !== undefined) return fromOverride;
|
||||
const d = numRating(song.artistUserRating);
|
||||
if (d !== undefined) return d;
|
||||
@@ -134,14 +141,14 @@ function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined
|
||||
|
||||
/** Song-level album (parent) rating when the server puts it on the child payload. */
|
||||
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
|
||||
const fromOverride = mixRatingOverrideForEntity(song.albumId);
|
||||
const fromOverride = mixRatingOverrideForEntity(song.albumId, song.serverId);
|
||||
if (fromOverride !== undefined) return fromOverride;
|
||||
const x = song as SubsonicSong & { albumRating?: unknown };
|
||||
return numRating(song.albumUserRating ?? x.albumRating);
|
||||
}
|
||||
|
||||
function songTrackStarRatingForMix(song: SubsonicSong): number | undefined {
|
||||
const fromOverride = mixRatingOverrideForEntity(song.id);
|
||||
const fromOverride = mixRatingOverrideForEntity(song.id, song.serverId);
|
||||
if (fromOverride !== undefined) return fromOverride;
|
||||
const x = song as SubsonicSong & { rating?: unknown };
|
||||
return numRating(song.userRating ?? x.rating);
|
||||
@@ -297,24 +304,29 @@ export async function filterTopArtistsForMixRatings<T extends { id: string }>(
|
||||
export async function enrichSongsForMixRatingFilter(
|
||||
songs: SubsonicSong[],
|
||||
c: MixMinRatingsConfig,
|
||||
ownerServerId?: string,
|
||||
): Promise<SubsonicSong[]> {
|
||||
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
|
||||
const serverId = useAuthStore.getState().activeServerId;
|
||||
const serverId = ownerServerId ?? useAuthStore.getState().activeServerId;
|
||||
if (!serverId) return songs;
|
||||
const ownedSongs = songs.map(song => ({
|
||||
...song,
|
||||
serverId: ownerServerId ?? song.serverId ?? serverId,
|
||||
}));
|
||||
const artistIds =
|
||||
c.minArtist > 0
|
||||
? [...new Set(songs.map(s => artistEntityIdForMixRating(s)).filter((id): id is string => !!id))]
|
||||
? [...new Set(ownedSongs.map(s => artistEntityIdForMixRating(s)).filter((id): id is string => !!id))]
|
||||
: [];
|
||||
const albumIds =
|
||||
c.minAlbum > 0
|
||||
? [...new Set(songs.filter(s => s.albumId).map(s => s.albumId!))]
|
||||
? [...new Set(ownedSongs.filter(s => s.albumId).map(s => s.albumId!))]
|
||||
: [];
|
||||
const ratings = await resolveEntityUserRatings([
|
||||
...artistIds.map(entityId => ({ serverId, entityKind: 'artist' as const, entityId })),
|
||||
...albumIds.map(entityId => ({ serverId, entityKind: 'album' as const, entityId })),
|
||||
]);
|
||||
if (!ratings.size) return songs;
|
||||
return songs.map(s => {
|
||||
if (!ratings.size) return ownedSongs;
|
||||
return ownedSongs.map(s => {
|
||||
const aid = artistEntityIdForMixRating(s);
|
||||
const payloadArtistRating = effectiveArtistRatingForFilter(s);
|
||||
const payloadAlbumRating = effectiveAlbumRatingOnSong(s);
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
* refactor (2026-05-12). This test pins the artist-first / random-fallback
|
||||
* order, the dedup contract against existingIds, and the autoAdded flag.
|
||||
*/
|
||||
import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import { getRandomSongs } from '@/lib/api/subsonicLibrary';
|
||||
import {
|
||||
getSimilarSongs2ForServer,
|
||||
getTopSongsForServer,
|
||||
} from '@/lib/api/subsonicArtists';
|
||||
import { getRandomSongsForServer } from '@/lib/api/subsonicLibrary';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -15,12 +18,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
// `api/subsonicArtists`); the barrel re-exports it, so consumers still get the
|
||||
// stubs while `coerceOpenArtistRefs` (used by songToTrack) stays real.
|
||||
vi.mock('@/lib/api/subsonicArtists', () => ({
|
||||
getSimilarSongs2: vi.fn(),
|
||||
getTopSongs: vi.fn(),
|
||||
getSimilarSongs2ForServer: vi.fn(),
|
||||
getTopSongsForServer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api/subsonicLibrary', () => ({
|
||||
getRandomSongs: vi.fn(),
|
||||
getRandomSongsForServer: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/playback/utils/mixRatingFilter', () => ({
|
||||
@@ -35,6 +38,9 @@ import {
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
} from '@/features/playback/utils/mixRatingFilter';
|
||||
import { makeSubsonicSong } from '@/test/helpers/factories';
|
||||
import { queueTrackIdentityKey } from '@/features/playback/utils/playback/queueIdentity';
|
||||
|
||||
const SERVER_ID = 'server-a';
|
||||
|
||||
const seed = (overrides: Partial<Track> = {}): Track => ({
|
||||
id: 'seed',
|
||||
@@ -48,14 +54,26 @@ const seed = (overrides: Partial<Track> = {}): Track => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
function buildCandidates(
|
||||
seedTrack: Track | null,
|
||||
existingIds: string[] = [],
|
||||
count = 5,
|
||||
serverId = SERVER_ID,
|
||||
): Promise<Track[]> {
|
||||
const existingIdentities = new Set(
|
||||
existingIds.map(id => queueTrackIdentityKey(id, serverId)),
|
||||
);
|
||||
return buildInfiniteQueueCandidates(seedTrack, serverId, existingIdentities, count);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Default mocks — individual tests override as needed. The random-topup loop
|
||||
// calls getRandomSongs unconditionally when artist sources don't fill `count`,
|
||||
// so a default empty resolution avoids "Cannot read properties of undefined".
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
// Default: filter disabled — the function then short-circuits the enrich path.
|
||||
vi.mocked(getMixMinRatingsConfigFromAuth).mockReturnValue({
|
||||
enabled: false,
|
||||
@@ -73,68 +91,68 @@ afterEach(() => {
|
||||
|
||||
describe('buildInfiniteQueueCandidates', () => {
|
||||
it('asks for similar + top in parallel when seedTrack has artistId + artist', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
||||
|
||||
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
||||
await buildCandidates(seed());
|
||||
|
||||
expect(getSimilarSongs2).toHaveBeenCalledWith('ar-A');
|
||||
expect(getTopSongs).toHaveBeenCalledWith('Artist A');
|
||||
expect(getSimilarSongs2ForServer).toHaveBeenCalledWith(SERVER_ID, 'ar-A');
|
||||
expect(getTopSongsForServer).toHaveBeenCalledWith(SERVER_ID, 'Artist A');
|
||||
});
|
||||
|
||||
it('skips similar when artistId is missing', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
||||
|
||||
await buildInfiniteQueueCandidates(seed({ artistId: undefined }), new Set(), 5);
|
||||
await buildCandidates(seed({ artistId: undefined }));
|
||||
|
||||
expect(getSimilarSongs2).not.toHaveBeenCalled();
|
||||
expect(getTopSongs).toHaveBeenCalledWith('Artist A');
|
||||
expect(getSimilarSongs2ForServer).not.toHaveBeenCalled();
|
||||
expect(getTopSongsForServer).toHaveBeenCalledWith(SERVER_ID, 'Artist A');
|
||||
});
|
||||
|
||||
it('skips top when artist name is missing', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
|
||||
await buildInfiniteQueueCandidates(seed({ artist: '' }), new Set(), 5);
|
||||
await buildCandidates(seed({ artist: '' }));
|
||||
|
||||
expect(getSimilarSongs2).toHaveBeenCalledWith('ar-A');
|
||||
expect(getTopSongs).not.toHaveBeenCalled();
|
||||
expect(getSimilarSongs2ForServer).toHaveBeenCalledWith(SERVER_ID, 'ar-A');
|
||||
expect(getTopSongsForServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips both when seedTrack is null', async () => {
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
await buildInfiniteQueueCandidates(null, new Set(), 5);
|
||||
await buildCandidates(null);
|
||||
|
||||
expect(getSimilarSongs2).not.toHaveBeenCalled();
|
||||
expect(getTopSongs).not.toHaveBeenCalled();
|
||||
expect(getSimilarSongs2ForServer).not.toHaveBeenCalled();
|
||||
expect(getTopSongsForServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks every returned candidate with autoAdded=true', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([
|
||||
makeSubsonicSong({ id: 'sim-1' }),
|
||||
makeSubsonicSong({ id: 'sim-2' }),
|
||||
]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
||||
const out = await buildCandidates(seed());
|
||||
|
||||
expect(out.length).toBeGreaterThan(0);
|
||||
for (const t of out) expect(t.autoAdded).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes the seedTrack id and existingIds from the result', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([
|
||||
makeSubsonicSong({ id: 'seed' }), // self → excluded
|
||||
makeSubsonicSong({ id: 'already-in-queue' }), // in existingIds → excluded
|
||||
makeSubsonicSong({ id: 'fresh-1' }),
|
||||
]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
const out = await buildInfiniteQueueCandidates(seed(), new Set(['already-in-queue']), 5);
|
||||
const out = await buildCandidates(seed(), ['already-in-queue']);
|
||||
|
||||
const ids = out.map(t => t.id);
|
||||
expect(ids).toContain('fresh-1');
|
||||
@@ -143,81 +161,81 @@ describe('buildInfiniteQueueCandidates', () => {
|
||||
});
|
||||
|
||||
it('falls back to getRandomSongs when artist sources are empty', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([
|
||||
makeSubsonicSong({ id: 'rnd-1' }),
|
||||
makeSubsonicSong({ id: 'rnd-2' }),
|
||||
makeSubsonicSong({ id: 'rnd-3' }),
|
||||
]);
|
||||
|
||||
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 3);
|
||||
const out = await buildCandidates(seed(), [], 3);
|
||||
|
||||
expect(getRandomSongs).toHaveBeenCalled();
|
||||
expect(getRandomSongsForServer).toHaveBeenCalled();
|
||||
expect(out.map(t => t.id).sort()).toEqual(['rnd-1', 'rnd-2', 'rnd-3']);
|
||||
});
|
||||
|
||||
it('passes the seed track genre to getRandomSongs', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([makeSubsonicSong({ id: 'rnd-1' })]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([makeSubsonicSong({ id: 'rnd-1' })]);
|
||||
|
||||
await buildInfiniteQueueCandidates(seed({ genre: 'Jazz' }), new Set(), 1);
|
||||
await buildCandidates(seed({ genre: 'Jazz' }), [], 1);
|
||||
|
||||
expect(getRandomSongs).toHaveBeenCalledWith(expect.any(Number), 'Jazz');
|
||||
expect(getRandomSongsForServer).toHaveBeenCalledWith(SERVER_ID, expect.any(Number), 'Jazz');
|
||||
});
|
||||
|
||||
it('stops after up to 8 random batches when supply is exhausted', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
// Each batch returns one same song that's already counted → no progress.
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([makeSubsonicSong({ id: 'dup' })]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([makeSubsonicSong({ id: 'dup' })]);
|
||||
|
||||
await buildInfiniteQueueCandidates(seed(), new Set(['dup']), 5);
|
||||
await buildCandidates(seed(), ['dup']);
|
||||
|
||||
// Cap is 8 batches.
|
||||
expect(vi.mocked(getRandomSongs).mock.calls.length).toBeLessThanOrEqual(8);
|
||||
expect(vi.mocked(getRandomSongsForServer).mock.calls.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it('breaks the random loop early when getRandomSongs returns an empty batch', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
||||
await buildCandidates(seed());
|
||||
|
||||
// First batch is empty → loop breaks immediately, no second call.
|
||||
expect(vi.mocked(getRandomSongs).mock.calls.length).toBe(1);
|
||||
expect(vi.mocked(getRandomSongsForServer).mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('survives a rejected getSimilarSongs2 call (catches and treats as empty)', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockRejectedValue(new Error('boom'));
|
||||
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockRejectedValue(new Error('boom'));
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
||||
const out = await buildCandidates(seed());
|
||||
|
||||
expect(out.map(t => t.id)).toContain('top-1');
|
||||
});
|
||||
|
||||
it('survives a rejected getTopSongs call (catches and treats as empty)', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
||||
vi.mocked(getTopSongs).mockRejectedValue(new Error('boom'));
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
||||
vi.mocked(getTopSongsForServer).mockRejectedValue(new Error('boom'));
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
||||
const out = await buildCandidates(seed());
|
||||
|
||||
expect(out.map(t => t.id)).toContain('sim-1');
|
||||
});
|
||||
|
||||
it('returns at most `count` items even when sources oversupply', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue(
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue(
|
||||
Array.from({ length: 10 }, (_, i) => makeSubsonicSong({ id: `sim-${i}` })),
|
||||
);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 3);
|
||||
const out = await buildCandidates(seed(), [], 3);
|
||||
|
||||
expect(out).toHaveLength(3);
|
||||
});
|
||||
@@ -232,22 +250,44 @@ describe('buildInfiniteQueueCandidates', () => {
|
||||
vi.mocked(enrichSongsForMixRatingFilter).mockResolvedValue([
|
||||
makeSubsonicSong({ id: 'sim-1' }),
|
||||
]);
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
||||
await buildCandidates(seed());
|
||||
|
||||
expect(enrichSongsForMixRatingFilter).toHaveBeenCalled();
|
||||
expect(enrichSongsForMixRatingFilter).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
SERVER_ID,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty array when nothing usable is found', async () => {
|
||||
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongs).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([]);
|
||||
vi.mocked(getTopSongsForServer).mockResolvedValue([]);
|
||||
vi.mocked(getRandomSongsForServer).mockResolvedValue([]);
|
||||
|
||||
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
||||
const out = await buildCandidates(seed());
|
||||
|
||||
expect(out).toEqual([]);
|
||||
});
|
||||
|
||||
it('stamps candidates with the requested owner and ignores same ids from other owners', async () => {
|
||||
vi.mocked(getSimilarSongs2ForServer).mockResolvedValue([
|
||||
makeSubsonicSong({ id: 'shared', serverId: undefined }),
|
||||
]);
|
||||
|
||||
const otherOwnerIdentity = queueTrackIdentityKey('shared', 'server-b');
|
||||
const out = await buildInfiniteQueueCandidates(
|
||||
seed(),
|
||||
SERVER_ID,
|
||||
new Set([otherOwnerIdentity]),
|
||||
1,
|
||||
);
|
||||
|
||||
expect(out).toEqual([
|
||||
expect.objectContaining({ id: 'shared', serverId: SERVER_ID, autoAdded: true }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import { getRandomSongs } from '@/lib/api/subsonicLibrary';
|
||||
import {
|
||||
getSimilarSongs2ForServer,
|
||||
getTopSongsForServer,
|
||||
} from '@/lib/api/subsonicArtists';
|
||||
import { getRandomSongsForServer } from '@/lib/api/subsonicLibrary';
|
||||
import type { Track } from '@/lib/media/trackTypes';
|
||||
import {
|
||||
enrichSongsForMixRatingFilter,
|
||||
@@ -8,6 +11,7 @@ import {
|
||||
} from '@/features/playback/utils/mixRatingFilter';
|
||||
import { shuffleArray } from '@/lib/util/shuffleArray';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
import { queueTrackIdentityKey } from '@/features/playback/utils/playback/queueIdentity';
|
||||
/**
|
||||
* Infinite queue source strategy (Instant Mix-like):
|
||||
* 1) Prefer artist-driven candidates (Top + Similar) around the current track.
|
||||
@@ -15,44 +19,58 @@ import { songToTrack } from '@/lib/media/songToTrack';
|
||||
*/
|
||||
export async function buildInfiniteQueueCandidates(
|
||||
seedTrack: Track | null,
|
||||
existingIds: Set<string>,
|
||||
serverId: string,
|
||||
existingIdentities: Set<string>,
|
||||
count = 5,
|
||||
): Promise<Track[]> {
|
||||
if (!serverId) return [];
|
||||
const RANDOM_TOPUP_BATCH_SIZE = Math.max(10, count * 2);
|
||||
const RANDOM_TOPUP_MAX_BATCHES = 8;
|
||||
const artistId = seedTrack?.artistId?.trim() || null;
|
||||
const artistName = seedTrack?.artist?.trim() || null;
|
||||
|
||||
const [similar, top] = await Promise.all([
|
||||
artistId ? getSimilarSongs2(artistId).catch(() => []) : Promise.resolve([]),
|
||||
artistName ? getTopSongs(artistName).catch(() => []) : Promise.resolve([]),
|
||||
artistId ? getSimilarSongs2ForServer(serverId, artistId).catch(() => []) : Promise.resolve([]),
|
||||
artistName ? getTopSongsForServer(serverId, artistName).catch(() => []) : Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const seedId = seedTrack?.id ?? null;
|
||||
const mixCfg = getMixMinRatingsConfigFromAuth();
|
||||
const mixedSources = [...top, ...similar];
|
||||
const filteredMixedSongs = mixCfg.enabled
|
||||
? (await enrichSongsForMixRatingFilter(mixedSources, mixCfg)).filter(s => passesMixMinRatings(s, mixCfg))
|
||||
? (await enrichSongsForMixRatingFilter(mixedSources, mixCfg, serverId)).filter(s => passesMixMinRatings(s, mixCfg))
|
||||
: mixedSources;
|
||||
const out: Track[] = shuffleArray(
|
||||
filteredMixedSongs
|
||||
.map(songToTrack)
|
||||
.filter(t => t.id !== seedId && !existingIds.has(t.id)),
|
||||
.map(song => ({ ...songToTrack(song), serverId }))
|
||||
.filter(t => (
|
||||
t.id !== seedId
|
||||
&& !existingIdentities.has(queueTrackIdentityKey(t.id, serverId))
|
||||
)),
|
||||
)
|
||||
.slice(0, count)
|
||||
.map(t => ({ ...t, autoAdded: true as const }));
|
||||
|
||||
const seenIds = new Set<string>([...existingIds, ...out.map(t => t.id)]);
|
||||
const seenIdentities = new Set<string>([
|
||||
...existingIdentities,
|
||||
...out.map(t => queueTrackIdentityKey(t.id, serverId)),
|
||||
]);
|
||||
for (let b = 0; out.length < count && b < RANDOM_TOPUP_MAX_BATCHES; b++) {
|
||||
const random = await getRandomSongs(RANDOM_TOPUP_BATCH_SIZE, seedTrack?.genre).catch(() => []);
|
||||
const random = await getRandomSongsForServer(
|
||||
serverId,
|
||||
RANDOM_TOPUP_BATCH_SIZE,
|
||||
seedTrack?.genre,
|
||||
).catch(() => []);
|
||||
if (!random.length) break;
|
||||
const filteredRandomSongs = mixCfg.enabled
|
||||
? (await enrichSongsForMixRatingFilter(random, mixCfg)).filter(s => passesMixMinRatings(s, mixCfg))
|
||||
? (await enrichSongsForMixRatingFilter(random, mixCfg, serverId)).filter(s => passesMixMinRatings(s, mixCfg))
|
||||
: random;
|
||||
for (const track of shuffleArray(filteredRandomSongs.map(songToTrack))) {
|
||||
if (track.id === seedId || seenIds.has(track.id)) continue;
|
||||
for (const rawTrack of shuffleArray(filteredRandomSongs.map(songToTrack))) {
|
||||
const track = { ...rawTrack, serverId };
|
||||
const identity = queueTrackIdentityKey(track.id, serverId);
|
||||
if (track.id === seedId || seenIdentities.has(identity)) continue;
|
||||
out.push({ ...track, autoAdded: true as const });
|
||||
seenIds.add(track.id);
|
||||
seenIdentities.add(identity);
|
||||
if (out.length >= count) break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,13 @@ const {
|
||||
librarySelectionMock,
|
||||
uploadArtistImageMock,
|
||||
findServerMock,
|
||||
similarSongsRequestCountMock,
|
||||
} = vi.hoisted(() => ({
|
||||
apiForServerMock: vi.fn(),
|
||||
librarySelectionMock: vi.fn<() => string[]>(() => []),
|
||||
uploadArtistImageMock: vi.fn(),
|
||||
findServerMock: vi.fn(),
|
||||
similarSongsRequestCountMock: vi.fn((count: number) => count),
|
||||
}));
|
||||
|
||||
vi.mock('@/generated/bindings', () => ({
|
||||
@@ -35,13 +37,14 @@ vi.mock('@/lib/api/subsonicClient', () => ({
|
||||
vi.mock('@/lib/api/subsonicLibrary', () => ({
|
||||
filterSongsToActiveLibrary: async (songs: unknown[]) => songs,
|
||||
filterSongsToServerLibrary: async (songs: unknown[]) => songs,
|
||||
similarSongsRequestCount: (count: number) => count,
|
||||
similarSongsRequestCount: similarSongsRequestCountMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getArtistForServer,
|
||||
getArtistInfoForServer,
|
||||
getArtistsForServer,
|
||||
getSimilarSongsForServer,
|
||||
getSimilarSongs2ForServer,
|
||||
getTopSongsForServer,
|
||||
uploadArtistImageForServer,
|
||||
@@ -57,6 +60,7 @@ describe('explicit-server artist wrappers', () => {
|
||||
librarySelectionMock.mockReturnValue([]);
|
||||
uploadArtistImageMock.mockReset();
|
||||
findServerMock.mockReset();
|
||||
similarSongsRequestCountMock.mockClear();
|
||||
});
|
||||
|
||||
it('preserves multi-folder fan-out, timeout, deduplication, and stamping', async () => {
|
||||
@@ -149,6 +153,23 @@ describe('explicit-server artist wrappers', () => {
|
||||
'getSimilarSongs2.view',
|
||||
expect.objectContaining({ id: 'seed', count: 12 }),
|
||||
);
|
||||
expect(similarSongsRequestCountMock).toHaveBeenCalledWith(12, 'srv-similar');
|
||||
});
|
||||
|
||||
it('routes legacy similar songs through the explicit owner scope', async () => {
|
||||
apiForServerMock.mockResolvedValue({
|
||||
similarSongs: { song: { id: 'similar-legacy', title: 'Similar' } },
|
||||
});
|
||||
|
||||
await expect(getSimilarSongsForServer('srv-legacy', 'seed', 7)).resolves.toEqual([
|
||||
{ id: 'similar-legacy', title: 'Similar', serverId: 'srv-legacy' },
|
||||
]);
|
||||
expect(similarSongsRequestCountMock).toHaveBeenCalledWith(7, 'srv-legacy');
|
||||
expect(apiForServerMock).toHaveBeenCalledWith(
|
||||
'srv-legacy',
|
||||
'getSimilarSongs.view',
|
||||
expect.objectContaining({ id: 'seed', count: 7 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads an artist image with the explicit server credentials', async () => {
|
||||
|
||||
@@ -213,7 +213,7 @@ export async function getSimilarSongs2ForServer(
|
||||
count = 50,
|
||||
): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const requestCount = similarSongsRequestCount(count, serverId);
|
||||
const data = await apiForServer<{ similarSongs2: { song: SubsonicSong[] } }>(
|
||||
serverId,
|
||||
'getSimilarSongs2.view',
|
||||
@@ -240,7 +240,7 @@ export async function getSimilarSongsForServer(
|
||||
count = 50,
|
||||
): Promise<SubsonicSong[]> {
|
||||
try {
|
||||
const requestCount = similarSongsRequestCount(count);
|
||||
const requestCount = similarSongsRequestCount(count, serverId);
|
||||
const data = await apiForServer<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
|
||||
serverId,
|
||||
'getSimilarSongs.view',
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { apiForServerMock, guardMock } = vi.hoisted(() => ({
|
||||
const { apiForServerMock, authState, guardMock } = vi.hoisted(() => ({
|
||||
apiForServerMock: vi.fn(),
|
||||
authState: {
|
||||
activeServerId: 'active',
|
||||
musicLibraryFilterByServer: {} as Record<string, string>,
|
||||
musicLibraryFilterVersion: 1,
|
||||
},
|
||||
guardMock: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/authStore', () => ({
|
||||
useAuthStore: {
|
||||
getState: () => ({
|
||||
activeServerId: 'active',
|
||||
musicLibraryFilterByServer: {},
|
||||
musicLibraryFilterVersion: 1,
|
||||
}),
|
||||
getState: () => authState,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
getAlbumListForServer,
|
||||
getRandomSongsForServer,
|
||||
getSongForServer,
|
||||
similarSongsRequestCount,
|
||||
} from '@/lib/api/subsonicLibrary';
|
||||
|
||||
const album = { id: 'album-1', name: 'Album', artist: 'Artist', artistId: 'artist-1', songCount: 1, duration: 30 };
|
||||
@@ -51,6 +53,8 @@ describe('explicit-server library wrappers', () => {
|
||||
apiForServerMock.mockReset();
|
||||
guardMock.mockReset();
|
||||
guardMock.mockReturnValue(true);
|
||||
authState.activeServerId = 'active';
|
||||
authState.musicLibraryFilterByServer = {};
|
||||
});
|
||||
|
||||
it('forwards album-list timeout and stamps albums', async () => {
|
||||
@@ -95,6 +99,13 @@ describe('explicit-server library wrappers', () => {
|
||||
expect(apiForServerMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sizes similar-song requests from the explicit owner library scope', () => {
|
||||
authState.musicLibraryFilterByServer = { active: 'all', 'srv-owner': 'folder-2' };
|
||||
|
||||
expect(similarSongsRequestCount(12, 'srv-owner')).toBe(48);
|
||||
expect(similarSongsRequestCount(12, 'active')).toBe(12);
|
||||
});
|
||||
|
||||
it('stamps explicit album details and song lookups', async () => {
|
||||
apiForServerMock
|
||||
.mockResolvedValueOnce({ album: { ...album, song: [song] } })
|
||||
|
||||
@@ -204,12 +204,13 @@ export async function filterAlbumsToActiveLibrary(albums: SubsonicAlbum[]): Prom
|
||||
}
|
||||
|
||||
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
|
||||
export function similarSongsRequestCount(desired: number): number {
|
||||
export function similarSongsRequestCount(desired: number, serverId?: string): number {
|
||||
if (getLuckyMixLibraryScopeOverride()) {
|
||||
return Math.min(300, Math.max(desired, desired * 4));
|
||||
}
|
||||
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
|
||||
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
|
||||
const ownerServerId = serverId ?? activeServerId;
|
||||
const f = ownerServerId ? musicLibraryFilterByServer[ownerServerId] : undefined;
|
||||
if (f === undefined || f === 'all') return desired;
|
||||
return Math.min(300, Math.max(desired, desired * 4));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user