refactor(lib): relocate shuffleArray to lib/util; albumBrowseCatalogChunk to features/album

shuffleArray is a pure generic Fisher-Yates over T[], misfiled in
features/playback. Move to lib/util (next to dedupeById): fixes the existing
core->feature edges (utils/componentHelpers/*) and drops one of
genreBrowsePlayback's playback deps.

albumBrowseCatalogChunk is the feature-layer orchestrator that picks the offline
branch on top of the pure lib catalog loaders; its sole consumer lives in
features/album and its offline dep is a legal feature->feature edge there. Its
.test.ts stays in utils/library -- that test exercises fetchLocalAlbumCatalogChunk
from albumBrowseLoad (misnamed), which is not moving.

utils/library now has a single remaining feature importer: genreBrowsePlayback
(songToTrack + type Track), blocked on the Track domain model living in
features/playback. tsc 0, lint 0, targeted suites green.
This commit is contained in:
Psychotoxical
2026-06-30 16:55:18 +02:00
parent f41005682d
commit edab32d7ee
16 changed files with 16 additions and 16 deletions
@@ -35,7 +35,7 @@ import { useOfflineBrowseReloadToken } from '@/features/offline';
import {
fetchAlbumBrowseCatalogChunk,
mergeAlbumCatalogChunk,
} from '@/utils/library/albumBrowseCatalogChunk';
} from '@/features/album/utils/albumBrowseCatalogChunk';
import { useOfflineBrowseContext } from '@/features/offline';
import { useClientSliceInfiniteScroll } from '@/hooks/useClientSliceInfiniteScroll';
import { useDebouncedValue } from '@/lib/hooks/useDebouncedValue';
+1 -1
View File
@@ -5,7 +5,7 @@ import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { getArtistInfo } from '@/lib/api/subsonicArtists';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
import { shuffleArray } from '@/lib/util/shuffleArray';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useSearchParams } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
+1 -1
View File
@@ -4,7 +4,7 @@ import { getAlbumList } from '@/lib/api/subsonicLibrary';
import { resolveAlbum } from '@/features/offline';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { dedupeById } from '@/lib/util/dedupeById';
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
import { shuffleArray } from '@/lib/util/shuffleArray';
import React, { useEffect, useLayoutEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw, Download, HardDriveDownload } from 'lucide-react';
import SelectionToggleButton from '@/components/SelectionToggleButton';
@@ -0,0 +1,44 @@
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { dedupeById } from '@/lib/util/dedupeById';
import { isOfflineBrowseActive } from '@/features/offline';
import { loadOfflineAlbumCatalogChunk } from '@/features/offline';
import type { AlbumBrowseQuery } from '@/utils/library/albumBrowseTypes';
import { fetchLocalAlbumCatalogChunk } from '@/utils/library/albumBrowseLoad';
export type AlbumCatalogChunk = {
albums: SubsonicAlbum[];
hasMore: boolean;
};
export function mergeAlbumCatalogChunk(
prev: SubsonicAlbum[],
chunk: AlbumCatalogChunk,
append: boolean,
): { albums: SubsonicAlbum[]; offset: number } {
if (!append) {
return { albums: chunk.albums, offset: chunk.albums.length };
}
const merged = dedupeById([...prev, ...chunk.albums]);
return { albums: merged, offset: merged.length };
}
/** Local-index or offline-bytes catalog chunk for the albums grid. */
export async function fetchAlbumBrowseCatalogChunk(
serverId: string,
indexEnabled: boolean,
query: AlbumBrowseQuery,
offset: number,
chunkSize: number,
starredOverrides: Record<string, boolean>,
): Promise<AlbumCatalogChunk | null> {
if (isOfflineBrowseActive()) {
return loadOfflineAlbumCatalogChunk(
serverId,
query,
offset,
chunkSize,
starredOverrides,
);
}
return fetchLocalAlbumCatalogChunk(serverId, indexEnabled, query, offset, chunkSize);
}
@@ -33,7 +33,7 @@ import {
type OfflineLibraryCard,
} from '@/features/offline/utils/offlineLibraryHelpers';
import { showToast } from '@/utils/ui/toast';
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
import { shuffleArray } from '@/lib/util/shuffleArray';
import { getMediaDir } from '@/utils/media/mediaDir';
import { canonicalQueueServerKey, resolveIndexKey } from '@/utils/server/serverIndexKey';
import { reconcileAllLibraryTiersFromDisk } from '@/features/offline/utils/libraryTierReconcile';
@@ -6,7 +6,7 @@ import {
getMixMinRatingsConfigFromAuth,
passesMixMinRatings,
} from '@/utils/mix/mixRatingFilter';
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
import { shuffleArray } from '@/lib/util/shuffleArray';
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
/**
* Infinite queue source strategy (Instant Mix-like):
@@ -19,7 +19,7 @@ import { resolveAlbumForActiveServer } from '@/store/mediaResolver';
import { useOrbitStore } from '@/features/orbit';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { playAlbum, playAlbumShuffled } from '@/features/playback/utils/playback/playAlbum';
import * as shuffleModule from '@/features/playback/utils/playback/shuffleArray';
import * as shuffleModule from '@/lib/util/shuffleArray';
const albumPayload = {
album: {
@@ -5,7 +5,7 @@ import { orbitSnapshot } from '@/store/orbitRuntime';
import { fadeOut } from '@/features/playback/utils/playback/fadeOut';
import { shouldAutodjInterruptBlend } from '@/features/playback/utils/playback/autodjManualBlend';
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
import { shuffleArray } from '@/lib/util/shuffleArray';
export async function fetchAlbumTracks(albumId: string, serverId?: string): Promise<Track[]> {
const albumData = await resolveAlbumForActiveServer(albumId, serverId);
@@ -1,6 +1,6 @@
import { resolveAlbum, resolveArtist, resolveMediaServerId } from '@/store/mediaResolver';
import { songToTrack } from '@/features/playback/utils/playback/songToTrack';
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
import { shuffleArray } from '@/lib/util/shuffleArray';
import { usePlayerStore } from '@/features/playback/store/playerStore';
/**
* All tracks from the artists albums, shuffled — same idea as Artist page “shuffle play”.
@@ -1,5 +1,5 @@
import type { Track } from '@/features/playback/store/playerStoreTypes';
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
import { shuffleArray } from '@/lib/util/shuffleArray';
/**
* Shared "play / shuffle / enqueue a fetched track list" core for detail pages whose
@@ -1,51 +0,0 @@
/**
* Pure-helper characterization for `shuffleArray` (Fisher-Yates).
*
* Originally lived in `playerStore.ts`; extracted in M0 of the frontend
* refactor (2026-05-12).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { shuffleArray } from '@/features/playback/utils/playback/shuffleArray';
describe('shuffleArray', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('does not mutate the input array', () => {
const input = [1, 2, 3, 4, 5];
const snapshot = [...input];
shuffleArray(input);
expect(input).toEqual(snapshot);
});
it('preserves the multiset of elements (same length, same members)', () => {
const input = ['a', 'b', 'c', 'd', 'e'];
const out = shuffleArray(input);
expect(out).toHaveLength(input.length);
expect([...out].sort()).toEqual([...input].sort());
});
it('returns a copy (not the same reference)', () => {
const input = [1, 2, 3];
expect(shuffleArray(input)).not.toBe(input);
});
it('returns an empty array when called with an empty array', () => {
expect(shuffleArray([])).toEqual([]);
});
it('returns the single-element input unchanged', () => {
expect(shuffleArray(['only'])).toEqual(['only']);
});
it('produces a deterministic order under a mocked RNG (Math.random=0 picks j=0 each iteration)', () => {
vi.spyOn(Math, 'random').mockReturnValue(0);
// With Math.random()=0, j=floor(0 * (i+1))=0 for every i. The Fisher-Yates
// step swaps arr[i] with arr[0]. Walk it through for [1,2,3,4]:
// i=3: swap(3,0) → [4,2,3,1]
// i=2: swap(2,0) → [3,2,4,1]
// i=1: swap(1,0) → [2,3,4,1]
expect(shuffleArray([1, 2, 3, 4])).toEqual([2, 3, 4, 1]);
});
});
@@ -1,11 +0,0 @@
/**
* Fisher-Yates shuffle. Returns a new array; the input is not mutated.
*/
export function shuffleArray<T>(items: T[]): T[] {
const arr = [...items];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}