mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
refactor(lib,playback): relocate albumDetailNavigation + mixRatingFilter decouple-knots
Two lower-layer→feature inversions removed: - Detail-route predicates (isAlbumDetailPath/isArtistDetailPath/isComposerDetailPath) were defined in features/album's browse store but are pure URL checks. Extracted to lib/navigation/detailRoutePaths; the browse store re-exports them so the @/features/album barrel surface is unchanged. This frees albumDetailNavigation of its only @/features import, so it moves utils/navigation → lib/navigation (drains utils/navigation). Also fixes the M5 isArtistDetailPath misplacement. - mixRatingFilter's sole feature dep is playback's userRatingOverrides and its only binding consumer is playback's buildInfiniteQueueCandidates, so it belongs in the playback feature: utils/mix → features/playback/utils (drains utils/mix). The earlier handoff note that lib/api/subsonicStarRating consumes it was stale — that is only a code comment, verified no runtime import. Pure moves; tests pass unmodified. The share cluster + switchActiveServer stay in utils/ — they are imported by the a11y-HELD PasteClipboardHandler, so relocating them would rewrite a do-not-touch file.
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
||||
useAlbumBrowseSessionStore,
|
||||
} from '@/features/album/store/albumBrowseSessionStore';
|
||||
import type { AlbumBrowseSort } from '@/lib/library/browseTextSearch';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
|
||||
const ALBUMS_SURFACE: AlbumBrowseSurface = 'albums';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type AlbumBrowseSurface,
|
||||
useAlbumBrowseSessionStore,
|
||||
} from '@/features/album/store/albumBrowseSessionStore';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
type PendingScroll = {
|
||||
scrollTop: number;
|
||||
|
||||
@@ -4,10 +4,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import { useAlbumDetailBack } from '@/features/album/hooks/useAlbumDetailBack';
|
||||
import { navigateAlbumDetailBack } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { navigateAlbumDetailBack } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
vi.mock('@/utils/navigation/albumDetailNavigation', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('@/utils/navigation/albumDetailNavigation')>();
|
||||
vi.mock('@/lib/navigation/albumDetailNavigation', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('@/lib/navigation/albumDetailNavigation')>();
|
||||
return {
|
||||
...mod,
|
||||
navigateAlbumDetailBack: vi.fn(mod.navigateAlbumDetailBack),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
navigateAlbumDetailBack,
|
||||
readAlbumDetailReturnTo,
|
||||
} from '@/utils/navigation/albumDetailNavigation';
|
||||
} from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
/** Leave album/artist detail for the page that opened it (or history back as fallback). */
|
||||
export function useAlbumDetailBack(fallback = '/') {
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
isAlbumDetailPath,
|
||||
useAlbumBrowseSessionStore,
|
||||
} from '@/features/album/store/albumBrowseSessionStore';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
import {
|
||||
inpageScrollViewportIdForSurface,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigateToAlbumDetail } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { navigateToAlbumDetail } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
/** Navigate to album detail, remembering the current page for the back button. */
|
||||
export function useNavigateToAlbum() {
|
||||
|
||||
@@ -40,7 +40,7 @@ import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrow
|
||||
import { useAlbumBrowseScrollReset } from '@/features/album/hooks/useAlbumBrowseScrollReset';
|
||||
import { useBrowseAlbumTextSearch } from '@/features/album/hooks/useBrowseAlbumTextSearch';
|
||||
import { peekAlbumBrowseScrollRestore } from '@/features/album/store/albumBrowseSessionStore';
|
||||
import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { readAlbumBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
|
||||
import { useAlbumCatalogYearBounds } from '@/features/album/hooks/useAlbumCatalogYearBounds';
|
||||
import type { AlbumBrowseSort } from '@/lib/library/albumBrowseSort';
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrow
|
||||
import { useAlbumBrowseScrollReset } from '@/features/album/hooks/useAlbumBrowseScrollReset';
|
||||
import { useBrowseAlbumTextSearch } from '@/features/album/hooks/useBrowseAlbumTextSearch';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { readAlbumBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { filterAlbumsByGenres } from '@/lib/library/albumBrowseFilters';
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter';
|
||||
import { runLocalRandomAlbums, runLocalAlbumsByGenres } from '@/lib/library/browseTextSearch';
|
||||
import { useOfflineStore } from '@/features/offline';
|
||||
import { useDownloadModalStore } from '@/features/offline';
|
||||
@@ -36,7 +36,7 @@ import { useInpageScrollViewport } from '@/lib/hooks/useInpageScrollViewport';
|
||||
import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '@/features/album/hooks/useAlbumGridBrowseFilters';
|
||||
import { useAlbumBrowseScrollRestore } from '@/features/album/hooks/useAlbumBrowseScrollRestore';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '@/features/album/hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { readAlbumBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { albumArtistDisplayName } from '@/features/album/utils/deriveAlbumHeaderArtistRefs';
|
||||
|
||||
const ALBUM_COUNT = 30;
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
|
||||
import type { AlbumBrowseSort } from '@/lib/library/browseTextSearch';
|
||||
import {
|
||||
isAlbumDetailPath,
|
||||
isArtistDetailPath,
|
||||
isComposerDetailPath,
|
||||
} from '@/lib/navigation/detailRoutePaths';
|
||||
|
||||
// Detail-route predicates now live in lib/navigation (pure URL checks shared by
|
||||
// both browse logic and lower-layer navigation helpers). Re-exported here so the
|
||||
// `@/features/album` barrel surface for browse consumers stays unchanged.
|
||||
export {
|
||||
isAlbumDetailPath,
|
||||
isArtistDetailPath,
|
||||
isComposerDetailPath,
|
||||
} from '@/lib/navigation/detailRoutePaths';
|
||||
|
||||
export const DEFAULT_ALBUM_BROWSE_SORT: AlbumBrowseSort = 'alphabeticalByName';
|
||||
|
||||
@@ -212,11 +226,6 @@ export function albumBrowseSurfaceForPath(pathname: string): AlbumBrowseSurface
|
||||
return null;
|
||||
}
|
||||
|
||||
/** True when pathname is a single album detail route (`/album/:id`). */
|
||||
export function isAlbumDetailPath(pathname: string): boolean {
|
||||
return /^\/album\/[^/]+\/?$/.test(pathname.split('?')[0]?.replace(/\/$/, '') || pathname);
|
||||
}
|
||||
|
||||
/** Single genre detail route (`/genres/:name`), not the genre cloud (`/genres`). */
|
||||
export function isGenreDetailPath(pathname: string): boolean {
|
||||
const path = pathname.split('?')[0]?.replace(/\/$/, '') || pathname;
|
||||
@@ -229,16 +238,6 @@ export function genreDetailGenreFromPath(pathname: string): string | null {
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
}
|
||||
|
||||
/** True when pathname is a single artist detail route (`/artist/:id`). */
|
||||
export function isArtistDetailPath(pathname: string): boolean {
|
||||
return /^\/artist\/[^/]+\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
/** True when pathname is a single composer detail route (`/composer/:id`). */
|
||||
export function isComposerDetailPath(pathname: string): boolean {
|
||||
return /^\/composer\/[^/]+\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
export function isAdvancedSearchLeaveTargetPath(pathname: string): boolean {
|
||||
return isAlbumDetailPath(pathname) || isArtistDetailPath(pathname) || isComposerDetailPath(pathname);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useArtistBrowseSessionStore,
|
||||
} from '@/features/artist/store/artistBrowseSessionStore';
|
||||
import { isArtistDetailPath } from '@/features/album';
|
||||
import { shouldRestoreArtistBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { shouldRestoreArtistBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
|
||||
export type ArtistBrowseScrollSnapshot = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
peekArtistBrowseScrollRestore,
|
||||
useArtistBrowseSessionStore,
|
||||
} from '@/features/artist/store/artistBrowseSessionStore';
|
||||
import { shouldRestoreArtistBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { shouldRestoreArtistBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
type PendingScroll = {
|
||||
scrollTop: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigateToArtistDetail } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { navigateToArtistDetail } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
/** Navigate to artist detail, remembering the current page for the back button. */
|
||||
export function useNavigateToArtist() {
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useArtistsBrowseScrollRestore } from '@/features/artist/hooks/useArtist
|
||||
import { useArtistsBrowseScrollReset } from '@/features/artist/hooks/useArtistsBrowseScrollReset';
|
||||
import { useNavigateToArtist } from '@/features/artist/hooks/useNavigateToArtist';
|
||||
import { peekArtistBrowseScrollRestore } from '@/features/artist/store/artistBrowseSessionStore';
|
||||
import { readArtistBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { readArtistBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
|
||||
import { useLibraryIndexStore } from '@/store/libraryIndexStore';
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
isComposersBrowsePath,
|
||||
useComposerBrowseSessionStore,
|
||||
} from '@/features/composers/store/composerBrowseSessionStore';
|
||||
import { shouldRestoreComposerBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { shouldRestoreComposerBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
|
||||
export type ComposerBrowseScrollSnapshot = {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
peekComposerBrowseScrollRestore,
|
||||
useComposerBrowseSessionStore,
|
||||
} from '@/features/composers/store/composerBrowseSessionStore';
|
||||
import { shouldRestoreComposerBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { shouldRestoreComposerBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
type PendingScroll = {
|
||||
scrollTop: number;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigateToComposerDetail } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { navigateToComposerDetail } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
/** Navigate to composer detail, remembering the current page for the back button. */
|
||||
export function useNavigateToComposer() {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useArtistsBrowseScrollReset } from '@/features/artist';
|
||||
import { useNavigateToComposer } from '@/features/composers/hooks/useNavigateToComposer';
|
||||
import { peekComposerBrowseScrollRestore } from '@/features/composers/store/composerBrowseSessionStore';
|
||||
import { useScopedBrowseSearchQuery } from '@/store/liveSearchScopeStore';
|
||||
import { readComposerBrowseRestore } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { readComposerBrowseRestore } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { filterArtistsWithRoleAlbumCredits } from '@/lib/library/composerBrowse';
|
||||
import { ALL_SENTINEL, artistLetterBucket } from '@/features/artist';
|
||||
import { useLibraryIgnoredArticles } from '@/lib/library/hooks/useLibraryIgnoredArticles';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { join } from '@tauri-apps/api/path';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { getSimilarSongs2, fetchSimilarTracksRouted, getTopSongs } from '@/lib/api/subsonicArtists';
|
||||
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
|
||||
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter';
|
||||
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
stashGenreDetailReturnFilters,
|
||||
useAlbumBrowseSessionStore,
|
||||
} from '@/features/album';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { shouldRestoreAlbumBrowseSession } from '@/lib/navigation/albumDetailNavigation';
|
||||
import type { AlbumBrowseScrollSnapshot } from '@/features/album';
|
||||
|
||||
/** Genre detail: locked genre filter + leave/restore session (same contract as All Albums). */
|
||||
|
||||
@@ -28,7 +28,7 @@ import { libraryScopeForServer } from '@/lib/api/subsonicClient';
|
||||
import {
|
||||
readAlbumBrowseRestore,
|
||||
readAlbumDetailReturnTo,
|
||||
} from '@/utils/navigation/albumDetailNavigation';
|
||||
} from '@/lib/navigation/albumDetailNavigation';
|
||||
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
|
||||
import { runBulkEnqueue, runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay';
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useIsMobile } from '@/lib/hooks/useIsMobile';
|
||||
import { useWindowVisibility } from '@/lib/hooks/useWindowVisibility';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { useThemeStore } from '@/store/themeStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter';
|
||||
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
|
||||
import { playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '@/lib/hooks/useLongPressAction';
|
||||
|
||||
@@ -13,7 +13,7 @@ import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useHomeStore } from '@/features/home/store/homeStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/utils/mix/mixRatingFilter';
|
||||
import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter';
|
||||
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
|
||||
import { bumpPerfCounter } from '@/lib/perf/perfTelemetry';
|
||||
import { dedupeById } from '@/lib/util/dedupeById';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ensurePlaybackServerActive } from '@/features/playback/utils/playback/playbackServer';
|
||||
import { navigatePathWithAlbumReturnTo } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { navigatePathWithAlbumReturnTo } from '@/lib/navigation/albumDetailNavigation';
|
||||
|
||||
/** Navigate to library routes for the playing queue — switches to {@link queueServerId} when needed. */
|
||||
export function usePlaybackLibraryNavigate() {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { resetPlayerStore } from '@/test/helpers/storeReset';
|
||||
|
||||
vi.mock('@/lib/api/subsonicRatings', () => ({
|
||||
prefetchArtistUserRatings: vi.fn(),
|
||||
prefetchAlbumUserRatings: vi.fn(),
|
||||
parseSubsonicEntityStarRating: vi.fn(),
|
||||
}));
|
||||
|
||||
import { prefetchArtistUserRatings, prefetchAlbumUserRatings } from '@/lib/api/subsonicRatings';
|
||||
import {
|
||||
enrichSongsForMixRatingFilter,
|
||||
filterTopArtistsForMixRatings,
|
||||
passesMixMinRatings,
|
||||
} from '@/features/playback/utils/mixRatingFilter';
|
||||
|
||||
const enabledArtist2: { enabled: true; minSong: 0; minAlbum: 0; minArtist: 2 } = {
|
||||
enabled: true,
|
||||
minSong: 0,
|
||||
minAlbum: 0,
|
||||
minArtist: 2,
|
||||
};
|
||||
|
||||
function song(partial: Partial<SubsonicSong> & Pick<SubsonicSong, 'id'>): SubsonicSong {
|
||||
return {
|
||||
title: 't',
|
||||
artist: 'A',
|
||||
album: 'Al',
|
||||
albumId: 'alb-1',
|
||||
artistId: 'art-1',
|
||||
duration: 180,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetPlayerStore();
|
||||
vi.mocked(prefetchArtistUserRatings).mockReset();
|
||||
vi.mocked(prefetchAlbumUserRatings).mockReset();
|
||||
vi.mocked(prefetchAlbumUserRatings).mockResolvedValue(new Map());
|
||||
});
|
||||
|
||||
describe('passesMixMinRatings — artist axis', () => {
|
||||
it('excludes when artistUserRating is at or below threshold', () => {
|
||||
expect(passesMixMinRatings(song({ id: '1', artistUserRating: 1 }), enabledArtist2)).toBe(false);
|
||||
expect(passesMixMinRatings(song({ id: '2', artistUserRating: 2 }), enabledArtist2)).toBe(false);
|
||||
expect(passesMixMinRatings(song({ id: '3', artistUserRating: 3 }), enabledArtist2)).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps unrated artists (missing or zero)', () => {
|
||||
expect(passesMixMinRatings(song({ id: '1' }), enabledArtist2)).toBe(true);
|
||||
expect(passesMixMinRatings(song({ id: '2', artistUserRating: 0 }), enabledArtist2)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses playerStore userRatingOverrides before API fields', () => {
|
||||
usePlayerStore.getState().setUserRatingOverride('art-1', 1);
|
||||
expect(
|
||||
passesMixMinRatings(song({ id: '1', artistUserRating: 5 }), enabledArtist2),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('uses OpenSubsonic artists[] ref when artistUserRating is absent', () => {
|
||||
const low = song({
|
||||
id: '1',
|
||||
artists: [{ id: 'art-1', userRating: 1 }],
|
||||
});
|
||||
expect(passesMixMinRatings(low, enabledArtist2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enrichSongsForMixRatingFilter', () => {
|
||||
it('prefetches entity artist rating even when song carries a misleading artists[] ref', async () => {
|
||||
vi.mocked(prefetchArtistUserRatings).mockResolvedValue(new Map([['art-1', 1]]));
|
||||
|
||||
const input = [
|
||||
song({
|
||||
id: '1',
|
||||
artists: [{ id: 'art-1', userRating: 5 }],
|
||||
}),
|
||||
];
|
||||
const out = await enrichSongsForMixRatingFilter(input, enabledArtist2);
|
||||
|
||||
expect(prefetchArtistUserRatings).toHaveBeenCalledWith(['art-1']);
|
||||
expect(out[0].artistUserRating).toBe(1);
|
||||
expect(passesMixMinRatings(out[0], enabledArtist2)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterTopArtistsForMixRatings', () => {
|
||||
it('drops artists rated at or below the threshold', async () => {
|
||||
vi.mocked(prefetchArtistUserRatings).mockResolvedValue(
|
||||
new Map([
|
||||
['a1', 1],
|
||||
['a2', 3],
|
||||
]),
|
||||
);
|
||||
|
||||
const out = await filterTopArtistsForMixRatings(
|
||||
[
|
||||
{ id: 'a1', name: 'Low' },
|
||||
{ id: 'a2', name: 'Ok' },
|
||||
{ id: 'a3', name: 'Unrated' },
|
||||
],
|
||||
enabledArtist2,
|
||||
);
|
||||
|
||||
expect(out.map(a => a.id)).toEqual(['a2', 'a3']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
import { parseSubsonicEntityStarRating, prefetchAlbumUserRatings, prefetchArtistUserRatings } from '@/lib/api/subsonicRatings';
|
||||
import { getRandomSongs } from '@/lib/api/subsonicLibrary';
|
||||
import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
|
||||
/** Default target list size for Random Mix; per-call override via `fetchRandomMixSongsUntilFull(c, { targetSize })`. */
|
||||
export const RANDOM_MIX_TARGET_SIZE = 50;
|
||||
/** Subsonic spec caps `getRandomSongs` at 500 per call. */
|
||||
const RANDOM_MIX_BATCH_HARD_CAP = 500;
|
||||
const RANDOM_MIX_BATCH_FLOOR = 50;
|
||||
/** Per-call batch size depends on whether a filter is active.
|
||||
* - No filter: ask for the full target in one call (server ORDER BY random LIMIT N is one query).
|
||||
* - With filter: stick to small 50-track batches — large batches waste bandwidth when only a small
|
||||
* fraction of candidates pass the filter, and they make every loop iteration slow on the server. */
|
||||
function batchSizeFor(targetSize: number, filterActive: boolean): number {
|
||||
if (filterActive) return RANDOM_MIX_BATCH_FLOOR;
|
||||
return Math.min(RANDOM_MIX_BATCH_HARD_CAP, Math.max(RANDOM_MIX_BATCH_FLOOR, targetSize));
|
||||
}
|
||||
/** Upper bound on `getRandomSongs` calls (avoids infinite loop if the library is tiny or the filter is extreme).
|
||||
* Filtered mode is generous because a selective filter (e.g. only ≥3★ in a library where 5% of tracks are
|
||||
* rated) needs many candidates to find a target's worth of passes. Unfiltered mode just tops up the
|
||||
* fast-path's first call. */
|
||||
function maxBatchesFor(targetSize: number, batchSize: number, filterActive: boolean): number {
|
||||
if (filterActive) {
|
||||
return Math.max(40, Math.ceil((targetSize * 8) / batchSize));
|
||||
}
|
||||
return Math.max(8, Math.ceil((targetSize * 4) / batchSize));
|
||||
}
|
||||
/** Stop if several batches in a row bring no new track ids (server keeps repeating the same set).
|
||||
* Scales with target size: at 50 a 6-batch floor is fine; at 150 we tolerate ~25 empty-novel batches
|
||||
* before giving up so libraries with weakly-shuffled random endpoints can still fill the larger requests.
|
||||
* Without this, "All Songs" mixes at 150 stalled around 120–145 while Genre mixes (smaller candidate pool,
|
||||
* lower repeat rate per batch) could reach 150 cleanly. */
|
||||
function dupStreakBudget(targetSize: number): number {
|
||||
return Math.max(8, Math.ceil(targetSize / 6));
|
||||
}
|
||||
|
||||
export interface MixMinRatingsConfig {
|
||||
enabled: boolean;
|
||||
minSong: number;
|
||||
minAlbum: number;
|
||||
minArtist: number;
|
||||
}
|
||||
|
||||
export function getMixMinRatingsConfigFromAuth(): MixMinRatingsConfig {
|
||||
const s = useAuthStore.getState();
|
||||
return {
|
||||
enabled: s.mixMinRatingFilterEnabled,
|
||||
minSong: s.mixMinRatingSong,
|
||||
minAlbum: s.mixMinRatingAlbum,
|
||||
minArtist: s.mixMinRatingArtist,
|
||||
};
|
||||
}
|
||||
|
||||
function numRating(v: unknown): number | undefined {
|
||||
if (v === null || v === undefined) return undefined;
|
||||
const n = typeof v === 'number' ? v : Number(v);
|
||||
if (!Number.isFinite(n)) return undefined;
|
||||
return n;
|
||||
}
|
||||
|
||||
/** Optimistic stars from the UI (`setUserRatingOverride`) take precedence over API payloads. */
|
||||
function mixRatingOverrideForEntity(entityId: string | undefined): number | undefined {
|
||||
if (!entityId) return undefined;
|
||||
const o = usePlayerStore.getState().userRatingOverrides[entityId];
|
||||
if (o === undefined || o <= 0) return undefined;
|
||||
return o;
|
||||
}
|
||||
|
||||
type OpenArtistRefLike = { id?: string; userRating?: unknown; rating?: unknown };
|
||||
|
||||
function refStarRating(a: OpenArtistRefLike | undefined): number | undefined {
|
||||
return numRating(a?.userRating ?? a?.rating);
|
||||
}
|
||||
|
||||
function ratingFromArtistRefs(
|
||||
list: OpenArtistRefLike[] | undefined,
|
||||
preferId?: string,
|
||||
): number | undefined {
|
||||
if (!list?.length) return undefined;
|
||||
if (preferId) {
|
||||
const m = list.find(x => x.id === preferId);
|
||||
const r = refStarRating(m);
|
||||
if (r !== undefined) return r;
|
||||
}
|
||||
for (const a of list) {
|
||||
const r = refStarRating(a);
|
||||
if (r !== undefined) return r;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const CONTRIBUTOR_ROLES_FOR_ARTIST_ID =
|
||||
/^(artist|album[\s_-]*artist|performer|track[\s_-]*artist|albumartist)$/i;
|
||||
|
||||
/**
|
||||
* Entity id for artist-level mix rating: canonical `artistId`, else OpenSubsonic `artists[].id`,
|
||||
* else Navidrome `contributors[].artist.id` when list payloads omit the former.
|
||||
*/
|
||||
function artistEntityIdForMixRating(song: SubsonicSong): string | undefined {
|
||||
if (song.artistId) return song.artistId;
|
||||
const fromArtists = song.artists?.find(a => a.id)?.id;
|
||||
if (fromArtists) return fromArtists;
|
||||
const cList = song.contributors;
|
||||
if (cList?.length) {
|
||||
const byRole = cList.find(
|
||||
c => c.artist?.id && CONTRIBUTOR_ROLES_FOR_ARTIST_ID.test((c.role || '').trim()),
|
||||
);
|
||||
if (byRole?.artist?.id) return byRole.artist.id;
|
||||
const anyId = cList.find(c => c.artist?.id);
|
||||
if (anyId?.artist?.id) return anyId.artist.id;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Song-level artist rating: explicit field, then OpenSubsonic `artists` / `albumArtists` on the child. */
|
||||
function effectiveArtistRatingForFilter(song: SubsonicSong): number | undefined {
|
||||
const prefer = artistEntityIdForMixRating(song);
|
||||
const fromOverride = mixRatingOverrideForEntity(prefer);
|
||||
if (fromOverride !== undefined) return fromOverride;
|
||||
const d = numRating(song.artistUserRating);
|
||||
if (d !== undefined) return d;
|
||||
const fromArtists = ratingFromArtistRefs(song.artists, prefer);
|
||||
if (fromArtists !== undefined) return fromArtists;
|
||||
return ratingFromArtistRefs(song.albumArtists, prefer);
|
||||
}
|
||||
|
||||
/** Song-level album (parent) rating when the server puts it on the child payload. */
|
||||
function effectiveAlbumRatingOnSong(song: SubsonicSong): number | undefined {
|
||||
const fromOverride = mixRatingOverrideForEntity(song.albumId);
|
||||
if (fromOverride !== undefined) return fromOverride;
|
||||
const x = song as SubsonicSong & { albumRating?: unknown };
|
||||
return numRating(song.albumUserRating ?? x.albumRating);
|
||||
}
|
||||
|
||||
function songTrackStarRatingForMix(song: SubsonicSong): number | undefined {
|
||||
const fromOverride = mixRatingOverrideForEntity(song.id);
|
||||
if (fromOverride !== undefined) return fromOverride;
|
||||
const x = song as SubsonicSong & { rating?: unknown };
|
||||
return numRating(song.userRating ?? x.rating);
|
||||
}
|
||||
|
||||
/**
|
||||
* Random mixes: when enabled, drop items with a **non-zero** rating that is **at or below** the
|
||||
* chosen threshold (inclusive). `0` / missing = unrated, never excluded.
|
||||
*/
|
||||
export function passesMixMinRatings(song: SubsonicSong, c: MixMinRatingsConfig): boolean {
|
||||
if (!c.enabled) return true;
|
||||
if (c.minSong > 0) {
|
||||
const r = songTrackStarRatingForMix(song);
|
||||
if (r !== undefined && r > 0 && r <= c.minSong) return false;
|
||||
}
|
||||
if (c.minAlbum > 0) {
|
||||
const r = effectiveAlbumRatingOnSong(song);
|
||||
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||||
}
|
||||
if (c.minArtist > 0) {
|
||||
const r = effectiveArtistRatingForFilter(song);
|
||||
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface MixAlbumFilterExtra {
|
||||
/** From `getArtist` when list payloads omit artist rating. */
|
||||
artistUserRating?: number;
|
||||
/** From `getAlbum` when list payloads omit album `userRating`. */
|
||||
albumUserRating?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Random album lists: album `userRating` when present; optional extra from entity fetches.
|
||||
* Song axis is not on this payload. `0` / missing = unrated, keep.
|
||||
*/
|
||||
export function passesMixMinRatingsForAlbum(
|
||||
album: SubsonicAlbum,
|
||||
c: MixMinRatingsConfig,
|
||||
extra?: MixAlbumFilterExtra,
|
||||
): boolean {
|
||||
if (!c.enabled) return true;
|
||||
if (c.minAlbum > 0) {
|
||||
const r =
|
||||
parseSubsonicEntityStarRating(album as SubsonicAlbum & { rating?: unknown })
|
||||
?? numRating(extra?.albumUserRating);
|
||||
if (r !== undefined && r > 0 && r <= c.minAlbum) return false;
|
||||
}
|
||||
if (c.minArtist > 0) {
|
||||
const r = numRating(extra?.artistUserRating);
|
||||
if (r !== undefined && r > 0 && r <= c.minArtist) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches missing entity ratings (bounded concurrency) then filters. Used for random album grids / hero.
|
||||
*/
|
||||
export async function filterAlbumsByMixRatings(
|
||||
albums: SubsonicAlbum[],
|
||||
c: MixMinRatingsConfig,
|
||||
): Promise<SubsonicAlbum[]> {
|
||||
if (!c.enabled) return albums;
|
||||
if (c.minAlbum <= 0 && c.minArtist <= 0) return albums;
|
||||
const needArtist = c.minArtist > 0;
|
||||
const needAlbum = c.minAlbum > 0;
|
||||
let byArtist = new Map<string, number>();
|
||||
let byAlbum = new Map<string, number>();
|
||||
if (needArtist) {
|
||||
const ids = [...new Set(albums.map(a => a.artistId).filter(Boolean))] as string[];
|
||||
byArtist = await prefetchArtistUserRatings(ids);
|
||||
}
|
||||
if (needAlbum) {
|
||||
const ids = [...new Set(albums.filter(a => a.userRating === undefined).map(a => a.id))];
|
||||
if (ids.length) byAlbum = await prefetchAlbumUserRatings(ids);
|
||||
}
|
||||
return albums.filter(a =>
|
||||
passesMixMinRatingsForAlbum(a, c, {
|
||||
artistUserRating: a.artistId ? byArtist.get(a.artistId) : undefined,
|
||||
albumUserRating: byAlbum.get(a.id),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Enrich when needed, then drop songs excluded by Settings → Ratings → filter-by-rating. */
|
||||
export async function filterSongsForLuckyMixRatings(
|
||||
songs: SubsonicSong[],
|
||||
c: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
if (!c.enabled) return songs;
|
||||
const enriched = await enrichSongsForMixRatingFilter(songs, c);
|
||||
return enriched.filter(s => passesMixMinRatings(s, c));
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge `getArtist` / `getAlbum` ratings into songs when list payloads omit them,
|
||||
* so `passesMixMinRatings` / Lucky Mix filtering see album and artist stars.
|
||||
*/
|
||||
/** Drop low-rated seed artists before Lucky Mix picks from listening history. */
|
||||
export async function filterTopArtistsForMixRatings<T extends { id: string }>(
|
||||
artists: T[],
|
||||
c: MixMinRatingsConfig,
|
||||
): Promise<T[]> {
|
||||
if (!c.enabled || c.minArtist <= 0 || !artists.length) return artists;
|
||||
const byArtist = await prefetchArtistUserRatings(artists.map(a => a.id));
|
||||
return artists.filter(a => {
|
||||
const r = mixRatingOverrideForEntity(a.id) ?? byArtist.get(a.id);
|
||||
if (r === undefined || r <= 0) return true;
|
||||
return r > c.minArtist;
|
||||
});
|
||||
}
|
||||
|
||||
export async function enrichSongsForMixRatingFilter(
|
||||
songs: SubsonicSong[],
|
||||
c: MixMinRatingsConfig,
|
||||
): Promise<SubsonicSong[]> {
|
||||
if (!c.enabled || (c.minArtist <= 0 && c.minAlbum <= 0)) return songs;
|
||||
const artistIds =
|
||||
c.minArtist > 0
|
||||
? [...new Set(songs.map(s => artistEntityIdForMixRating(s)).filter((id): id is string => !!id))]
|
||||
: [];
|
||||
const albumIds =
|
||||
c.minAlbum > 0
|
||||
? [...new Set(songs.filter(s => s.albumId).map(s => s.albumId!))]
|
||||
: [];
|
||||
const [byArtist, byAlbum] = await Promise.all([
|
||||
artistIds.length ? prefetchArtistUserRatings(artistIds) : Promise.resolve(new Map<string, number>()),
|
||||
albumIds.length ? prefetchAlbumUserRatings(albumIds) : Promise.resolve(new Map<string, number>()),
|
||||
]);
|
||||
if (!byArtist.size && !byAlbum.size) return songs;
|
||||
return songs.map(s => {
|
||||
const aid = artistEntityIdForMixRating(s);
|
||||
const artistPatch =
|
||||
aid && byArtist.has(aid) ? { artistUserRating: byArtist.get(aid)! } : {};
|
||||
const albumPatch =
|
||||
s.albumId && byAlbum.has(s.albumId) ? { albumUserRating: byAlbum.get(s.albumId)! } : {};
|
||||
return { ...s, ...artistPatch, ...albumPatch };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads random songs in batches until `targetSize` (or `RANDOM_MIX_TARGET_SIZE`) songs
|
||||
* pass `passesMixMinRatings` (after enrich), or batch/duplicate limits are hit.
|
||||
*
|
||||
* When NO filter is active (neither `enabled` nor `onlyRatedMinStars`), a single
|
||||
* batch fast-path is used and capped to a single batch — for `targetSize` greater
|
||||
* than `RANDOM_MIX_BATCH_SIZE` (50) the loop path is taken so we can issue multiple
|
||||
* `getRandomSongs` calls.
|
||||
*/
|
||||
export async function fetchRandomMixSongsUntilFull(
|
||||
c: MixMinRatingsConfig,
|
||||
opts?: { genre?: string; timeout?: number; targetSize?: number },
|
||||
): Promise<SubsonicSong[]> {
|
||||
const timeout = opts?.timeout ?? 15000;
|
||||
const genre = opts?.genre;
|
||||
const targetSize = opts?.targetSize ?? RANDOM_MIX_TARGET_SIZE;
|
||||
const filterActive = c.enabled;
|
||||
const batchSize = batchSizeFor(targetSize, filterActive);
|
||||
|
||||
// Fast-path: no filter — one call asking for the full target, slice, done. The server-side
|
||||
// `ORDER BY random() LIMIT N` returns N distinct rows, so a single round-trip usually fills
|
||||
// the request without dup-streak gymnastics.
|
||||
if (!filterActive) {
|
||||
const raw = await getRandomSongs(batchSize, genre, timeout);
|
||||
if (raw.length >= targetSize) return raw.slice(0, targetSize);
|
||||
// Library smaller than target, or random endpoint returned fewer — fall through to the
|
||||
// batched loop below so we can top up via additional calls (deduped by id).
|
||||
}
|
||||
|
||||
const maxBatches = maxBatchesFor(targetSize, batchSize, filterActive);
|
||||
const maxDupStreak = dupStreakBudget(targetSize);
|
||||
|
||||
const out: SubsonicSong[] = [];
|
||||
const outIds = new Set<string>();
|
||||
const seenFromApi = new Set<string>();
|
||||
let dupStreak = 0;
|
||||
|
||||
for (let b = 0; b < maxBatches && out.length < targetSize; b++) {
|
||||
const raw = await getRandomSongs(batchSize, genre, timeout);
|
||||
if (!raw.length) break;
|
||||
|
||||
const novel = raw.filter(s => !seenFromApi.has(s.id));
|
||||
for (const s of raw) seenFromApi.add(s.id);
|
||||
|
||||
if (!novel.length) {
|
||||
if (++dupStreak >= maxDupStreak) break;
|
||||
continue;
|
||||
}
|
||||
dupStreak = 0;
|
||||
|
||||
const enriched = filterActive
|
||||
? await enrichSongsForMixRatingFilter(novel, c)
|
||||
: novel;
|
||||
for (const s of enriched) {
|
||||
if (!passesMixMinRatings(s, c) || outIds.has(s.id)) continue;
|
||||
outIds.add(s.id);
|
||||
out.push(s);
|
||||
if (out.length >= targetSize) break;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ vi.mock('@/lib/api/subsonicLibrary', () => ({
|
||||
getRandomSongs: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/mix/mixRatingFilter', () => ({
|
||||
vi.mock('@/features/playback/utils/mixRatingFilter', () => ({
|
||||
getMixMinRatingsConfigFromAuth: vi.fn(),
|
||||
enrichSongsForMixRatingFilter: vi.fn(),
|
||||
passesMixMinRatings: vi.fn(),
|
||||
@@ -33,7 +33,7 @@ import { buildInfiniteQueueCandidates } from '@/features/playback/utils/playback
|
||||
import {
|
||||
enrichSongsForMixRatingFilter,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
} from '@/utils/mix/mixRatingFilter';
|
||||
} from '@/features/playback/utils/mixRatingFilter';
|
||||
import { makeSubsonicSong } from '@/test/helpers/factories';
|
||||
|
||||
const seed = (overrides: Partial<Track> = {}): Track => ({
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
enrichSongsForMixRatingFilter,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
passesMixMinRatings,
|
||||
} from '@/utils/mix/mixRatingFilter';
|
||||
} from '@/features/playback/utils/mixRatingFilter';
|
||||
import { shuffleArray } from '@/lib/util/shuffleArray';
|
||||
import { songToTrack } from '@/lib/media/songToTrack';
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useOrbitSongRowBehavior } from '@/features/orbit';
|
||||
import {
|
||||
fetchRandomMixSongsUntilFull,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
} from '@/utils/mix/mixRatingFilter';
|
||||
} from '@/features/playback/utils/mixRatingFilter';
|
||||
import { fetchGenreCatalog } from '@/features/playback/utils/playback/genreBrowsePlayback';
|
||||
import { AUDIOBOOK_GENRES, filterRandomMixSongs } from '@/features/randomMix/utils/randomMixHelpers';
|
||||
import RandomMixHeader from '@/features/randomMix/components/RandomMixHeader';
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
filterSongsForLuckyMixRatings,
|
||||
filterTopArtistsForMixRatings,
|
||||
getMixMinRatingsConfigFromAuth,
|
||||
} from '@/utils/mix/mixRatingFilter';
|
||||
} from '@/features/playback/utils/mixRatingFilter';
|
||||
import {
|
||||
MIX_TARGET_SIZE,
|
||||
SEED_TARGET_SIZE,
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { SubsonicAlbum, SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import {
|
||||
filterSongsForLuckyMixRatings,
|
||||
type MixMinRatingsConfig,
|
||||
} from '@/utils/mix/mixRatingFilter';
|
||||
} from '@/features/playback/utils/mixRatingFilter';
|
||||
|
||||
export interface TopArtist {
|
||||
id: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
|
||||
import { passesMixMinRatings, type MixMinRatingsConfig } from '@/utils/mix/mixRatingFilter';
|
||||
import { passesMixMinRatings, type MixMinRatingsConfig } from '@/features/playback/utils/mixRatingFilter';
|
||||
|
||||
export const AUDIOBOOK_GENRES = [
|
||||
'hörbuch', 'hoerbuch', 'hörspiel', 'hoerspiel',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useLiveSearchScopeStore } from '@/store/liveSearchScopeStore';
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { navigatePathWithAlbumReturnTo } from '@/utils/navigation/albumDetailNavigation';
|
||||
import { navigatePathWithAlbumReturnTo } from '@/lib/navigation/albumDetailNavigation';
|
||||
import { X, Search, Disc3, Users, Music, Music2, Clock, ChevronRight } from 'lucide-react';
|
||||
import { usePlayerStore } from '@/features/playback/store/playerStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
import {
|
||||
readAdvancedSearchRestore,
|
||||
shouldRestoreAdvancedSearchSession,
|
||||
} from '@/utils/navigation/albumDetailNavigation';
|
||||
} from '@/lib/navigation/albumDetailNavigation';
|
||||
import {
|
||||
clearAdvancedSearchLeaveSnapshots,
|
||||
consumeAdvancedSearchLeavingForDetail,
|
||||
|
||||
Reference in New Issue
Block a user