mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 07:15:47 +00:00
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:
@@ -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'
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user