diff --git a/src/store/playbackUrlRouting.test.ts b/src/store/playbackUrlRouting.test.ts new file mode 100644 index 00000000..aebf2f5d --- /dev/null +++ b/src/store/playbackUrlRouting.test.ts @@ -0,0 +1,111 @@ +/** + * Playback-URL routing characterization. The non-trivial behaviour is + * (a) the module-scoped `lastOpenedWithHttpTrackId` only persists for HTTP + * URLs (offline / hot-cache plays clear it), (b) the rebind check matches + * across the `stream:` / bare id forms, and (c) the source-kind classifier + * picks 'offline' vs 'hot' only when the offline store actually has a local + * URL for the track. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { offlineStoreState } = vi.hoisted(() => ({ + offlineStoreState: { localUrlByKey: new Map() }, +})); + +vi.mock('./offlineStore', () => ({ + useOfflineStore: { + getState: () => ({ + getLocalUrl: (trackId: string, serverId: string) => + offlineStoreState.localUrlByKey.get(`${serverId}:${trackId}`) ?? null, + }), + }, +})); + +vi.mock('../utils/resolvePlaybackUrl', () => ({ + resolvePlaybackUrl: vi.fn((trackId: string, serverId: string) => { + if (offlineStoreState.localUrlByKey.has(`${serverId}:${trackId}`)) { + return `psysonic-local://${serverId}/${trackId}`; + } + return `https://mock/${serverId}/${trackId}`; + }), +})); + +import { + _resetPlaybackUrlRoutingForTest, + playbackSourceHintForResolvedUrl, + recordEnginePlayUrl, + shouldRebindPlaybackToHotCache, +} from './playbackUrlRouting'; + +beforeEach(() => { + offlineStoreState.localUrlByKey.clear(); +}); + +afterEach(() => { + _resetPlaybackUrlRoutingForTest(); +}); + +describe('playbackSourceHintForResolvedUrl', () => { + it("classifies non-local URLs as 'stream'", () => { + expect(playbackSourceHintForResolvedUrl('t1', 'srv', 'https://mock/srv/t1')).toBe('stream'); + }); + + it("classifies psysonic-local:// as 'offline' when the offline store has the file", () => { + offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3'); + expect(playbackSourceHintForResolvedUrl('t1', 'srv', 'psysonic-local://srv/t1')).toBe('offline'); + }); + + it("classifies psysonic-local:// as 'hot' when no offline copy exists", () => { + expect(playbackSourceHintForResolvedUrl('t1', 'srv', 'psysonic-local://srv/t1')).toBe('hot'); + }); +}); + +describe('recordEnginePlayUrl + shouldRebindPlaybackToHotCache', () => { + it('records HTTP URLs and rebinds when the resolved URL later goes local', () => { + recordEnginePlayUrl('t1', 'https://mock/srv/t1'); + offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3'); + expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(true); + }); + + it('does not rebind when the recorded URL was already local', () => { + recordEnginePlayUrl('t1', 'psysonic-local://srv/t1'); + offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3'); + expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false); + }); + + it('matches the recorded id across the stream: prefix', () => { + recordEnginePlayUrl('stream:t1', 'https://mock/srv/stream:t1'); + offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3'); + expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(true); + }); + + it('returns false for a different track id', () => { + recordEnginePlayUrl('t1', 'https://mock/srv/t1'); + offlineStoreState.localUrlByKey.set('srv:t2', 'file:///cache/t2.mp3'); + expect(shouldRebindPlaybackToHotCache('t2', 'srv')).toBe(false); + }); + + it('returns false when serverId is empty', () => { + recordEnginePlayUrl('t1', 'https://mock/srv/t1'); + expect(shouldRebindPlaybackToHotCache('t1', '')).toBe(false); + }); + + it('returns false when nothing has been recorded yet', () => { + expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false); + }); + + it('returns false when the resolved URL is still HTTP (no hot-cache entry)', () => { + recordEnginePlayUrl('t1', 'https://mock/srv/t1'); + // No entry in offlineStoreState → resolvePlaybackUrl mock returns https://... + expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false); + }); +}); + +describe('_resetPlaybackUrlRoutingForTest', () => { + it('clears the recorded id', () => { + recordEnginePlayUrl('t1', 'https://mock/srv/t1'); + _resetPlaybackUrlRoutingForTest(); + offlineStoreState.localUrlByKey.set('srv:t1', 'file:///cache/t1.mp3'); + expect(shouldRebindPlaybackToHotCache('t1', 'srv')).toBe(false); + }); +}); diff --git a/src/store/playbackUrlRouting.ts b/src/store/playbackUrlRouting.ts new file mode 100644 index 00000000..d9faa615 --- /dev/null +++ b/src/store/playbackUrlRouting.ts @@ -0,0 +1,39 @@ +import { resolvePlaybackUrl, type PlaybackSourceKind } from '../utils/resolvePlaybackUrl'; +import { sameQueueTrackId } from '../utils/queueIdentity'; +import { useOfflineStore } from './offlineStore'; + +/** + * Helpers that classify the URL `audio_play` was last invoked with, so the + * runtime can rebind playback onto a freshly-promoted hot-cache entry the + * next time the same track plays. + * + * `lastOpenedWithHttpTrackId` remembers the most recent track id we asked + * the engine to fetch over HTTP (anything that isn't the `psysonic-local://` + * scheme). When the hot-cache later promotes that track to a local URL we + * compare against this id to decide whether a transparent rebind is worth + * triggering. + */ +let lastOpenedWithHttpTrackId: string | null = null; + +export function recordEnginePlayUrl(trackId: string, url: string): void { + lastOpenedWithHttpTrackId = url.startsWith('psysonic-local://') ? null : trackId; +} + +/** Matches `playTrack` / PlayerBar: stream vs hot-cache vs offline file from resolved `audio_play` URL. */ +export function playbackSourceHintForResolvedUrl(trackId: string, serverId: string, url: string): PlaybackSourceKind { + if (!url.startsWith('psysonic-local://')) return 'stream'; + return useOfflineStore.getState().getLocalUrl(trackId, serverId) ? 'offline' : 'hot'; +} + +export function shouldRebindPlaybackToHotCache(trackId: string, serverId: string): boolean { + if (!serverId) return false; + if (!lastOpenedWithHttpTrackId || !sameQueueTrackId(lastOpenedWithHttpTrackId, trackId)) { + return false; + } + return resolvePlaybackUrl(trackId, serverId).startsWith('psysonic-local://'); +} + +/** Test-only: clear the module-scoped track id so each test starts clean. */ +export function _resetPlaybackUrlRoutingForTest(): void { + lastOpenedWithHttpTrackId = null; +} diff --git a/src/store/playerStore.ts b/src/store/playerStore.ts index 0d00a8b9..77a9f3af 100644 --- a/src/store/playerStore.ts +++ b/src/store/playerStore.ts @@ -39,6 +39,11 @@ import { subscribePlaybackProgress, type PlaybackProgressSnapshot, } from './playbackProgress'; +import { + playbackSourceHintForResolvedUrl, + recordEnginePlayUrl, + shouldRebindPlaybackToHotCache, +} from './playbackUrlRouting'; // Re-export the playback-progress public surface so existing call sites // (PlayerBar, FullscreenPlayer, WaveformSeek, LyricsPane, MobilePlayerView, @@ -1034,31 +1039,6 @@ async function promoteCompletedStreamToHotCache(track: Track, serverId: string, } } -/** - * Tracks the **actual** `audio_play` URL family: `getPlaybackSourceKind()` can - * report `hot` for RAM preload or disk index while the engine still uses HTTP. - * Rebind-to-local seeks need this, not the UI hint alone. - */ -let lastOpenedWithHttpTrackId: string | null = null; - -function recordEnginePlayUrl(trackId: string, url: string): void { - lastOpenedWithHttpTrackId = url.startsWith('psysonic-local://') ? null : trackId; -} - -/** Matches `playTrack` / PlayerBar: stream vs hot-cache vs offline file from resolved `audio_play` URL. */ -function playbackSourceHintForResolvedUrl(trackId: string, serverId: string, url: string): PlaybackSourceKind { - if (!url.startsWith('psysonic-local://')) return 'stream'; - return useOfflineStore.getState().getLocalUrl(trackId, serverId) ? 'offline' : 'hot'; -} - -function shouldRebindPlaybackToHotCache(trackId: string, serverId: string): boolean { - if (!serverId) return false; - if (!lastOpenedWithHttpTrackId || !sameQueueTrackId(lastOpenedWithHttpTrackId, trackId)) { - return false; - } - return resolvePlaybackUrl(trackId, serverId).startsWith('psysonic-local://'); -} - // Track ID that has already been sent to audio_chain_preload (gapless chain). let gaplessPreloadingId: string | null = null; // Track ID that has already been sent to audio_preload (byte pre-download).