feat(artist): add "add to queue" for the whole discography (#1321)

* feat(artist): enqueue whole discography from artist page

* i18n(artist): add enqueue-all button strings

* docs(changelog): artist discography enqueue (#1321)
This commit is contained in:
Psychotoxical
2026-07-17 12:37:25 +02:00
committed by GitHub
parent 6759ee40e2
commit 7dfe58dba6
20 changed files with 111 additions and 5 deletions
+6
View File
@@ -139,6 +139,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The header search field on the Playlists page now filters the list by playlist name (same scoped badge pattern as Artists / Albums), including in folder view.
### Artist page — add the whole discography to the queue
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1321](https://github.com/Psychotoxical/psysonic/pull/1321)**
* A new queue button on the artist page appends the artist's entire discography to the current queue in one click, next to Play all and Shuffle — matching what album pages already offer.
## Changed
+1
View File
@@ -430,6 +430,7 @@ const CONTRIBUTOR_ENTRIES = [
'Settings → System: community theme authors credited in a Themes sub-section; theme card What\'s new shows the latest version only (PR #1248)',
'Fullscreen player style — selectable Minimal or Immersive view, with the artist backdrop, cover-derived accent, and rail/Apple lyrics (PR #1249)',
'Word-by-word lyrics from the server via OpenSubsonic songLyrics v2, no third-party backend needed (PR #1265)',
'Artist page — add the whole discography to the queue (PR #1321)',
],
},
{
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
import { useAlbumDetailBack } from '@/features/album';
import {
ArrowLeft, Camera, Check, HardDriveDownload, Heart,
Loader2, Play, Radio, Share2, Shuffle, Users,
ListPlus, Loader2, Play, Radio, Share2, Shuffle, Users,
} from 'lucide-react';
import type { SubsonicAlbum, SubsonicArtist, SubsonicArtistInfo } from '@/lib/api/subsonicTypes';
import { useOfflineStore } from '@/features/offline';
@@ -35,6 +35,7 @@ interface Props {
toggleStar: () => Promise<void>;
handlePlayAll: () => void;
handleShuffle: () => void;
handleEnqueueAll: () => void;
handleStartRadio: () => void;
handleShareArtist: () => void;
handleImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => Promise<void>;
@@ -100,7 +101,7 @@ function ArtistHeaderBg({ url, position }: { url: string; position?: string }) {
export default function ArtistDetailHero({
artist, id, albums, info, isStarred, artistEntityRating, handleArtistEntityRating,
toggleStar, handlePlayAll, handleShuffle, handleStartRadio, handleShareArtist,
toggleStar, handlePlayAll, handleShuffle, handleEnqueueAll, handleStartRadio, handleShareArtist,
handleImageUpload, playAllLoading, radioLoading, uploading,
openedLink, openLink,
coverId, coverRef, coverRevision, headerCoverFailed, setHeaderCoverFailed,
@@ -302,6 +303,15 @@ export default function ArtistDetailHero({
{playAllLoading ? <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> : <Shuffle size={16} />}
{!isMobile && <span className="compact-btn-label">{t('artistDetail.shuffle')}</span>}
</button>
<button
className="btn btn-surface"
onClick={handleEnqueueAll}
disabled={playAllLoading}
aria-label={t('artistDetail.enqueue')}
{...tooltipAttrs(t('artistDetail.enqueueTooltip'))}
>
<ListPlus size={16} />
</button>
</>
)}
<button
+6 -1
View File
@@ -18,7 +18,8 @@ import {
import { useArtistDetailData } from '@/features/artist/hooks/useArtistDetailData';
import { useArtistSimilarArtists } from '@/features/artist/hooks/useArtistSimilarArtists';
import {
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle, runArtistDetailStartRadio,
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle,
runArtistDetailStartRadio, runArtistDetailEnqueueAll,
} from '@/features/artist/utils/runArtistDetailPlay';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
@@ -113,6 +114,9 @@ export default function ArtistDetail() {
const handleShuffle = () => runArtistDetailShuffle({
albums, serverId: activeServerId, setPlayAllLoading, playTrack,
});
const handleEnqueueAll = () => runArtistDetailEnqueueAll({
albums, serverId: activeServerId, setPlayAllLoading, enqueue,
});
const handleStartRadio = () => {
if (!artist) return;
return runArtistDetailStartRadio({ artist, t, setRadioLoading, playTrack, enqueue });
@@ -270,6 +274,7 @@ export default function ArtistDetail() {
toggleStar={toggleStar}
handlePlayAll={handlePlayAll}
handleShuffle={handleShuffle}
handleEnqueueAll={handleEnqueueAll}
handleStartRadio={handleStartRadio}
handleShareArtist={handleShareArtist}
handleImageUpload={handleImageUpload}
@@ -1,7 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import * as offlineMediaResolve from '@/features/offline';
import { fetchArtistDetailTracks, runArtistDetailPlayTopSong } from '@/features/artist/utils/runArtistDetailPlay';
import {
fetchArtistDetailTracks, runArtistDetailPlayTopSong, runArtistDetailEnqueueAll,
} from '@/features/artist/utils/runArtistDetailPlay';
vi.mock('@/features/offline', () => ({
resolveAlbum: vi.fn(),
@@ -99,3 +101,39 @@ describe('runArtistDetailPlayTopSong', () => {
);
});
});
describe('runArtistDetailEnqueueAll', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('enqueues the whole ordered discography', async () => {
resolveAlbumMock
.mockResolvedValueOnce({
album: albums[1],
songs: [{ id: 't1', title: 'One', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 1 }],
})
.mockResolvedValueOnce({
album: albums[0],
songs: [{ id: 't2', title: 'Two', artist: 'A', album: 'B', albumId: 'al-2', duration: 100, track: 1 }],
});
const enqueue = vi.fn();
const setPlayAllLoading = vi.fn();
await runArtistDetailEnqueueAll({ albums, serverId: 'srv-1', setPlayAllLoading, enqueue });
expect(enqueue).toHaveBeenCalledTimes(1);
expect(enqueue.mock.calls[0][0].map((t: { id: string }) => t.id)).toEqual(['t1', 't2']);
expect(setPlayAllLoading).toHaveBeenCalledWith(true);
expect(setPlayAllLoading).toHaveBeenLastCalledWith(false);
});
it('is a no-op with no albums', async () => {
const enqueue = vi.fn();
await runArtistDetailEnqueueAll({ albums: [], serverId: 'srv-1', setPlayAllLoading: vi.fn(), enqueue });
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(enqueue).not.toHaveBeenCalled();
});
});
@@ -3,7 +3,7 @@ import { getSimilarSongs2, getTopSongs } from '@/lib/api/subsonicArtists';
import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { runBulkPlayAll, runBulkShuffle } from '@/features/playback/utils/playback/runBulkPlay';
import { runBulkPlayAll, runBulkShuffle, runBulkEnqueue } from '@/features/playback/utils/playback/runBulkPlay';
import { resolveAlbum, resolveMediaServerId } from '@/features/offline';
/** Ordered artist discography tracks for play-all / shuffle (network or local bytes). */
@@ -82,6 +82,24 @@ export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Pro
});
}
export interface RunArtistDetailEnqueueDeps {
albums: SubsonicAlbum[];
serverId?: string | null;
setPlayAllLoading: (v: boolean) => void;
enqueue: (tracks: Track[]) => void;
}
/** Append the whole artist discography to the play queue. */
export async function runArtistDetailEnqueueAll(deps: RunArtistDetailEnqueueDeps): Promise<void> {
const { albums, serverId, setPlayAllLoading, enqueue } = deps;
if (albums.length === 0) return;
await runBulkEnqueue({
fetchTracks: () => fetchArtistDetailTracks(albums, serverId),
setLoading: setPlayAllLoading,
enqueue,
});
}
export interface RunArtistDetailStartRadioDeps {
artist: SubsonicArtist;
t: TFunction;
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Пусни цялата дискография',
shuffleTooltip: 'Разбъркай цялата дискография',
radioTooltip: 'Стартирай радио на базата на този изпълнител',
enqueue: 'Добави в опашката',
enqueueTooltip: 'Добави цялата дискография в опашката',
loading: 'Зареждане…',
noRadio: 'Няма намерени подобни песни за този изпълнител.',
notFound: 'Изпълнителят не е намерен.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Gesamte Diskografie abspielen',
shuffleTooltip: 'Gesamte Diskografie zufällig abspielen',
radioTooltip: 'Radio basierend auf diesem Künstler starten',
enqueue: 'Einreihen',
enqueueTooltip: 'Gesamte Diskografie zur Warteschlange hinzufügen',
loading: 'Lädt…',
noRadio: 'Keine ähnlichen Titel für diesen Künstler gefunden.',
notFound: 'Künstler nicht gefunden.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Play the whole discography',
shuffleTooltip: 'Shuffle the whole discography',
radioTooltip: 'Start a radio based on this artist',
enqueue: 'Enqueue',
enqueueTooltip: 'Add the whole discography to queue',
loading: 'Loading…',
noRadio: 'No similar tracks found for this artist.',
notFound: 'Artist not found.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Reproducir toda la discografía',
shuffleTooltip: 'Reproducir toda la discografía en aleatorio',
radioTooltip: 'Iniciar una radio basada en este artista',
enqueue: 'Agregar a la Cola',
enqueueTooltip: 'Agregar toda la discografía a la cola',
loading: 'Cargando…',
noRadio: 'No se encontraron pistas similares para este artista.',
notFound: 'Artista no encontrado.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Lire toute la discographie',
shuffleTooltip: 'Lire toute la discographie en aléatoire',
radioTooltip: 'Démarrer une radio basée sur cet artiste',
enqueue: 'Mettre en file',
enqueueTooltip: 'Ajouter toute la discographie à la file d\'attente',
loading: 'Chargement…',
noRadio: 'Aucun morceau similaire trouvé pour cet artiste.',
notFound: 'Artiste introuvable.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'A teljes diszkográfia lejátszása',
shuffleTooltip: 'A teljes diszkográfia keverése',
radioTooltip: 'Rádió indítása az előadó alapján',
enqueue: 'Sorba',
enqueueTooltip: 'A teljes diszkográfia hozzáadása a sorhoz',
loading: 'Betöltés…',
noRadio: 'Nem található hasonló szám ehhez az előadóhoz.',
notFound: 'Az előadó nem található.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Ascolta tutta la discografia',
shuffleTooltip: 'Mescola tutta la discografia',
radioTooltip: 'Crea una radio basata su questo artista',
enqueue: 'Accoda',
enqueueTooltip: 'Aggiungi tutta la discografia alla coda',
loading: 'Caricamento…',
noRadio: 'Non sono stati trovati brani simili per questo artista.',
notFound: 'Artista non trovato.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'ディスコグラフィ全体を再生',
shuffleTooltip: 'ディスコグラフィ全体をシャッフル',
radioTooltip: 'このアーティストをもとにラジオを開始',
enqueue: 'キューに追加',
enqueueTooltip: 'ディスコグラフィ全体をキューに追加',
loading: '読み込み中…',
noRadio: 'このアーティストの類似トラックは見つかりませんでした。',
notFound: 'アーティストが見つかりません。',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Spill hele diskografien',
shuffleTooltip: 'Spill hele diskografien i tilfeldig rekkefølge',
radioTooltip: 'Start en radio basert på denne artisten',
enqueue: 'Legg i kø',
enqueueTooltip: 'Legg hele diskografien i kø',
loading: 'Laster…',
noRadio: 'Ingen lignende spor funnet for denne artisten.',
notFound: 'Artisten ble ikke funnet.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Volledige discografie afspelen',
shuffleTooltip: 'Volledige discografie willekeurig afspelen',
radioTooltip: 'Radio op basis van deze artiest starten',
enqueue: 'In wachtrij',
enqueueTooltip: 'Volledige discografie aan wachtrij toevoegen',
loading: 'Laden…',
noRadio: 'Geen vergelijkbare nummers gevonden voor deze artiest.',
notFound: 'Artiest niet gevonden.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Odtwórz całą dyskografię',
shuffleTooltip: 'Przetasuj całą dyskografię',
radioTooltip: 'Rozpocznij radio w oparciu o tego wykonawcę',
enqueue: 'Dodaj do kolejki',
enqueueTooltip: 'Dodaj całą dyskografię do kolejki',
loading: 'Ładowanie…',
noRadio: 'Nie znaleziono podobnych utworów dla tego wykonawcy.',
notFound: 'Wykonawca nie znaleziony.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Redă întreaga discografie',
shuffleTooltip: 'Redă întreaga discografie amestecat',
radioTooltip: 'Pornește un radio bazat pe acest artist',
enqueue: 'Adaugă în coadă',
enqueueTooltip: 'Adaugă întreaga discografie în coadă',
loading: 'Se încarcă…',
noRadio: 'Nicio piesă similară găsită pentru acest artist.',
notFound: 'Artistul nu a fost găsit.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: 'Воспроизвести всю дискографию',
shuffleTooltip: 'Воспроизвести всю дискографию вперемешку',
radioTooltip: 'Запустить радио на основе этого исполнителя',
enqueue: 'В очередь',
enqueueTooltip: 'Добавить всю дискографию в очередь',
loading: 'Загрузка…',
noRadio: 'Похожие треки для этого исполнителя не найдены.',
notFound: 'Исполнитель не найден.',
+2
View File
@@ -11,6 +11,8 @@ export const artistDetail = {
playAllTooltip: '播放全部作品',
shuffleTooltip: '随机播放全部作品',
radioTooltip: '基于该艺人开始电台',
enqueue: '加入队列',
enqueueTooltip: '将全部作品添加到播放队列',
loading: '加载中…',
noRadio: '未找到该艺术家的相似曲目。',
notFound: '未找到艺术家。',