diff --git a/CHANGELOG.md b/CHANGELOG.md index 748a3458..1ae313b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,6 +179,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Genre detail — play or shuffle a whole genre + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#926](https://github.com/Psychotoxical/psysonic/pull/926)** + +* Play, Shuffle and Add-to-queue buttons on a genre page start the whole genre in one click — in order or randomized — instead of adding each album by hand. Suggested by Apollosport. + + + ## Changed ### CI — hot-path coverage gates block merges diff --git a/src/api/subsonicGenres.test.ts b/src/api/subsonicGenres.test.ts new file mode 100644 index 00000000..72f78c86 --- /dev/null +++ b/src/api/subsonicGenres.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fetchAllSongsByGenre, getSongsByGenre } from './subsonicGenres'; +import type { SubsonicSong } from './subsonicTypes'; + +const { apiMock } = vi.hoisted(() => ({ apiMock: vi.fn() })); + +vi.mock('./subsonicClient', () => ({ + api: apiMock, + libraryFilterParams: () => ({}), +})); + +function songs(n: number, startId = 0): SubsonicSong[] { + return Array.from({ length: n }, (_, i) => ({ id: String(startId + i), title: `t${startId + i}` }) as SubsonicSong); +} + +describe('getSongsByGenre', () => { + beforeEach(() => apiMock.mockReset()); + + it('normalizes a single-song response into an array', async () => { + apiMock.mockResolvedValue({ songsByGenre: { song: { id: '1', title: 'only' } } }); + expect(await getSongsByGenre('Metal')).toEqual([{ id: '1', title: 'only' }]); + }); + + it('returns an empty array when the genre has no songs', async () => { + apiMock.mockResolvedValue({ songsByGenre: {} }); + expect(await getSongsByGenre('Empty')).toEqual([]); + }); +}); + +describe('fetchAllSongsByGenre', () => { + beforeEach(() => apiMock.mockReset()); + + it('paginates until a short page signals the end', async () => { + apiMock + .mockResolvedValueOnce({ songsByGenre: { song: songs(500) } }) + .mockResolvedValueOnce({ songsByGenre: { song: songs(30, 500) } }); + const all = await fetchAllSongsByGenre('Metal'); + expect(all).toHaveLength(530); + expect(apiMock).toHaveBeenCalledTimes(2); + expect(apiMock.mock.calls[0][1]).toMatchObject({ offset: 0, count: 500 }); + expect(apiMock.mock.calls[1][1]).toMatchObject({ offset: 500 }); + }); + + it('stops at the cap without fetching further pages', async () => { + apiMock.mockResolvedValue({ songsByGenre: { song: songs(500) } }); + const all = await fetchAllSongsByGenre('Huge', 10); + expect(all).toHaveLength(10); + expect(apiMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/api/subsonicGenres.ts b/src/api/subsonicGenres.ts index 52c49dbf..3fb9d32b 100644 --- a/src/api/subsonicGenres.ts +++ b/src/api/subsonicGenres.ts @@ -1,5 +1,5 @@ import { api, libraryFilterParams } from './subsonicClient'; -import type { SubsonicAlbum, SubsonicGenre } from './subsonicTypes'; +import type { SubsonicAlbum, SubsonicGenre, SubsonicSong } from './subsonicTypes'; export async function getGenres(): Promise { const data = await api<{ genres: { genre: SubsonicGenre | SubsonicGenre[] } }>('getGenres.view'); @@ -21,3 +21,32 @@ export async function getAlbumsByGenre(genre: string, size = 50, offset = 0): Pr if (!raw) return []; return Array.isArray(raw) ? raw : [raw]; } + +/** Single page of songs for a genre (Subsonic `getSongsByGenre`, supported by Navidrome). */ +export async function getSongsByGenre(genre: string, count = 500, offset = 0): Promise { + const data = await api<{ songsByGenre: { song: SubsonicSong | SubsonicSong[] } }>('getSongsByGenre.view', { + genre, + count, + offset, + _t: Date.now(), + ...libraryFilterParams(), + }); + const raw = data.songsByGenre?.song; + if (!raw) return []; + return Array.isArray(raw) ? raw : [raw]; +} + +/** + * Every song in a genre, paginated until exhausted. Capped to keep the queue and the + * burst of server requests bounded for very large genres (a handful of sequential pages). + */ +export async function fetchAllSongsByGenre(genre: string, cap = 5000): Promise { + const PAGE = 500; + const songs: SubsonicSong[] = []; + for (let offset = 0; songs.length < cap; offset += PAGE) { + const page = await getSongsByGenre(genre, PAGE, offset); + songs.push(...page); + if (page.length < PAGE) break; + } + return songs.length > cap ? songs.slice(0, cap) : songs; +} diff --git a/src/locales/de/genres.ts b/src/locales/de/genres.ts index 41197333..4019c54d 100644 --- a/src/locales/de/genres.ts +++ b/src/locales/de/genres.ts @@ -9,4 +9,6 @@ export const genres = { albumsEmpty: 'Keine Alben in diesem Genre gefunden.', loadMore: 'Mehr laden', back: 'Zurück', + shuffle: 'Zufallswiedergabe', + addToQueue: 'Zur Warteschlange', }; diff --git a/src/locales/en/genres.ts b/src/locales/en/genres.ts index 192a0f96..77ad13d1 100644 --- a/src/locales/en/genres.ts +++ b/src/locales/en/genres.ts @@ -9,4 +9,6 @@ export const genres = { albumsEmpty: 'No albums found for this genre.', loadMore: 'Load more', back: 'Back', + shuffle: 'Shuffle', + addToQueue: 'Add to queue', }; diff --git a/src/locales/es/genres.ts b/src/locales/es/genres.ts index 59270632..73093fbd 100644 --- a/src/locales/es/genres.ts +++ b/src/locales/es/genres.ts @@ -9,4 +9,6 @@ export const genres = { albumsEmpty: 'No se encontraron álbumes para este género.', loadMore: 'Cargar más', back: 'Volver', + shuffle: 'Aleatorio', + addToQueue: 'Agregar a la cola', }; diff --git a/src/locales/fr/genres.ts b/src/locales/fr/genres.ts index b5e37d9a..4b02f7de 100644 --- a/src/locales/fr/genres.ts +++ b/src/locales/fr/genres.ts @@ -9,4 +9,6 @@ export const genres = { albumsEmpty: 'Aucun album trouvé pour ce genre.', loadMore: 'Charger plus', back: 'Retour', + shuffle: 'Aléatoire', + addToQueue: 'Ajouter à la file', }; diff --git a/src/locales/nb/genres.ts b/src/locales/nb/genres.ts index 52c1938d..3f316503 100644 --- a/src/locales/nb/genres.ts +++ b/src/locales/nb/genres.ts @@ -9,4 +9,6 @@ export const genres = { albumsEmpty: 'Ingen album funnet for denne sjangeren.', loadMore: 'Last mer', back: 'Tilbake', + shuffle: 'Bland', + addToQueue: 'Legg til i kø', }; diff --git a/src/locales/nl/genres.ts b/src/locales/nl/genres.ts index 722f4747..298f5b94 100644 --- a/src/locales/nl/genres.ts +++ b/src/locales/nl/genres.ts @@ -9,4 +9,6 @@ export const genres = { albumsEmpty: 'Geen albums gevonden voor dit genre.', loadMore: 'Meer laden', back: 'Terug', + shuffle: 'Willekeurig', + addToQueue: 'Aan wachtrij toevoegen', }; diff --git a/src/locales/ro/genres.ts b/src/locales/ro/genres.ts index 5184f6d0..57be270e 100644 --- a/src/locales/ro/genres.ts +++ b/src/locales/ro/genres.ts @@ -9,4 +9,6 @@ export const genres = { albumsEmpty: 'Niciun album găsit pentru acest gen.', loadMore: 'Încarcă mai mult', back: 'Înapoi', + shuffle: 'Amestecă', + addToQueue: 'Adaugă la coadă', }; diff --git a/src/locales/ru/genres.ts b/src/locales/ru/genres.ts index 04104556..d9da2aed 100644 --- a/src/locales/ru/genres.ts +++ b/src/locales/ru/genres.ts @@ -11,4 +11,6 @@ export const genres = { albumsEmpty: 'В этом жанре альбомов нет.', loadMore: 'Ещё', back: 'Назад', + shuffle: 'Перемешать', + addToQueue: 'В очередь', }; diff --git a/src/locales/zh/genres.ts b/src/locales/zh/genres.ts index 657dde10..2a5b10fa 100644 --- a/src/locales/zh/genres.ts +++ b/src/locales/zh/genres.ts @@ -9,4 +9,6 @@ export const genres = { albumsEmpty: '未找到该流派的专辑。', loadMore: '加载更多', back: '返回', + shuffle: '随机播放', + addToQueue: '添加到队列', }; diff --git a/src/pages/GenreDetail.tsx b/src/pages/GenreDetail.tsx index 0f61cced..7aee798a 100644 --- a/src/pages/GenreDetail.tsx +++ b/src/pages/GenreDetail.tsx @@ -1,16 +1,24 @@ -import { getAlbumsByGenre } from '../api/subsonicGenres'; +import { getAlbumsByGenre, fetchAllSongsByGenre } from '../api/subsonicGenres'; import type { SubsonicAlbum } from '../api/subsonicTypes'; import React, { useEffect, useState, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; -import { ArrowLeft, Disc3 } from 'lucide-react'; +import { ArrowLeft, Disc3, Play, Shuffle, ListPlus, Loader2 } from 'lucide-react'; import { useAuthStore } from '../store/authStore'; +import { usePlayerStore } from '../store/playerStore'; import AlbumCard from '../components/AlbumCard'; +import { songToTrack } from '../utils/playback/songToTrack'; +import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '../utils/playback/runBulkPlay'; import { usePerfProbeFlags } from '../utils/perf/perfFlags'; import { albumGridWarmCovers } from '../cover/layoutSizes'; import { VirtualCardGrid } from '../components/VirtualCardGrid'; const PAGE_SIZE = 50; +// Bulk play/shuffle pulls a bounded slice of the genre. The queue resolver +// (queueTrackResolver) holds a 500-entry LRU; seeding a larger queue evicts the +// earliest tracks, which then render as "…"/0:00 placeholders until lazily +// re-resolved. Keep the slice within that budget so the whole queue stays warm. +const GENRE_QUEUE_CAP = 500; export default function GenreDetail() { const { name } = useParams<{ name: string }>(); @@ -23,7 +31,27 @@ export default function GenreDetail() { const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); const [offset, setOffset] = useState(0); + const [bulkLoading, setBulkLoading] = useState(false); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); + const playTrack = usePlayerStore(s => s.playTrack); + const enqueue = usePlayerStore(s => s.enqueue); + + const fetchGenreTracks = useCallback( + () => fetchAllSongsByGenre(genre, GENRE_QUEUE_CAP).then(songs => songs.map(songToTrack)), + [genre], + ); + const handlePlayAll = useCallback( + () => runBulkPlayAll({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }), + [fetchGenreTracks, playTrack], + ); + const handleShuffleAll = useCallback( + () => runBulkShuffle({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, playTrack }), + [fetchGenreTracks, playTrack], + ); + const handleEnqueueAll = useCallback( + () => runBulkEnqueue({ fetchTracks: fetchGenreTracks, setLoading: setBulkLoading, enqueue }), + [fetchGenreTracks, enqueue], + ); useEffect(() => { setAlbums([]); @@ -70,6 +98,29 @@ export default function GenreDetail() { {t('genres.albumCount', { count: albums.length })}{hasMore ? '+' : ''} )} + {!loading && albums.length > 0 && ( +
+ + + +
+ )} {loading &&

{t('genres.albumsLoading')}

} diff --git a/src/utils/componentHelpers/runArtistDetailPlay.ts b/src/utils/componentHelpers/runArtistDetailPlay.ts index 2cd8e380..4ef0927e 100644 --- a/src/utils/componentHelpers/runArtistDetailPlay.ts +++ b/src/utils/componentHelpers/runArtistDetailPlay.ts @@ -4,6 +4,7 @@ import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists'; import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes'; import type { Track } from '../../store/playerStoreTypes'; import { songToTrack } from '../playback/songToTrack'; +import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay'; async function fetchAllTracks(albums: SubsonicAlbum[]): Promise { const results = await Promise.all(albums.map(a => getAlbum(a.id))); @@ -20,28 +21,13 @@ export interface RunArtistDetailPlayDeps { export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Promise { const { albums, setPlayAllLoading, playTrack } = deps; if (albums.length === 0) return; - setPlayAllLoading(true); - try { - const tracks = await fetchAllTracks(albums); - if (tracks.length > 0) playTrack(tracks[0], tracks); - } finally { - setPlayAllLoading(false); - } + await runBulkPlayAll({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack }); } export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise { const { albums, setPlayAllLoading, playTrack } = deps; if (albums.length === 0) return; - setPlayAllLoading(true); - try { - const tracks = await fetchAllTracks(albums); - if (tracks.length > 0) { - const shuffled = [...tracks].sort(() => Math.random() - 0.5); - playTrack(shuffled[0], shuffled); - } - } finally { - setPlayAllLoading(false); - } + await runBulkShuffle({ fetchTracks: () => fetchAllTracks(albums), setLoading: setPlayAllLoading, playTrack }); } export interface RunArtistDetailStartRadioDeps { diff --git a/src/utils/playback/runBulkPlay.test.ts b/src/utils/playback/runBulkPlay.test.ts new file mode 100644 index 00000000..0d68e0fc --- /dev/null +++ b/src/utils/playback/runBulkPlay.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { Track } from '../../store/playerStoreTypes'; +import { runBulkEnqueue, runBulkPlayAll, runBulkShuffle } from './runBulkPlay'; + +function track(id: string): Track { + return { id, title: id } as Track; +} + +describe('runBulkPlayAll', () => { + it('plays the first track with the full list as queue and toggles loading', async () => { + const tracks = [track('a'), track('b'), track('c')]; + const setLoading = vi.fn(); + const playTrack = vi.fn(); + await runBulkPlayAll({ fetchTracks: async () => tracks, setLoading, playTrack }); + expect(playTrack).toHaveBeenCalledWith(tracks[0], tracks); + expect(setLoading.mock.calls).toEqual([[true], [false]]); + }); + + it('does not start playback for an empty genre but still clears loading', async () => { + const setLoading = vi.fn(); + const playTrack = vi.fn(); + await runBulkPlayAll({ fetchTracks: async () => [], setLoading, playTrack }); + expect(playTrack).not.toHaveBeenCalled(); + expect(setLoading).toHaveBeenLastCalledWith(false); + }); + + it('clears loading even when fetching throws', async () => { + const setLoading = vi.fn(); + await expect( + runBulkPlayAll({ fetchTracks: async () => { throw new Error('boom'); }, setLoading, playTrack: vi.fn() }), + ).rejects.toThrow('boom'); + expect(setLoading).toHaveBeenLastCalledWith(false); + }); +}); + +describe('runBulkShuffle', () => { + it('plays a permutation of the same tracks, head consistent with the queue', async () => { + const tracks = Array.from({ length: 25 }, (_, i) => track(String(i))); + const playTrack = vi.fn(); + await runBulkShuffle({ fetchTracks: async () => tracks, setLoading: vi.fn(), playTrack }); + const [head, queue] = playTrack.mock.calls[0]; + expect(queue).toHaveLength(tracks.length); + expect(queue[0]).toBe(head); + expect([...queue].map(t => t.id).sort()).toEqual([...tracks].map(t => t.id).sort()); + }); +}); + +describe('runBulkEnqueue', () => { + it('enqueues all fetched tracks', async () => { + const tracks = [track('a'), track('b')]; + const enqueue = vi.fn(); + await runBulkEnqueue({ fetchTracks: async () => tracks, setLoading: vi.fn(), enqueue }); + expect(enqueue).toHaveBeenCalledWith(tracks); + }); + + it('skips enqueue when there are no tracks', async () => { + const enqueue = vi.fn(); + await runBulkEnqueue({ fetchTracks: async () => [], setLoading: vi.fn(), enqueue }); + expect(enqueue).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/playback/runBulkPlay.ts b/src/utils/playback/runBulkPlay.ts new file mode 100644 index 00000000..c16cd77b --- /dev/null +++ b/src/utils/playback/runBulkPlay.ts @@ -0,0 +1,53 @@ +import type { Track } from '../../store/playerStoreTypes'; +import { shuffleArray } from './shuffleArray'; + +/** + * Shared "play / shuffle / enqueue a fetched track list" core for detail pages whose + * tracks are loaded asynchronously (Artist, Genre, …). The caller supplies `fetchTracks`; + * everything else — loading flag, empty guard, shuffle — is uniform here so each page does + * not grow its own divergent copy. + */ +export interface RunBulkPlayDeps { + fetchTracks: () => Promise; + setLoading: (v: boolean) => void; + playTrack: (track: Track, queue: Track[]) => void; +} + +export async function runBulkPlayAll(deps: RunBulkPlayDeps): Promise { + const { fetchTracks, setLoading, playTrack } = deps; + setLoading(true); + try { + const tracks = await fetchTracks(); + if (tracks.length > 0) playTrack(tracks[0], tracks); + } finally { + setLoading(false); + } +} + +export async function runBulkShuffle(deps: RunBulkPlayDeps): Promise { + const { fetchTracks, setLoading, playTrack } = deps; + setLoading(true); + try { + const shuffled = shuffleArray(await fetchTracks()); + if (shuffled.length > 0) playTrack(shuffled[0], shuffled); + } finally { + setLoading(false); + } +} + +export interface RunBulkEnqueueDeps { + fetchTracks: () => Promise; + setLoading: (v: boolean) => void; + enqueue: (tracks: Track[]) => void; +} + +export async function runBulkEnqueue(deps: RunBulkEnqueueDeps): Promise { + const { fetchTracks, setLoading, enqueue } = deps; + setLoading(true); + try { + const tracks = await fetchTracks(); + if (tracks.length > 0) enqueue(tracks); + } finally { + setLoading(false); + } +}