mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
45e0e1206f
* fix(playback): pin queue streams, cover art, and library links to queue server When the active server changes while a queue from another server is playing, keep streams and UI on queueServerId; switch back for artist/album links and queue or player-bar context menus. * fix(playback): switch to queue server when opening Now Playing Ensure active server matches queueServerId before Subsonic fetches on the Now Playing page, mobile player route, and queue info panel; scope caches by server id. * docs(credits): mention Now Playing in PR #717 contribution line * fix(playback): route scrobble and queue sync to queue server Address PR review: apiForServer for scrobble/now-playing/savePlayQueue, clear queueServerId on server removal, mini-player queueServerId sync, block cross-server enqueue with toast, and regression tests.
92 lines
3.8 KiB
TypeScript
92 lines
3.8 KiB
TypeScript
/**
|
|
* Promote-stream-cache helper: wraps a single Rust IPC and forwards the
|
|
* result into the hot-cache store index. Tests pin the payload shape, the
|
|
* suffix fallback, the null-result skip, and the swallow-on-error
|
|
* contract.
|
|
*/
|
|
import type { Track } from './playerStoreTypes';
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
const { invokeMock, setEntryMock, buildStreamUrlMock } = vi.hoisted(() => ({
|
|
invokeMock: vi.fn(async (_cmd: string, _args?: Record<string, unknown>) => null as { path: string; size: number } | null),
|
|
setEntryMock: vi.fn(),
|
|
buildStreamUrlMock: vi.fn((id: string) => `https://mock/stream/${id}`),
|
|
}));
|
|
|
|
vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock }));
|
|
vi.mock('../api/subsonicStreamUrl', () => ({
|
|
buildStreamUrl: buildStreamUrlMock,
|
|
buildStreamUrlForServer: (_serverId: string, id: string) => buildStreamUrlMock(id),
|
|
}));
|
|
vi.mock('./hotCacheStore', () => ({
|
|
useHotCacheStore: { getState: () => ({ setEntry: setEntryMock }) },
|
|
}));
|
|
|
|
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
|
|
|
function track(id: string, overrides: Partial<Track> = {}): Track {
|
|
return { id, title: id, artist: 'A', album: 'X', albumId: 'X', duration: 100, ...overrides };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
invokeMock.mockReset();
|
|
invokeMock.mockResolvedValue(null);
|
|
setEntryMock.mockReset();
|
|
buildStreamUrlMock.mockClear();
|
|
});
|
|
|
|
describe('promoteCompletedStreamToHotCache', () => {
|
|
it('forwards a complete payload to the Rust command', async () => {
|
|
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 1234 });
|
|
await promoteCompletedStreamToHotCache(track('t1', { suffix: 'flac' }), 'srv', '/hot');
|
|
expect(invokeMock).toHaveBeenCalledWith('promote_stream_cache_to_hot_cache', {
|
|
trackId: 't1',
|
|
serverId: 'srv',
|
|
url: 'https://mock/stream/t1',
|
|
suffix: 'flac',
|
|
customDir: '/hot',
|
|
});
|
|
});
|
|
|
|
it("falls back to suffix='mp3' when the track has no suffix", async () => {
|
|
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 100 });
|
|
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
|
|
expect(invokeMock.mock.calls[0][1]?.suffix).toBe('mp3');
|
|
});
|
|
|
|
it('passes through customDir=null when the user has no hot-cache dir set', async () => {
|
|
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 100 });
|
|
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
|
|
expect(invokeMock.mock.calls[0][1]?.customDir).toBeNull();
|
|
});
|
|
|
|
it('records the entry in the hot-cache store on a successful path', async () => {
|
|
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 5678 });
|
|
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
|
|
expect(setEntryMock).toHaveBeenCalledWith('t1', 'srv', '/cache/t1.mp3', 5678, 'stream-promote');
|
|
});
|
|
|
|
it('defaults size to 0 when Rust omits it', async () => {
|
|
invokeMock.mockResolvedValueOnce({ path: '/cache/t1.mp3', size: 0 });
|
|
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
|
|
expect(setEntryMock.mock.calls[0][3]).toBe(0);
|
|
});
|
|
|
|
it('skips the hot-cache write when Rust returns null', async () => {
|
|
invokeMock.mockResolvedValueOnce(null);
|
|
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
|
|
expect(setEntryMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('skips the hot-cache write when path is empty', async () => {
|
|
invokeMock.mockResolvedValueOnce({ path: '', size: 100 });
|
|
await promoteCompletedStreamToHotCache(track('t1'), 'srv', null);
|
|
expect(setEntryMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('swallows Rust errors silently', async () => {
|
|
invokeMock.mockRejectedValueOnce(new Error('boom'));
|
|
await expect(promoteCompletedStreamToHotCache(track('t1'), 'srv', null)).resolves.toBeUndefined();
|
|
expect(setEntryMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|