mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(player): E.17 — extract stream-cache-to-hot-cache promoter (#580)
`promoteCompletedStreamToHotCache` — wraps the `promote_stream_cache_to_hot_cache` Rust IPC, forwards the resolved path + size into `useHotCacheStore` as a `'stream-promote'` entry — moves into `src/store/promoteStreamCache.ts`. File-private with four call sites; no caller-side changes outside playerStore's own import. 9 focused tests pin the payload shape (incl. the `'mp3'` suffix fallback and the null customDir pass-through), the success path that records the entry, the early-returns for null / empty path, the `size || 0` fallback, and the silent error swallow. playerStore 3207 → 3189 LOC.
This commit is contained in:
committed by
GitHub
parent
3a99be4daa
commit
9029ab8ec5
@@ -104,6 +104,7 @@ import {
|
||||
setBytePreloadingId,
|
||||
setGaplessPreloadingId,
|
||||
} from './gaplessPreloadState';
|
||||
import { promoteCompletedStreamToHotCache } from './promoteStreamCache';
|
||||
|
||||
// Re-export so TauriEventBridge + persistence test keep their existing
|
||||
// `from './playerStore'` imports.
|
||||
@@ -807,25 +808,6 @@ function prefetchLoudnessForEnqueuedTracks(
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteCompletedStreamToHotCache(track: Track, serverId: string, customDir: string | null) {
|
||||
try {
|
||||
const res = await invoke<{ path: string; size: number } | null>(
|
||||
'promote_stream_cache_to_hot_cache',
|
||||
{
|
||||
trackId: track.id,
|
||||
serverId,
|
||||
url: buildStreamUrl(track.id),
|
||||
suffix: track.suffix || 'mp3',
|
||||
customDir,
|
||||
},
|
||||
);
|
||||
if (!res || !res.path) return;
|
||||
useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0, 'stream-promote');
|
||||
} catch {
|
||||
// best-effort promotion; normal hot-cache prefetch remains fallback
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Audio event handlers (called from initAudioListeners) ───────────────────
|
||||
|
||||
function handleAudioPlaying(_duration: number) {
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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 { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { Track } from './playerStore';
|
||||
|
||||
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/subsonic', () => ({ buildStreamUrl: buildStreamUrlMock }));
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { buildStreamUrl } from '../api/subsonic';
|
||||
import { useHotCacheStore } from './hotCacheStore';
|
||||
import type { Track } from './playerStore';
|
||||
|
||||
/**
|
||||
* Promote a track whose stream cache is full to the on-disk hot cache.
|
||||
* Rust copies the cached bytes into the hot-cache directory and returns
|
||||
* the resolved path + size; the JS-side `useHotCacheStore` index gets the
|
||||
* entry tagged `'stream-promote'` so the LRU treats it the same as a
|
||||
* prefetch hit.
|
||||
*
|
||||
* Best-effort: any failure is swallowed because the regular hot-cache
|
||||
* prefetch path remains a fallback. `customDir` may be null when the user
|
||||
* hasn't picked a hot-cache directory yet — Rust then writes to the
|
||||
* default location.
|
||||
*/
|
||||
export async function promoteCompletedStreamToHotCache(
|
||||
track: Track,
|
||||
serverId: string,
|
||||
customDir: string | null,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const res = await invoke<{ path: string; size: number } | null>(
|
||||
'promote_stream_cache_to_hot_cache',
|
||||
{
|
||||
trackId: track.id,
|
||||
serverId,
|
||||
url: buildStreamUrl(track.id),
|
||||
suffix: track.suffix || 'mp3',
|
||||
customDir,
|
||||
},
|
||||
);
|
||||
if (!res || !res.path) return;
|
||||
useHotCacheStore.getState().setEntry(track.id, serverId, res.path, res.size || 0, 'stream-promote');
|
||||
} catch {
|
||||
// best-effort promotion; normal hot-cache prefetch remains fallback
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user