From bd5143d98c10b45f4de371e87dcbe2163b53217f Mon Sep 17 00:00:00 2001 From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com> Date: Tue, 30 Jun 2026 09:10:18 +0200 Subject: [PATCH] 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. --- .../hooks/useFavoritesOfflineStatus.ts | 2 +- src/features/offline/store/offlineStore.ts | 2 +- .../offline/utils/favoritesOfflineSync.ts | 2 +- .../offline/utils/libraryTierReconcile.ts | 2 +- .../offline/utils/offlineLibraryHelpers.ts | 95 +--------------- .../offline/utils/offlineLocalBrowse.ts | 2 +- .../offline/utils/offlinePlaylistBrowse.ts | 4 +- .../offline/utils/pinnedOfflineSync.ts | 2 +- src/hotCachePrefetch.ts | 2 +- src/store/crossfadePreload.ts | 2 +- src/store/hotCacheStore.ts | 2 +- src/store/localPlaybackResolve.ts | 102 ++++++++++++++++++ src/store/playTrackAction.ts | 2 +- src/store/playbackUrlRouting.test.ts | 2 +- src/store/playbackUrlRouting.ts | 2 +- src/store/promoteStreamCache.test.ts | 2 +- src/store/promoteStreamCache.ts | 2 +- src/utils/playback/resolvePlaybackUrl.ts | 2 +- 18 files changed, 120 insertions(+), 111 deletions(-) create mode 100644 src/store/localPlaybackResolve.ts diff --git a/src/features/favorites/hooks/useFavoritesOfflineStatus.ts b/src/features/favorites/hooks/useFavoritesOfflineStatus.ts index 8a0f380a..ee480a25 100644 --- a/src/features/favorites/hooks/useFavoritesOfflineStatus.ts +++ b/src/features/favorites/hooks/useFavoritesOfflineStatus.ts @@ -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' diff --git a/src/features/offline/store/offlineStore.ts b/src/features/offline/store/offlineStore.ts index 67b7d7c8..c53a773b 100644 --- a/src/features/offline/store/offlineStore.ts +++ b/src/features/offline/store/offlineStore.ts @@ -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'; diff --git a/src/features/offline/utils/favoritesOfflineSync.ts b/src/features/offline/utils/favoritesOfflineSync.ts index 1d86c619..79b33d1f 100644 --- a/src/features/offline/utils/favoritesOfflineSync.ts +++ b/src/features/offline/utils/favoritesOfflineSync.ts @@ -21,7 +21,7 @@ import { entryBelongsToServer, hasLocalLibraryBytes, hasLocalFavoriteAutoBytes, -} from '@/features/offline/utils/offlineLibraryHelpers'; +} from '@/store/localPlaybackResolve'; const CONCURRENCY = 2; const DEBOUNCE_MS = 600; diff --git a/src/features/offline/utils/libraryTierReconcile.ts b/src/features/offline/utils/libraryTierReconcile.ts index 2eeb70f9..e7361b6f 100644 --- a/src/features/offline/utils/libraryTierReconcile.ts +++ b/src/features/offline/utils/libraryTierReconcile.ts @@ -11,7 +11,7 @@ import { entryBelongsToServer, findLocalPlaybackEntry, indexKeyBelongsToServer, -} from '@/features/offline/utils/offlineLibraryHelpers'; +} from '@/store/localPlaybackResolve'; interface LibraryTrackProbeResult { path: string; diff --git a/src/features/offline/utils/offlineLibraryHelpers.ts b/src/features/offline/utils/offlineLibraryHelpers.ts index 07a9f39d..11a5196c 100644 --- a/src/features/offline/utils/offlineLibraryHelpers.ts +++ b/src/features/offline/utils/offlineLibraryHelpers.ts @@ -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(); - 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( songs: T[], diff --git a/src/features/offline/utils/offlineLocalBrowse.ts b/src/features/offline/utils/offlineLocalBrowse.ts index 155caaf5..ef810154 100644 --- a/src/features/offline/utils/offlineLocalBrowse.ts +++ b/src/features/offline/utils/offlineLocalBrowse.ts @@ -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)); diff --git a/src/features/offline/utils/offlinePlaylistBrowse.ts b/src/features/offline/utils/offlinePlaylistBrowse.ts index 2af15f3d..98b12dd1 100644 --- a/src/features/offline/utils/offlinePlaylistBrowse.ts +++ b/src/features/offline/utils/offlinePlaylistBrowse.ts @@ -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() diff --git a/src/features/offline/utils/pinnedOfflineSync.ts b/src/features/offline/utils/pinnedOfflineSync.ts index dc128f4f..bb73c06d 100644 --- a/src/features/offline/utils/pinnedOfflineSync.ts +++ b/src/features/offline/utils/pinnedOfflineSync.ts @@ -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']; diff --git a/src/hotCachePrefetch.ts b/src/hotCachePrefetch.ts index df22ba09..a82827d1 100644 --- a/src/hotCachePrefetch.ts +++ b/src/hotCachePrefetch.ts @@ -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; diff --git a/src/store/crossfadePreload.ts b/src/store/crossfadePreload.ts index 64e9ef66..cb2a9726 100644 --- a/src/store/crossfadePreload.ts +++ b/src/store/crossfadePreload.ts @@ -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'; diff --git a/src/store/hotCacheStore.ts b/src/store/hotCacheStore.ts index 299b401c..8d706d82 100644 --- a/src/store/hotCacheStore.ts +++ b/src/store/hotCacheStore.ts @@ -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'; diff --git a/src/store/localPlaybackResolve.ts b/src/store/localPlaybackResolve.ts new file mode 100644 index 00000000..feb8d7da --- /dev/null +++ b/src/store/localPlaybackResolve.ts @@ -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(); + 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; +} diff --git a/src/store/playTrackAction.ts b/src/store/playTrackAction.ts index 486835a8..69c88b00 100644 --- a/src/store/playTrackAction.ts +++ b/src/store/playTrackAction.ts @@ -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'; diff --git a/src/store/playbackUrlRouting.test.ts b/src/store/playbackUrlRouting.test.ts index 905813e1..cfc03719 100644 --- a/src/store/playbackUrlRouting.test.ts +++ b/src/store/playbackUrlRouting.test.ts @@ -11,7 +11,7 @@ const { offlineStoreState } = vi.hoisted(() => ({ offlineStoreState: { localUrlByKey: new Map() }, })); -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}`) diff --git a/src/store/playbackUrlRouting.ts b/src/store/playbackUrlRouting.ts index 8897f2a3..58e5b10d 100644 --- a/src/store/playbackUrlRouting.ts +++ b/src/store/playbackUrlRouting.ts @@ -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'; diff --git a/src/store/promoteStreamCache.test.ts b/src/store/promoteStreamCache.test.ts index 11edb0e0..d5bf8a40 100644 --- a/src/store/promoteStreamCache.test.ts +++ b/src/store/promoteStreamCache.test.ts @@ -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), })); diff --git a/src/store/promoteStreamCache.ts b/src/store/promoteStreamCache.ts index 46cd7738..b8327e5a 100644 --- a/src/store/promoteStreamCache.ts +++ b/src/store/promoteStreamCache.ts @@ -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. diff --git a/src/utils/playback/resolvePlaybackUrl.ts b/src/utils/playback/resolvePlaybackUrl.ts index 84cd99da..b6d4d7ec 100644 --- a/src/utils/playback/resolvePlaybackUrl.ts +++ b/src/utils/playback/resolvePlaybackUrl.ts @@ -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';