From 1b4fb9e9b390b143bcb4229001f8baa9a2bd40b4 Mon Sep 17 00:00:00 2001 From: cucadmuh <49571317+cucadmuh@users.noreply.github.com> Date: Mon, 8 Jun 2026 13:43:49 +0300 Subject: [PATCH] fix(artist): play top tracks when album list is empty (#1031) * fix(artist): play top tracks when album list is empty on page Top-track rows silently no-op'd when albums.length was 0 (common in lossless artist view). Play the selected top songs immediately and only append catalog tracks when albums are available. * docs: note artist top tracks play fix in CHANGELOG for PR #1031 --- CHANGELOG.md | 8 +++ src/config/settingsCredits.ts | 1 + src/pages/ArtistDetail.tsx | 38 +++---------- .../runArtistDetailPlay.test.ts | 57 ++++++++++++++++++- .../componentHelpers/runArtistDetailPlay.ts | 34 ++++++++++- 5 files changed, 107 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 896c5ce4..0348abba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,6 +189,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Artist page — Top Tracks play button + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1031](https://github.com/Psychotoxical/psysonic/pull/1031)** + +* Play on **Top Tracks** rows no longer silently does nothing when the artist page has top songs but no albums loaded (e.g. lossless artist view); playback starts from the clicked track and continues into the catalog when albums are available. + + + ## [1.47.0] > **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u) diff --git a/src/config/settingsCredits.ts b/src/config/settingsCredits.ts index dfd6ce77..9b48442e 100644 --- a/src/config/settingsCredits.ts +++ b/src/config/settingsCredits.ts @@ -155,6 +155,7 @@ const CONTRIBUTOR_ENTRIES = [ 'Offline experience — unified media layout (cache/library/favorites), localPlaybackStore, library-index Offline Library, favorites auto-sync, cached album/playlist/artist pin reconcile, mixed-server offline queue, and single mediaDir setting (PR #1008)', 'Offline browse — local-bytes catalog when server is down, integration contract (context/policy/resolvers), disconnect nav fork, Home stale-cache feed, read-only context menus, PlayerBar rating/favorite guard (PR #1017)', 'Themed startup splash before Vite loads — deferred window show, per-theme logo gradient (PR #1030)', + 'Artist page: Top Tracks play when album list is empty on the page (PR #1031)', ], }, { diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 82598c84..6065746c 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -3,7 +3,6 @@ import { useCoverArt } from '../cover/useCoverArt'; import { useArtistCoverRef } from '../cover/useLibraryCoverRef'; import { setRating, star, unstar } from '../api/subsonicStarRating'; import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes'; -import { songToTrack } from '../utils/playback/songToTrack'; import { useEffect, useState, useRef, Fragment, useMemo } from 'react'; import { useParams, useNavigate, useSearchParams } from 'react-router-dom'; import AlbumCard from '../components/AlbumCard'; @@ -30,8 +29,7 @@ import { import { useArtistDetailData } from '../hooks/useArtistDetailData'; import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists'; import { - fetchArtistDetailTracks, - runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio, + runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle, runArtistDetailStartRadio, } from '../utils/componentHelpers/runArtistDetailPlay'; import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext'; import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy'; @@ -142,32 +140,14 @@ export default function ArtistDetail() { return runArtistShare({ artist, t }); }; - const playTopSongWithContinuation = async (startIndex: number) => { - if (!artist || albums.length === 0) return; - setPlayAllLoading(true); - try { - // Get all artist tracks ordered by album and track number - const allTracks = await fetchArtistDetailTracks(albums, activeServerId); - - // Top songs from clicked index onward - const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack); - - // Track IDs for deduplication - const topSongIds = new Set(topSongs.map(s => s.id)); - - // Filter remaining tracks to exclude top songs (prevent duplicates) - const remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id)); - - // Build queue: remaining top songs + rest of artist catalog - const queue = [...topTracksFromIndex, ...remainingTracks]; - - if (queue.length > 0) { - playTrack(queue[0], queue); - } - } finally { - setPlayAllLoading(false); - } - }; + const playTopSongWithContinuation = (startIndex: number) => runArtistDetailPlayTopSong({ + topSongs, + albums, + serverId: activeServerId, + startIndex, + setPlayAllLoading, + playTrack, + }); const handleImageUpload = (e: React.ChangeEvent) => runArtistImageUpload({ e, artist, t, setUploading, setCoverRevision, diff --git a/src/utils/componentHelpers/runArtistDetailPlay.test.ts b/src/utils/componentHelpers/runArtistDetailPlay.test.ts index 2f73fb07..c3ce9559 100644 --- a/src/utils/componentHelpers/runArtistDetailPlay.test.ts +++ b/src/utils/componentHelpers/runArtistDetailPlay.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { SubsonicAlbum } from '../../api/subsonicTypes'; import * as offlineMediaResolve from '../offline/offlineMediaResolve'; -import { fetchArtistDetailTracks } from './runArtistDetailPlay'; +import { fetchArtistDetailTracks, runArtistDetailPlayTopSong } from './runArtistDetailPlay'; vi.mock('../offline/offlineMediaResolve', () => ({ resolveAlbum: vi.fn(), @@ -44,3 +44,58 @@ describe('fetchArtistDetailTracks', () => { expect(resolveAlbumMock).not.toHaveBeenCalled(); }); }); + +describe('runArtistDetailPlayTopSong', () => { + const topSongs = [ + { id: 'top-1', title: 'Hit', artist: 'A', album: 'Greatest', albumId: 'al-x', duration: 200 }, + { id: 'top-2', title: 'Hit 2', artist: 'A', album: 'Greatest 2', albumId: 'al-y', duration: 180 }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('plays top tracks when the artist has no albums on the page', async () => { + const playTrack = vi.fn(); + const setPlayAllLoading = vi.fn(); + + await runArtistDetailPlayTopSong({ + topSongs, + albums: [], + serverId: 'srv-1', + startIndex: 1, + setPlayAllLoading, + playTrack, + }); + + expect(resolveAlbumMock).not.toHaveBeenCalled(); + expect(playTrack).toHaveBeenCalledWith( + expect.objectContaining({ id: 'top-2' }), + [expect.objectContaining({ id: 'top-2' })], + ); + expect(setPlayAllLoading).toHaveBeenCalledWith(true); + expect(setPlayAllLoading).toHaveBeenLastCalledWith(false); + }); + + it('continues into album tracks after the top-song block', async () => { + resolveAlbumMock.mockResolvedValueOnce({ + album: albums[1], + songs: [{ id: 'top-1', title: 'Dup', artist: 'A', album: 'A', albumId: 'al-1', duration: 100, track: 1 }], + }); + const playTrack = vi.fn(); + + await runArtistDetailPlayTopSong({ + topSongs: [topSongs[0]], + albums: [albums[1]], + serverId: 'srv-1', + startIndex: 0, + setPlayAllLoading: vi.fn(), + playTrack, + }); + + expect(playTrack).toHaveBeenCalledWith( + expect.objectContaining({ id: 'top-1' }), + [expect.objectContaining({ id: 'top-1' })], + ); + }); +}); diff --git a/src/utils/componentHelpers/runArtistDetailPlay.ts b/src/utils/componentHelpers/runArtistDetailPlay.ts index e79e4f2c..7e069eee 100644 --- a/src/utils/componentHelpers/runArtistDetailPlay.ts +++ b/src/utils/componentHelpers/runArtistDetailPlay.ts @@ -1,6 +1,6 @@ import type { TFunction } from 'i18next'; import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists'; -import type { SubsonicAlbum, SubsonicArtist } from '../../api/subsonicTypes'; +import type { SubsonicAlbum, SubsonicArtist, SubsonicSong } from '../../api/subsonicTypes'; import type { Track } from '../../store/playerStoreTypes'; import { songToTrack } from '../playback/songToTrack'; import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay'; @@ -40,6 +40,38 @@ export async function runArtistDetailPlayAll(deps: RunArtistDetailPlayDeps): Pro }); } +export interface RunArtistDetailPlayTopSongDeps { + topSongs: SubsonicSong[]; + albums: SubsonicAlbum[]; + serverId?: string | null; + startIndex: number; + setPlayAllLoading: (v: boolean) => void; + playTrack: (track: Track, queue: Track[]) => void; +} + +/** Play from a top-track row, then continue with the rest of the artist catalog when available. */ +export async function runArtistDetailPlayTopSong(deps: RunArtistDetailPlayTopSongDeps): Promise { + const { topSongs, albums, serverId, startIndex, setPlayAllLoading, playTrack } = deps; + if (topSongs.length === 0 || startIndex < 0 || startIndex >= topSongs.length) return; + + setPlayAllLoading(true); + try { + const topTracksFromIndex = topSongs.slice(startIndex).map(songToTrack); + const topSongIds = new Set(topSongs.map(s => s.id)); + + let remainingTracks: Track[] = []; + if (albums.length > 0) { + const allTracks = await fetchArtistDetailTracks(albums, serverId); + remainingTracks = allTracks.filter(tr => !topSongIds.has(tr.id)); + } + + const queue = [...topTracksFromIndex, ...remainingTracks]; + if (queue.length > 0) playTrack(queue[0], queue); + } finally { + setPlayAllLoading(false); + } +} + export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise { const { albums, serverId, setPlayAllLoading, playTrack } = deps; if (albums.length === 0) return;