From 0153435787caa38b869c8900170bcd0f026f38b7 Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Thu, 14 May 2026 14:58:26 +0200 Subject: [PATCH] refactor(dedup): route inline shuffle + union-dedupe through existing utils (Phase L, part 2) (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, 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. --- src/components/BecauseYouLikeRail.tsx | 14 +++----------- src/pages/AlbumDetail.tsx | 7 ++----- src/pages/Albums.tsx | 4 ++-- src/pages/Home.tsx | 8 ++------ src/pages/NewReleases.tsx | 5 ++--- src/pages/RandomAlbums.tsx | 10 +++------- 6 files changed, 14 insertions(+), 34 deletions(-) diff --git a/src/components/BecauseYouLikeRail.tsx b/src/components/BecauseYouLikeRail.tsx index d0c4527f..02fae902 100644 --- a/src/components/BecauseYouLikeRail.tsx +++ b/src/components/BecauseYouLikeRail.tsx @@ -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(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)) ); diff --git a/src/pages/AlbumDetail.tsx b/src/pages/AlbumDetail.tsx index 92c2b76a..0d19b64a 100644 --- a/src/pages/AlbumDetail.tsx +++ b/src/pages/AlbumDetail.tsx @@ -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); }; diff --git a/src/pages/Albums.tsx b/src/pages/Albums.tsx index 2bb0b08b..f2969061 100644 --- a/src/pages/Albums.tsx +++ b/src/pages/Albums.tsx @@ -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 { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); - const seen = new Set(); - 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() { diff --git a/src/pages/Home.tsx b/src/pages/Home.tsx index 6a431c0a..0bc05e75 100644 --- a/src/pages/Home.tsx +++ b/src/pages/Home.tsx @@ -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 { diff --git a/src/pages/NewReleases.tsx b/src/pages/NewReleases.tsx index fadf8d5f..c156d8eb 100644 --- a/src/pages/NewReleases.tsx +++ b/src/pages/NewReleases.tsx @@ -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 { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); - const seen = new Set(); - 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() { diff --git a/src/pages/RandomAlbums.tsx b/src/pages/RandomAlbums.tsx index 151b5477..65a6963f 100644 --- a/src/pages/RandomAlbums.tsx +++ b/src/pages/RandomAlbums.tsx @@ -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 { const results = await Promise.all(genres.map(g => getAlbumsByGenre(g, 500, 0))); - const seen = new Set(); - 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); }