mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
006635de4b
Three domain-eng modules peel ~316 LOC of fetch/mapping out of `api/subsonic.ts`: - `subsonicLibrary.ts` — browse + random + per-song fetch (`getMusicDirectory`, `getMusicIndexes`, `getMusicFolders`, `getRandomAlbums`, `getAlbumList`, `getRandomSongs`, `getRandomSongsFiltered`, `getSong`, `getAlbum`, `filterSongsToActiveLibrary`, `similarSongsRequestCount`, plus the private `albumIdsInActiveLibraryScope` cache). - `subsonicArtists.ts` — artist endpoints (`getArtists`, `getArtist`, `getArtistInfo`, `getTopSongs`, `getSimilarSongs2`, `getSimilarSongs`). Uses Library's `filterSongsToActiveLibrary` and `similarSongsRequestCount` for the per-library scoping fallback. - `subsonicRatings.ts` — `parseSubsonicEntityStarRating` parser plus `prefetchArtistUserRatings` and `prefetchAlbumUserRatings` workers with the shared 7-min cache. Calls back into Library/Artists for the per-id fetch. 51 external call sites migrated to direct imports. No re-export shims in `subsonic.ts`. Statistics endpoints still in `subsonic.ts` keep their `RATING_CACHE_TTL` constant locally (same 7-min window). subsonic.ts: 1078 → 762 LOC (−316).
251 lines
9.2 KiB
TypeScript
251 lines
9.2 KiB
TypeScript
/**
|
|
* Characterization for `buildInfiniteQueueCandidates` (Instant-Mix-style
|
|
* top-up source for the infinite queue).
|
|
*
|
|
* Originally lived in `playerStore.ts`; extracted in M0 of the frontend
|
|
* 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 '../api/subsonicArtists';
|
|
import { getRandomSongs } from '../api/subsonicLibrary';
|
|
import type { Track } from '../store/playerStoreTypes';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../api/subsonicArtists', () => ({
|
|
getSimilarSongs2: vi.fn(),
|
|
getTopSongs: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../api/subsonicLibrary', () => ({
|
|
getRandomSongs: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('./mixRatingFilter', () => ({
|
|
getMixMinRatingsConfigFromAuth: vi.fn(),
|
|
enrichSongsForMixRatingFilter: vi.fn(),
|
|
passesMixMinRatings: vi.fn(),
|
|
}));
|
|
|
|
import { buildInfiniteQueueCandidates } from './buildInfiniteQueueCandidates';
|
|
import {
|
|
enrichSongsForMixRatingFilter,
|
|
getMixMinRatingsConfigFromAuth,
|
|
} from './mixRatingFilter';
|
|
import { makeSubsonicSong } from '@/test/helpers/factories';
|
|
|
|
const seed = (overrides: Partial<Track> = {}): Track => ({
|
|
id: 'seed',
|
|
title: 'Seed',
|
|
artist: 'Artist A',
|
|
album: 'Album A',
|
|
albumId: 'al-A',
|
|
artistId: 'ar-A',
|
|
duration: 180,
|
|
genre: 'Rock',
|
|
...overrides,
|
|
});
|
|
|
|
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([]);
|
|
// Default: filter disabled — the function then short-circuits the enrich path.
|
|
vi.mocked(getMixMinRatingsConfigFromAuth).mockReturnValue({
|
|
enabled: false,
|
|
minSong: 0,
|
|
minAlbum: 0,
|
|
minArtist: 0,
|
|
});
|
|
// Deterministic shuffle: Math.random()=0 collapses Fisher-Yates to a known order.
|
|
vi.spyOn(Math, 'random').mockReturnValue(0);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
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' })]);
|
|
|
|
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
|
|
|
expect(getSimilarSongs2).toHaveBeenCalledWith('ar-A');
|
|
expect(getTopSongs).toHaveBeenCalledWith('Artist A');
|
|
});
|
|
|
|
it('skips similar when artistId is missing', async () => {
|
|
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
|
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
|
|
|
await buildInfiniteQueueCandidates(seed({ artistId: undefined }), new Set(), 5);
|
|
|
|
expect(getSimilarSongs2).not.toHaveBeenCalled();
|
|
expect(getTopSongs).toHaveBeenCalledWith('Artist A');
|
|
});
|
|
|
|
it('skips top when artist name is missing', async () => {
|
|
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
|
vi.mocked(getTopSongs).mockResolvedValue([]);
|
|
|
|
await buildInfiniteQueueCandidates(seed({ artist: '' }), new Set(), 5);
|
|
|
|
expect(getSimilarSongs2).toHaveBeenCalledWith('ar-A');
|
|
expect(getTopSongs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips both when seedTrack is null', async () => {
|
|
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
|
|
|
await buildInfiniteQueueCandidates(null, new Set(), 5);
|
|
|
|
expect(getSimilarSongs2).not.toHaveBeenCalled();
|
|
expect(getTopSongs).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('marks every returned candidate with autoAdded=true', async () => {
|
|
vi.mocked(getSimilarSongs2).mockResolvedValue([
|
|
makeSubsonicSong({ id: 'sim-1' }),
|
|
makeSubsonicSong({ id: 'sim-2' }),
|
|
]);
|
|
vi.mocked(getTopSongs).mockResolvedValue([makeSubsonicSong({ id: 'top-1' })]);
|
|
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
|
|
|
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
|
|
|
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([
|
|
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([]);
|
|
|
|
const out = await buildInfiniteQueueCandidates(seed(), new Set(['already-in-queue']), 5);
|
|
|
|
const ids = out.map(t => t.id);
|
|
expect(ids).toContain('fresh-1');
|
|
expect(ids).not.toContain('seed');
|
|
expect(ids).not.toContain('already-in-queue');
|
|
});
|
|
|
|
it('falls back to getRandomSongs when artist sources are empty', async () => {
|
|
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
|
vi.mocked(getTopSongs).mockResolvedValue([]);
|
|
vi.mocked(getRandomSongs).mockResolvedValue([
|
|
makeSubsonicSong({ id: 'rnd-1' }),
|
|
makeSubsonicSong({ id: 'rnd-2' }),
|
|
makeSubsonicSong({ id: 'rnd-3' }),
|
|
]);
|
|
|
|
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 3);
|
|
|
|
expect(getRandomSongs).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' })]);
|
|
|
|
await buildInfiniteQueueCandidates(seed({ genre: 'Jazz' }), new Set(), 1);
|
|
|
|
expect(getRandomSongs).toHaveBeenCalledWith(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([]);
|
|
// Each batch returns one same song that's already counted → no progress.
|
|
vi.mocked(getRandomSongs).mockResolvedValue([makeSubsonicSong({ id: 'dup' })]);
|
|
|
|
await buildInfiniteQueueCandidates(seed(), new Set(['dup']), 5);
|
|
|
|
// Cap is 8 batches.
|
|
expect(vi.mocked(getRandomSongs).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([]);
|
|
|
|
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
|
|
|
// First batch is empty → loop breaks immediately, no second call.
|
|
expect(vi.mocked(getRandomSongs).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([]);
|
|
|
|
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
|
|
|
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([]);
|
|
|
|
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
|
|
|
expect(out.map(t => t.id)).toContain('sim-1');
|
|
});
|
|
|
|
it('returns at most `count` items even when sources oversupply', async () => {
|
|
vi.mocked(getSimilarSongs2).mockResolvedValue(
|
|
Array.from({ length: 10 }, (_, i) => makeSubsonicSong({ id: `sim-${i}` })),
|
|
);
|
|
vi.mocked(getTopSongs).mockResolvedValue([]);
|
|
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
|
|
|
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 3);
|
|
|
|
expect(out).toHaveLength(3);
|
|
});
|
|
|
|
it('runs the rating-filter enrichment pipeline when filter is enabled', async () => {
|
|
vi.mocked(getMixMinRatingsConfigFromAuth).mockReturnValue({
|
|
enabled: true,
|
|
minSong: 3,
|
|
minAlbum: 0,
|
|
minArtist: 0,
|
|
});
|
|
vi.mocked(enrichSongsForMixRatingFilter).mockResolvedValue([
|
|
makeSubsonicSong({ id: 'sim-1' }),
|
|
]);
|
|
vi.mocked(getSimilarSongs2).mockResolvedValue([makeSubsonicSong({ id: 'sim-1' })]);
|
|
vi.mocked(getTopSongs).mockResolvedValue([]);
|
|
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
|
|
|
await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
|
|
|
expect(enrichSongsForMixRatingFilter).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns an empty array when nothing usable is found', async () => {
|
|
vi.mocked(getSimilarSongs2).mockResolvedValue([]);
|
|
vi.mocked(getTopSongs).mockResolvedValue([]);
|
|
vi.mocked(getRandomSongs).mockResolvedValue([]);
|
|
|
|
const out = await buildInfiniteQueueCandidates(seed(), new Set(), 5);
|
|
|
|
expect(out).toEqual([]);
|
|
});
|
|
});
|