refactor(dedup): route inline shuffle + union-dedupe through existing utils (Phase L, part 2) (#691)

Findings 3 + 4 of the dedup audit — replace hand-rolled copies with the
utils that already exist:

- shuffleArray (utils/playback/shuffleArray.ts): BecauseYouLikeRail's
  local shuffle<T>, plus the inline Fisher-Yates loops in RandomAlbums,
  AlbumDetail and Home.
- dedupeById (utils/dedupeById.ts): the identical seen-Set/filter
  union-dedupe block in the fetchByGenres of Albums, NewReleases and
  RandomAlbums.

The extracted utils are character-identical to the inline loops, so no
behaviour change. RandomMix's biased `.sort(() => Math.random() - 0.5)`
is intentionally left alone — swapping it would change behaviour.
This commit is contained in:
Frank Stellmacher
2026-05-14 14:58:26 +02:00
committed by GitHub
parent 5231169a71
commit 0153435787
6 changed files with 14 additions and 34 deletions
+3 -11
View File
@@ -3,6 +3,7 @@ import { getArtist, getArtistInfo } from '../api/subsonicArtists';
import { getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -39,15 +40,6 @@ const SHOW_COUNT = 3;
const PICKS_HISTORY_SIZE = 30;
const COVER_SIZE = 300;
function shuffle<T>(arr: T[]): T[] {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
interface Anchor {
id: string;
name: string;
@@ -162,7 +154,7 @@ export default function BecauseYouLikeRail({
const recentAnchors = new Set(anchorHistory.slice(-cooldown));
const eligibleRaw = pool.filter(a => !recentAnchors.has(a.id));
const eligible = eligibleRaw.length > 0 ? eligibleRaw : pool.slice();
const candidates = shuffle(eligible);
const candidates = shuffleArray(eligible);
const recentPicks = new Set(picksHistory);
(async () => {
@@ -182,7 +174,7 @@ export default function BecauseYouLikeRail({
const similar = (info.similarArtist ?? []).filter(s => s.id);
if (similar.length === 0) continue;
const sampled = shuffle(similar).slice(0, SIMILAR_PICK);
const sampled = shuffleArray(similar).slice(0, SIMILAR_PICK);
const results = await Promise.all(
sampled.map(s => getArtist(s.id).catch(() => null))
);
+2 -5
View File
@@ -3,6 +3,7 @@ import { setRating, star, unstar } from '../api/subsonicStarRating';
import { getArtistInfo } from '../api/subsonicArtists';
import type { SubsonicSong } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { invoke } from '@tauri-apps/api/core';
@@ -103,11 +104,7 @@ const handleShuffleAll = () => {
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
const shuffled = [...tracks];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const shuffled = shuffleArray(tracks);
if (shuffled[0]) playTrack(shuffled[0], shuffled);
};
+2 -2
View File
@@ -3,6 +3,7 @@ import { getAlbumsByGenre } from '../api/subsonicGenres';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { songToTrack } from '../utils/playback/songToTrack';
import { dedupeById } from '../utils/dedupeById';
import React, { useState, useEffect, useRef, useCallback, useMemo, useLayoutEffect } from 'react';
import AlbumCard from '../components/AlbumCard';
import GenreFilterBar from '../components/GenreFilterBar';
@@ -41,8 +42,7 @@ function sanitizeFilename(name: string): string {
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
return results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
return dedupeById(results.flat());
}
export default function Albums() {
+2 -6
View File
@@ -16,6 +16,7 @@ import { filterAlbumsByMixRatings, getMixMinRatingsConfigFromAuth } from '../uti
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
import { bumpPerfCounter } from '../utils/perf/perfTelemetry';
import { dedupeById } from '../utils/dedupeById';
import { shuffleArray } from '../utils/playback/shuffleArray';
/** Match Random Albums overshoot when mix filter uses album/artist axes so hero + discover row can still fill. */
const HOME_RANDOM_FETCH = 100;
@@ -87,12 +88,7 @@ export default function Home() {
setMostPlayed(dedupeById(f));
setRecentlyPlayed(dedupeById(rp));
setDiscoverSongs(dedupeById(songs));
const shuffled = [...artists];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
setRandomArtists(dedupeById(shuffled).slice(0, 16));
setRandomArtists(dedupeById(shuffleArray(artists)).slice(0, 16));
} catch {
/* ignore */
} finally {
+2 -3
View File
@@ -2,6 +2,7 @@ import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbumsByGenre } from '../api/subsonicGenres';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { CheckSquare2, Download, HardDriveDownload, ListMusic } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
@@ -25,9 +26,7 @@ function sanitizeFilename(name: string): string {
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
return union.sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
return dedupeById(results.flat()).sort((a, b) => (b.year ?? 0) - (a.year ?? 0));
}
export default function NewReleases() {
+3 -7
View File
@@ -2,6 +2,8 @@ import { buildDownloadUrl } from '../api/subsonicStreamUrl';
import { getAlbumsByGenre } from '../api/subsonicGenres';
import { getAlbumList, getAlbum } from '../api/subsonicLibrary';
import type { SubsonicAlbum } from '../api/subsonicTypes';
import { dedupeById } from '../utils/dedupeById';
import { shuffleArray } from '../utils/playback/shuffleArray';
import React, { useEffect, useState, useCallback, useRef } from 'react';
import { RefreshCw, CheckSquare2, Download, HardDriveDownload } from 'lucide-react';
import AlbumCard from '../components/AlbumCard';
@@ -29,13 +31,7 @@ function sanitizeFilename(name: string): string {
async function fetchByGenres(genres: string[]): Promise<SubsonicAlbum[]> {
const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0)));
const seen = new Set<string>();
const union = results.flat().filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; });
for (let i = union.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[union[i], union[j]] = [union[j], union[i]];
}
const pool = union.slice(0, GENRE_UNION_PREFILTER_CAP);
const pool = shuffleArray(dedupeById(results.flat())).slice(0, GENRE_UNION_PREFILTER_CAP);
const filtered = await filterAlbumsByMixRatings(pool, getMixMinRatingsConfigFromAuth());
return filtered.slice(0, ALBUM_COUNT);
}