fix(playback): preserve queue top-up ownership

This commit is contained in:
cucadmuh
2026-07-19 05:35:51 +03:00
parent 0a41b64adb
commit 32a777055d
17 changed files with 495 additions and 210 deletions
+1 -1
View File
@@ -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);
+99 -51
View File
@@ -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', () => {
+22 -10
View File
@@ -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;
}
}
+22 -1
View File
@@ -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 () => {
+2 -2
View File
@@ -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',
+17 -6
View File
@@ -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] } })
+3 -2
View File
@@ -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));
}