From 9029ab8ec5473439143492155285f36a0bce0179 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 12 May 2026 15:02:46 +0200 Subject: [PATCH] =?UTF-8?q?refactor(player):=20E.17=20=E2=80=94=20extract?= =?UTF-8?q?=20stream-cache-to-hot-cache=20promoter=20(#580)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- src/store/playerStore.ts | 20 +------ src/store/promoteStreamCache.test.ts | 89 ++++++++++++++++++++++++++++ src/store/promoteStreamCache.ts | 39 ++++++++++++ 3 files changed, 129 insertions(+), 19 deletions(-) create mode 100644 src/store/promoteStreamCache.test.ts create mode 100644 src/store/promoteStreamCache.ts diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index f99edf6b..bc2686ed 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -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) { diff --git a/src/store/promoteStreamCache.test.ts b/src/store/promoteStreamCache.test.ts new file mode 100644 index 00000000..814456db --- /dev/null +++ b/src/store/promoteStreamCache.test.ts @@ -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) => 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 { + 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(); + }); +}); diff --git a/src/store/promoteStreamCache.ts b/src/store/promoteStreamCache.ts new file mode 100644 index 00000000..88b2adc9 --- /dev/null +++ b/src/store/promoteStreamCache.ts @@ -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 { + 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 + } +}