refactor(decouple): extract local-playback resolve substrate to store/

Pull findLocalPlaybackUrl/findLocalPlaybackEntry/hasLocalPersistentPlaybackBytes
+ the entry/index-key membership helpers (entryBelongsToServer,
indexKeyBelongsToServer, findFavoriteAutoEntry, hasLocal{Library,FavoriteAuto}Bytes)
out of features/offline/utils/offlineLibraryHelpers into a new core module
store/localPlaybackResolve.ts. The substrate depends only on authStore +
localPlaybackStore + serverIndexKey utils (no useOfflineStore), so the audio
core can resolve on-disk bytes without inverting into @/features/offline.

Repointed the audio-core consumers (crossfadePreload, playbackUrlRouting,
resolvePlaybackUrl, playTrackAction, promoteStreamCache, hotCachePrefetch,
hotCacheStore) + offline-internal callers + favorites hook; moved the two
barrel vi.mocks onto the new leaf module. offlineLibraryHelpers re-imports the
two primitives it still needs internally.

Decouple Step 2a. Removes the local-bytes offline edge from the audio core;
the resolve* media-resolution family (Step 2b) + orbit seam (Step 3) remain.

tsc 0, lint 0, full suite 319/2353 green.
This commit is contained in:
Psychotoxical
2026-06-30 09:10:18 +02:00
parent 36b0042d9a
commit bd5143d98c
18 changed files with 120 additions and 111 deletions
@@ -4,7 +4,7 @@ import { useFavoritesOfflineSyncStore } from '@/features/offline';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { useOfflineJobStore } from '@/features/offline';
import { FAVORITES_OFFLINE_JOB_ID } from '@/features/offline';
import { entryBelongsToServer } from '@/features/offline';
import { entryBelongsToServer } from '@/store/localPlaybackResolve';
export type FavoritesOfflineUiStatus =
| 'disabled'
+1 -1
View File
@@ -11,8 +11,8 @@ import { showToast } from '@/utils/ui/toast';
import { useOfflineJobStore, cancelledDownloads } from '@/features/offline/store/offlineJobStore';
import { useLocalPlaybackStore, type PinSource } from '@/store/localPlaybackStore';
import { getMediaDir } from '@/utils/media/mediaDir';
import { findLocalPlaybackEntry } from '@/store/localPlaybackResolve';
import {
findLocalPlaybackEntry,
isOfflinePinComplete,
pendingOfflinePinSongs,
} from '@/features/offline/utils/offlineLibraryHelpers';
@@ -21,7 +21,7 @@ import {
entryBelongsToServer,
hasLocalLibraryBytes,
hasLocalFavoriteAutoBytes,
} from '@/features/offline/utils/offlineLibraryHelpers';
} from '@/store/localPlaybackResolve';
const CONCURRENCY = 2;
const DEBOUNCE_MS = 600;
@@ -11,7 +11,7 @@ import {
entryBelongsToServer,
findLocalPlaybackEntry,
indexKeyBelongsToServer,
} from '@/features/offline/utils/offlineLibraryHelpers';
} from '@/store/localPlaybackResolve';
interface LibraryTrackProbeResult {
path: string;
@@ -5,6 +5,7 @@ import type { CoverServerScope } from '@/cover/types';
import { useAuthStore } from '@/store/authStore';
import type { LocalPlaybackEntry, PinnedGroup, PinSource } from '@/store/localPlaybackStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { findLocalPlaybackEntry, hasLocalLibraryBytes } from '@/store/localPlaybackResolve';
import { useOfflineStore, type OfflineAlbumMeta } from '@/features/offline/store/offlineStore';
import { resolveTrackCoverArtId, trackToSong } from '@/utils/library/advancedSearchLocal';
import { canonicalQueueServerKey, resolveIndexKey } from '@/utils/server/serverIndexKey';
@@ -34,100 +35,6 @@ export function resolveOfflineAlbumMeta(
return albums[`${indexKey}:${albumId}`] ?? albums[`${serverId}:${albumId}`];
}
function serverIndexKeysForServerId(serverId: string): string[] {
const servers = useAuthStore.getState().servers;
const server = servers.find(s => s.id === serverId);
const keys = new Set<string>();
if (server) {
const profileKey = serverIndexKeyForProfile(server);
if (profileKey) keys.add(profileKey);
keys.add(server.id);
}
keys.add(resolveIndexKey(serverId));
keys.add(serverId);
return [...keys].filter(Boolean);
}
export function entryBelongsToServer(entry: LocalPlaybackEntry, serverId: string): boolean {
return serverIndexKeysForServerId(serverId).includes(entry.serverIndexKey);
}
export function indexKeyBelongsToServer(serverIndexKey: string, serverId: string): boolean {
return serverIndexKeysForServerId(serverId).includes(serverIndexKey);
}
/** Resolve a library-tier row across legacy UUID / URL index-key variants. */
export function findLocalPlaybackEntry(
trackId: string,
serverId: string,
): LocalPlaybackEntry | null {
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const hit = lp.getEntry(trackId, key);
if (hit?.tier === 'library') return hit;
}
for (const entry of Object.values(lp.entries)) {
if (entry.trackId !== trackId || entry.tier !== 'library') continue;
if (entryBelongsToServer(entry, serverId)) return entry;
}
return null;
}
/** Index cache; run {@link reconcileLibraryTierForAlbum} / server reconcile so rows match disk. */
export function hasLocalLibraryBytes(trackId: string, serverId: string): boolean {
return !!findLocalPlaybackEntry(trackId, serverId)?.localPath;
}
/** Resolve a `favorite-auto` tier row across index-key variants. */
export function findFavoriteAutoEntry(
trackId: string,
serverId: string,
): LocalPlaybackEntry | null {
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const hit = lp.getEntry(trackId, key);
if (hit?.tier === 'favorite-auto') return hit;
}
for (const entry of Object.values(lp.entries)) {
if (entry.trackId !== trackId || entry.tier !== 'favorite-auto') continue;
if (entryBelongsToServer(entry, serverId)) return entry;
}
return null;
}
export function hasLocalFavoriteAutoBytes(trackId: string, serverId: string): boolean {
return !!findFavoriteAutoEntry(trackId, serverId)?.localPath;
}
/** Manual offline library or favorites auto-sync — skip redundant hot-cache prefetch/promote. */
export function hasLocalPersistentPlaybackBytes(trackId: string, serverId: string): boolean {
return hasLocalLibraryBytes(trackId, serverId) || hasLocalFavoriteAutoBytes(trackId, serverId);
}
/** Resolve `psysonic-local://` across legacy UUID / host index-key variants. */
export function findLocalPlaybackUrl(
trackId: string,
serverId: string,
tier: 'library' | 'ephemeral' | 'favorite-auto',
): string | null {
if (tier === 'library') {
const entry = findLocalPlaybackEntry(trackId, serverId);
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
return null;
}
if (tier === 'favorite-auto') {
const entry = findFavoriteAutoEntry(trackId, serverId);
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
return null;
}
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const url = lp.getLocalUrl(trackId, key, 'ephemeral');
if (url) return url;
}
return null;
}
/** Songs that still need a library-tier pin (used to skip redundant downloads). */
export function pendingOfflinePinSongs<T extends { id: string }>(
songs: T[],
@@ -20,7 +20,7 @@ import {
import type { AlbumBrowseQuery } from '@/utils/library/albumBrowseTypes';
import { sortSubsonicAlbums } from '@/utils/library/albumBrowseSort';
import { isLosslessSuffix } from '@/utils/library/losslessFormats';
import { entryBelongsToServer } from '@/features/offline/utils/offlineLibraryHelpers';
import { entryBelongsToServer } from '@/store/localPlaybackResolve';
function sortBrowsableSongs(songs: SubsonicSong[]): SubsonicSong[] {
return [...songs].sort((a, b) => a.title.localeCompare(b.title));
@@ -8,8 +8,8 @@ import { isManualOfflinePlaylist } from '@/features/offline/utils/pinnedOfflineS
import {
hasLocalLibraryBytes,
indexKeyBelongsToServer,
resolveOfflineAlbumMeta,
} from '@/features/offline/utils/offlineLibraryHelpers';
} from '@/store/localPlaybackResolve';
import { resolveOfflineAlbumMeta } from '@/features/offline/utils/offlineLibraryHelpers';
function listPlaylistPinnedGroupsForServer(serverId: string): PinnedGroup[] {
return useLocalPlaybackStore.getState()
@@ -17,7 +17,7 @@ import {
} from '@/utils/network/activeServerReachability';
import { resolveIndexKey, serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
import { resolveServerIdForIndexKey } from '@/utils/server/serverLookup';
import { findLocalPlaybackEntry } from '@/features/offline/utils/offlineLibraryHelpers';
import { findLocalPlaybackEntry } from '@/store/localPlaybackResolve';
import { enqueueOfflinePin } from '@/features/offline/utils/offlinePinQueue';
export type OfflinePinKind = PinSource['kind'];
+1 -1
View File
@@ -28,7 +28,7 @@ import {
resetAnalysisPruneState,
} from './hotCachePrefetch/analysisPrune';
import { reconcileEphemeralCache } from './utils/cache/ephemeralTierReconcile';
import { hasLocalPersistentPlaybackBytes } from '@/features/offline';
import { hasLocalPersistentPlaybackBytes } from '@/store/localPlaybackResolve';
/** Periodic index↔disk sync (stale rows + empty dirs); unindexed files evicted only on budget pressure. */
const EPHEMERAL_MAINTENANCE_MS = 10 * 60 * 1000;
+1 -1
View File
@@ -2,7 +2,7 @@ import { invoke } from '@tauri-apps/api/core';
import { useAuthStore } from './authStore';
import { autodjMaxOverlapCapSec } from '../utils/playback/autodjOverlapCap';
import { computeWaveformSilence, planCrossfadeTransition } from '../utils/waveform/waveformSilence';
import { findLocalPlaybackUrl } from '@/features/offline';
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { playbackCacheKeyForRef } from '../utils/playback/playbackServer';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveQueueTrack } from '../utils/library/queueTrackView';
+1 -1
View File
@@ -2,7 +2,7 @@ import type { QueueItemRef } from './playerStoreTypes';
import { create } from 'zustand';
import type { HotCacheEntry } from './hotCacheStoreTypes';
import { useLocalPlaybackStore, type LocalPlaybackEntry } from './localPlaybackStore';
import { entryBelongsToServer } from '@/features/offline';
import { entryBelongsToServer } from '@/store/localPlaybackResolve';
import { invoke } from '@tauri-apps/api/core';
import { getMediaDir } from '../utils/media/mediaDir';
+102
View File
@@ -0,0 +1,102 @@
// Resolve on-disk local playback bytes/URLs for a track across the legacy UUID /
// URL index-key variants. Pure substrate over authStore + localPlaybackStore —
// holds no offline-feature state (no useOfflineStore), so the audio core can
// depend on it without inverting into @/features/offline.
import { useAuthStore } from '@/store/authStore';
import type { LocalPlaybackEntry } from '@/store/localPlaybackStore';
import { useLocalPlaybackStore } from '@/store/localPlaybackStore';
import { resolveIndexKey, serverIndexKeyForProfile } from '@/utils/server/serverIndexKey';
function serverIndexKeysForServerId(serverId: string): string[] {
const servers = useAuthStore.getState().servers;
const server = servers.find(s => s.id === serverId);
const keys = new Set<string>();
if (server) {
const profileKey = serverIndexKeyForProfile(server);
if (profileKey) keys.add(profileKey);
keys.add(server.id);
}
keys.add(resolveIndexKey(serverId));
keys.add(serverId);
return [...keys].filter(Boolean);
}
export function entryBelongsToServer(entry: LocalPlaybackEntry, serverId: string): boolean {
return serverIndexKeysForServerId(serverId).includes(entry.serverIndexKey);
}
export function indexKeyBelongsToServer(serverIndexKey: string, serverId: string): boolean {
return serverIndexKeysForServerId(serverId).includes(serverIndexKey);
}
/** Resolve a library-tier row across legacy UUID / URL index-key variants. */
export function findLocalPlaybackEntry(
trackId: string,
serverId: string,
): LocalPlaybackEntry | null {
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const hit = lp.getEntry(trackId, key);
if (hit?.tier === 'library') return hit;
}
for (const entry of Object.values(lp.entries)) {
if (entry.trackId !== trackId || entry.tier !== 'library') continue;
if (entryBelongsToServer(entry, serverId)) return entry;
}
return null;
}
/** Index cache; run {@link reconcileLibraryTierForAlbum} / server reconcile so rows match disk. */
export function hasLocalLibraryBytes(trackId: string, serverId: string): boolean {
return !!findLocalPlaybackEntry(trackId, serverId)?.localPath;
}
/** Resolve a `favorite-auto` tier row across index-key variants. */
export function findFavoriteAutoEntry(
trackId: string,
serverId: string,
): LocalPlaybackEntry | null {
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const hit = lp.getEntry(trackId, key);
if (hit?.tier === 'favorite-auto') return hit;
}
for (const entry of Object.values(lp.entries)) {
if (entry.trackId !== trackId || entry.tier !== 'favorite-auto') continue;
if (entryBelongsToServer(entry, serverId)) return entry;
}
return null;
}
export function hasLocalFavoriteAutoBytes(trackId: string, serverId: string): boolean {
return !!findFavoriteAutoEntry(trackId, serverId)?.localPath;
}
/** Manual offline library or favorites auto-sync — skip redundant hot-cache prefetch/promote. */
export function hasLocalPersistentPlaybackBytes(trackId: string, serverId: string): boolean {
return hasLocalLibraryBytes(trackId, serverId) || hasLocalFavoriteAutoBytes(trackId, serverId);
}
/** Resolve `psysonic-local://` across legacy UUID / host index-key variants. */
export function findLocalPlaybackUrl(
trackId: string,
serverId: string,
tier: 'library' | 'ephemeral' | 'favorite-auto',
): string | null {
if (tier === 'library') {
const entry = findLocalPlaybackEntry(trackId, serverId);
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
return null;
}
if (tier === 'favorite-auto') {
const entry = findFavoriteAutoEntry(trackId, serverId);
if (entry?.localPath) return `psysonic-local://${entry.localPath}`;
return null;
}
const lp = useLocalPlaybackStore.getState();
for (const key of serverIndexKeysForServerId(serverId)) {
const url = lp.getLocalUrl(trackId, key, 'ephemeral');
if (url) return url;
}
return null;
}
+1 -1
View File
@@ -30,7 +30,7 @@ import { stampTrackServerId, stampTrackServerIds } from '../utils/playback/track
import {
findLocalPlaybackUrl,
hasLocalPersistentPlaybackBytes,
} from '@/features/offline';
} from '@/store/localPlaybackResolve';
import { resolvePlaybackUrl } from '../utils/playback/resolvePlaybackUrl';
import { resolveReplayGainDb } from '../utils/audio/resolveReplayGainDb';
import { audioPlayHiResBlendArgs } from '../utils/audio/hiResCrossfadeResample';
+1 -1
View File
@@ -11,7 +11,7 @@ const { offlineStoreState } = vi.hoisted(() => ({
offlineStoreState: { localUrlByKey: new Map<string, string>() },
}));
vi.mock('@/features/offline', () => ({
vi.mock('@/store/localPlaybackResolve', () => ({
findLocalPlaybackUrl: (trackId: string, serverId: string, tier: string) => {
if (tier !== 'library') return null;
return offlineStoreState.localUrlByKey.has(`${serverId}:${trackId}`)
+1 -1
View File
@@ -1,4 +1,4 @@
import { findLocalPlaybackUrl } from '@/features/offline';
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { resolvePlaybackUrl, type PlaybackSourceKind } from '../utils/playback/resolvePlaybackUrl';
import { resolveServerIdForIndexKey } from '../utils/server/serverLookup';
import { sameQueueTrackId } from '../utils/playback/queueIdentity';
+1 -1
View File
@@ -28,7 +28,7 @@ vi.mock('../api/coverCache', () => ({
const hasLocalPersistentPlaybackBytesMock = vi.fn((_trackId: string, _serverId: string) => false);
vi.mock('@/features/offline', () => ({
vi.mock('@/store/localPlaybackResolve', () => ({
hasLocalPersistentPlaybackBytes: (trackId: string, serverId: string) =>
hasLocalPersistentPlaybackBytesMock(trackId, serverId),
}));
+1 -1
View File
@@ -4,7 +4,7 @@ import { invoke } from '@tauri-apps/api/core';
import { useHotCacheStore } from './hotCacheStore';
import { getMediaDir } from '../utils/media/mediaDir';
import { librarySqlServerId } from '../api/coverCache';
import { hasLocalPersistentPlaybackBytes } from '@/features/offline';
import { hasLocalPersistentPlaybackBytes } from '@/store/localPlaybackResolve';
/**
* Promote a track whose stream cache is full to the on-disk ephemeral tier.
+1 -1
View File
@@ -1,5 +1,5 @@
import { buildStreamUrlForServer } from '@/lib/api/subsonicStreamUrl';
import { findLocalPlaybackUrl } from '@/features/offline';
import { findLocalPlaybackUrl } from '@/store/localPlaybackResolve';
import { resolveServerIdForIndexKey } from '../server/serverLookup';
import { getPlaybackCacheServerKey, getPlaybackServerId } from './playbackServer';