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:
Psychotoxical
2026-06-30 21:17:01 +02:00
parent fbca1831a1
commit d4ab56f5a6
40 changed files with 80 additions and 60 deletions
@@ -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() {
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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() {
+1 -1
View File
@@ -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() {
+1 -1
View File
@@ -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). */
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 120145 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';
/**
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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,