mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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
This commit is contained in:
@@ -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]
|
## [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)
|
> **🙏 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)
|
||||||
|
|||||||
@@ -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 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)',
|
'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)',
|
'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)',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { useCoverArt } from '../cover/useCoverArt';
|
|||||||
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
import { useArtistCoverRef } from '../cover/useLibraryCoverRef';
|
||||||
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
import { setRating, star, unstar } from '../api/subsonicStarRating';
|
||||||
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
import type { SubsonicArtist, SubsonicAlbum, SubsonicSong, SubsonicArtistInfo } from '../api/subsonicTypes';
|
||||||
import { songToTrack } from '../utils/playback/songToTrack';
|
|
||||||
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
import { useEffect, useState, useRef, Fragment, useMemo } from 'react';
|
||||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import AlbumCard from '../components/AlbumCard';
|
import AlbumCard from '../components/AlbumCard';
|
||||||
@@ -30,8 +29,7 @@ import {
|
|||||||
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
import { useArtistDetailData } from '../hooks/useArtistDetailData';
|
||||||
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
import { useArtistSimilarArtists } from '../hooks/useArtistSimilarArtists';
|
||||||
import {
|
import {
|
||||||
fetchArtistDetailTracks,
|
runArtistDetailPlayAll, runArtistDetailPlayTopSong, runArtistDetailShuffle, runArtistDetailStartRadio,
|
||||||
runArtistDetailPlayAll, runArtistDetailShuffle, runArtistDetailStartRadio,
|
|
||||||
} from '../utils/componentHelpers/runArtistDetailPlay';
|
} from '../utils/componentHelpers/runArtistDetailPlay';
|
||||||
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
import { useOfflineBrowseContext } from '../hooks/useOfflineBrowseContext';
|
||||||
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
import { offlineActionPolicy } from '../utils/offline/offlineActionPolicy';
|
||||||
@@ -142,32 +140,14 @@ export default function ArtistDetail() {
|
|||||||
return runArtistShare({ artist, t });
|
return runArtistShare({ artist, t });
|
||||||
};
|
};
|
||||||
|
|
||||||
const playTopSongWithContinuation = async (startIndex: number) => {
|
const playTopSongWithContinuation = (startIndex: number) => runArtistDetailPlayTopSong({
|
||||||
if (!artist || albums.length === 0) return;
|
topSongs,
|
||||||
setPlayAllLoading(true);
|
albums,
|
||||||
try {
|
serverId: activeServerId,
|
||||||
// Get all artist tracks ordered by album and track number
|
startIndex,
|
||||||
const allTracks = await fetchArtistDetailTracks(albums, activeServerId);
|
setPlayAllLoading,
|
||||||
|
playTrack,
|
||||||
// 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 handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => runArtistImageUpload({
|
const handleImageUpload = (e: React.ChangeEvent<HTMLInputElement>) => runArtistImageUpload({
|
||||||
e, artist, t, setUploading, setCoverRevision,
|
e, artist, t, setUploading, setCoverRevision,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||||
import * as offlineMediaResolve from '../offline/offlineMediaResolve';
|
import * as offlineMediaResolve from '../offline/offlineMediaResolve';
|
||||||
import { fetchArtistDetailTracks } from './runArtistDetailPlay';
|
import { fetchArtistDetailTracks, runArtistDetailPlayTopSong } from './runArtistDetailPlay';
|
||||||
|
|
||||||
vi.mock('../offline/offlineMediaResolve', () => ({
|
vi.mock('../offline/offlineMediaResolve', () => ({
|
||||||
resolveAlbum: vi.fn(),
|
resolveAlbum: vi.fn(),
|
||||||
@@ -44,3 +44,58 @@ describe('fetchArtistDetailTracks', () => {
|
|||||||
expect(resolveAlbumMock).not.toHaveBeenCalled();
|
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' })],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TFunction } from 'i18next';
|
import type { TFunction } from 'i18next';
|
||||||
import { getSimilarSongs2, getTopSongs } from '../../api/subsonicArtists';
|
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 type { Track } from '../../store/playerStoreTypes';
|
||||||
import { songToTrack } from '../playback/songToTrack';
|
import { songToTrack } from '../playback/songToTrack';
|
||||||
import { runBulkPlayAll, runBulkShuffle } from '../playback/runBulkPlay';
|
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<void> {
|
||||||
|
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<void> {
|
export async function runArtistDetailShuffle(deps: RunArtistDetailPlayDeps): Promise<void> {
|
||||||
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
const { albums, serverId, setPlayAllLoading, playTrack } = deps;
|
||||||
if (albums.length === 0) return;
|
if (albums.length === 0) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user