mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
* fix(library): show album artist in album grids (#1056) Prefer album-artist tags over track artist when building album rows from the local index, and align grid cards with OpenSubsonic displayArtist. * fix(library): align album artist in FTS, search, and offline paths (#1056) Apply album-artist preference in FTS album dedupe and live search, fix offline pin hydration order, and use albumArtistDisplayName in remaining cheap UI/export/download call sites. * docs(changelog): album artist grid fix for compilations (PR #1057) * fix(library): parity guard and live-search album artist helper (#1056) Align SQL ELSE branch with pick_album_group_artist trimming, add parity test, and use albumArtistDisplayName in LiveSearch and MobileSearchOverlay.
This commit is contained in:
@@ -21,7 +21,7 @@ import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { useDragDrop } from '../contexts/DragDropContext';
|
||||
import { isAlbumRecentlyAdded } from '../utils/albumRecency';
|
||||
import { deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { albumArtistDisplayName, deriveAlbumArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { coverServerScopeForServerId } from '../cover/serverScope';
|
||||
import { appendServerQuery } from '../utils/navigation/detailServerScope';
|
||||
|
||||
@@ -93,6 +93,7 @@ function AlbumCard({
|
||||
}, [coverRef, displayCssPx]);
|
||||
const isNewAlbum = isAlbumRecentlyAdded(album.created);
|
||||
const artistRefs = useMemo(() => deriveAlbumArtistRefs(album), [album]);
|
||||
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
|
||||
|
||||
const handleClick = (opts?: { shiftKey?: boolean }) => {
|
||||
if (selectionMode) { onToggleSelect?.(album.id, opts); return; }
|
||||
@@ -105,7 +106,7 @@ function AlbumCard({
|
||||
onClick={e => handleClick({ shiftKey: e.shiftKey })}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${album.name} von ${album.artist}`}
|
||||
aria-label={`${album.name} von ${artistLabel}`}
|
||||
onKeyDown={e => e.key === 'Enter' && handleClick()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -213,7 +214,7 @@ function AlbumCard({
|
||||
<p className="album-card-artist truncate">
|
||||
<OpenArtistRefInline
|
||||
refs={artistRefs}
|
||||
fallbackName={album.artist}
|
||||
fallbackName={artistLabel}
|
||||
onGoArtist={id => navigate(`/artist/${id}`)}
|
||||
as="none"
|
||||
linkTag="span"
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { formatHumanHoursMinutes } from '../utils/format/formatHumanDuration';
|
||||
import AlbumRow from './AlbumRow';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
|
||||
const ANCHOR_HISTORY_KEY_PREFIX = 'psysonic_because_anchor_history:';
|
||||
const PICKS_HISTORY_KEY_PREFIX = 'psysonic_because_picks:';
|
||||
@@ -599,6 +600,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
||||
});
|
||||
const imgSrc = coverImgSrc(coverHandle.src);
|
||||
const bgResolved = coverHandle.src;
|
||||
const artistLabel = useMemo(() => albumArtistDisplayName(album), [album]);
|
||||
const handleOpen = () => navigate(`/album/${album.id}`);
|
||||
const handleEnqueue = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
@@ -620,7 +622,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
||||
className={`because-card${enter ? ' because-card--slot-enter' : ''}`}
|
||||
onClick={handleOpen}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleOpen(); }}
|
||||
aria-label={`${album.name} – ${album.artist}`}
|
||||
aria-label={`${album.name} – ${artistLabel}`}
|
||||
>
|
||||
{!disableArtwork && bgResolved && (
|
||||
<div
|
||||
@@ -683,7 +685,7 @@ const BecauseCard = memo(function BecauseCard({ album, anchor, disableArtwork, e
|
||||
{t('home.similarTo', { artist: anchor })}
|
||||
</div>
|
||||
<div className="because-card-title">{album.name}</div>
|
||||
<div className="because-card-artist">{album.artist}</div>
|
||||
<div className="because-card-artist">{artistLabel}</div>
|
||||
</div>
|
||||
{album.releaseTypes && album.releaseTypes[0] ? (
|
||||
<div className="because-card-pills">
|
||||
|
||||
@@ -19,6 +19,7 @@ import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from './LongPressWaveOverlay';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
|
||||
const INTERVAL_MS = 10000;
|
||||
const HERO_ALBUM_COUNT = 8;
|
||||
@@ -266,6 +267,10 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
}, [albums.length, startTimer]);
|
||||
|
||||
const album = albums[activeIdx] ?? null;
|
||||
const heroArtistLabel = useMemo(
|
||||
() => (album ? albumArtistDisplayName(album) : ''),
|
||||
[album],
|
||||
);
|
||||
|
||||
// Lazily fetch format label for the currently-visible album (cached by id)
|
||||
const [albumFormats, setAlbumFormats] = useState<Record<string, string>>({});
|
||||
@@ -335,7 +340,7 @@ export default function Hero({ albums: albumsProp }: HeroProps = {}) {
|
||||
<div className="hero-text">
|
||||
<span className="hero-eyebrow">{t('hero.eyebrow')}</span>
|
||||
<h2 className="hero-title">{album.name}</h2>
|
||||
<p className="hero-artist">{album.artist}</p>
|
||||
<p className="hero-artist">{heroArtistLabel}</p>
|
||||
<div className="hero-meta">
|
||||
{album.year && <span className="badge">{album.year}</span>}
|
||||
{album.genre && <span className="badge">{album.genre}</span>}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { usePlayerStore } from '../store/playerStore';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { FETCH_QUEUE_BIAS_SEARCH_ARTIST_OVER_ALBUM } from './CachedImage';
|
||||
import type { SubsonicSong } from '../api/subsonicTypes';
|
||||
import { AlbumCoverArtImage } from '../cover/AlbumCoverArtImage';
|
||||
@@ -775,7 +776,7 @@ export default function LiveSearch() {
|
||||
)}
|
||||
<div>
|
||||
<div className="search-result-name">{a.name}</div>
|
||||
<div className="search-result-sub">{a.artist}</div>
|
||||
<div className="search-result-sub">{albumArtistDisplayName(a)}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ArtistCoverArtImage } from '../cover/ArtistCoverArtImage';
|
||||
import { CoverArtImage } from '../cover/CoverArtImage';
|
||||
import { albumCoverRefForSong } from '../cover/ref';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { useShareSearch } from '../hooks/useShareSearch';
|
||||
import ShareSearchResults from './search/ShareSearchResults';
|
||||
import {
|
||||
@@ -383,7 +384,7 @@ export default function MobileSearchOverlay({ onClose }: { onClose: () => void }
|
||||
)}
|
||||
<div className="mobile-search-item-info">
|
||||
<span className="mobile-search-item-title">{a.name}</span>
|
||||
<span className="mobile-search-item-sub">{a.artist}</span>
|
||||
<span className="mobile-search-item-sub">{albumArtistDisplayName(a)}</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="mobile-search-item-chevron" />
|
||||
</button>
|
||||
|
||||
@@ -38,7 +38,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { showToast } from '../utils/ui/toast';
|
||||
import { useSelectionStore } from '../store/selectionStore';
|
||||
import { sanitizeFilename } from '../utils/componentHelpers/albumDetailHelpers';
|
||||
import { deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { albumArtistDisplayName, deriveAlbumHeaderArtistRefs } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { usePerfProbeFlags } from '../utils/perf/perfFlags';
|
||||
import { albumGridWarmCovers } from '../cover/layoutSizes';
|
||||
import { VirtualCardGrid } from '../components/VirtualCardGrid';
|
||||
@@ -295,7 +295,7 @@ const handleShuffleAll = () => {
|
||||
}
|
||||
}
|
||||
if (isOfflinePinComplete(album.album.id, serverId, songs.map(s => s.id))) return;
|
||||
downloadAlbum(album.album.id, album.album.name, album.album.artist, album.album.coverArt, album.album.year, songs, serverId);
|
||||
downloadAlbum(album.album.id, album.album.name, albumArtistDisplayName(album.album), album.album.coverArt, album.album.year, songs, serverId);
|
||||
}, [album, downloadAlbum, serverId, effectiveSongs, losslessOnly, resolvedOfflineStatus]);
|
||||
|
||||
const handleRemoveOffline = () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
|
||||
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
||||
import { peekAlbumBrowseScrollRestore } from '../store/albumBrowseSessionStore';
|
||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { useAlbumCatalogYearBounds } from '../hooks/useAlbumCatalogYearBounds';
|
||||
import type { AlbumBrowseSort } from '../utils/library/albumBrowseSort';
|
||||
import { LOSSLESS_MODE_QUERY } from '../utils/library/losslessMode';
|
||||
@@ -281,7 +282,7 @@ export default function Albums() {
|
||||
try {
|
||||
const detail = await resolveAlbum(serverId, album.id);
|
||||
if (!detail) throw new Error('album unavailable');
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
|
||||
@@ -28,6 +28,7 @@ import { useInpageScrollViewport } from '../hooks/useInpageScrollViewport';
|
||||
import InpageScrollSentinel from '../components/InpageScrollSentinel';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import SortDropdown from '../components/SortDropdown';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import {
|
||||
albumBrowseSortForServer,
|
||||
useAlbumBrowseSessionStore,
|
||||
@@ -252,7 +253,7 @@ export default function LosslessAlbums() {
|
||||
try {
|
||||
const detail = await resolveAlbum(serverId, album.id);
|
||||
if (!detail) throw new Error('album unavailable');
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
|
||||
@@ -13,6 +13,7 @@ import { playAlbum, playAlbumShuffled } from '../utils/playback/playAlbum';
|
||||
import { useLongPressAction } from '../hooks/useLongPressAction';
|
||||
import { LongPressWaveOverlay } from '../components/LongPressWaveOverlay';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
@@ -256,7 +257,7 @@ export default function MostPlayed() {
|
||||
className="mp-album-artist truncate track-artist-link"
|
||||
onClick={e => { e.stopPropagation(); navigate(`/artist/${album.artistId}`); }}
|
||||
>
|
||||
{album.artist}
|
||||
{albumArtistDisplayName(album)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mp-album-actions">
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useAlbumBrowseScrollReset } from '../hooks/useAlbumBrowseScrollReset';
|
||||
import { useBrowseAlbumTextSearch } from '../hooks/useBrowseAlbumTextSearch';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
import { useLibraryIndexStore } from '../store/libraryIndexStore';
|
||||
import { filterAlbumsByGenres } from '../utils/library/albumBrowseFilters';
|
||||
import { useScopedBrowseSearchQuery } from '../store/liveSearchScopeStore';
|
||||
@@ -160,7 +161,7 @@ export default function NewReleases() {
|
||||
try {
|
||||
const detail = await resolveAlbum(serverId, album.id);
|
||||
if (!detail) throw new Error('album unavailable');
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
|
||||
@@ -36,6 +36,7 @@ import { useAlbumGridBrowseFilters, type AlbumGridBrowseSnapshot } from '../hook
|
||||
import { useAlbumBrowseScrollRestore } from '../hooks/useAlbumBrowseScrollRestore';
|
||||
import { useAlbumBrowseScrollSnapshotSync, type AlbumBrowseScrollSnapshot } from '../hooks/useAlbumBrowseFilters';
|
||||
import { readAlbumBrowseRestore } from '../utils/navigation/albumDetailNavigation';
|
||||
import { albumArtistDisplayName } from '../utils/album/deriveAlbumHeaderArtistRefs';
|
||||
|
||||
const ALBUM_COUNT = 30;
|
||||
/** Extra pool when mix rating filter is on so we can still fill the grid after filtering. */
|
||||
@@ -202,7 +203,7 @@ export default function RandomAlbums() {
|
||||
try {
|
||||
const detail = await resolveAlbum(serverId, album.id);
|
||||
if (!detail) throw new Error('album unavailable');
|
||||
downloadAlbum(album.id, album.name, album.artist, album.coverArt, album.year, detail.songs, serverId);
|
||||
downloadAlbum(album.id, album.name, albumArtistDisplayName(album), album.coverArt, album.year, detail.songs, serverId);
|
||||
queued++;
|
||||
} catch {
|
||||
showToast(t('albums.offlineFailed', { name: album.name }), 3000, 'error');
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { deriveAlbumArtistRefs, deriveAlbumHeaderArtistRefs } from './deriveAlbumHeaderArtistRefs';
|
||||
import {
|
||||
albumArtistDisplayName,
|
||||
deriveAlbumArtistRefs,
|
||||
deriveAlbumHeaderArtistRefs,
|
||||
} from './deriveAlbumHeaderArtistRefs';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { makeSubsonicSong } from '@/test/helpers/factories';
|
||||
|
||||
@@ -37,6 +41,16 @@ describe('deriveAlbumArtistRefs', () => {
|
||||
};
|
||||
expect(deriveAlbumArtistRefs(album)).toEqual([{ id: 'a1', name: 'Solo' }]);
|
||||
});
|
||||
|
||||
it('prefers OpenSubsonic displayArtist over legacy artist', () => {
|
||||
const album: SubsonicAlbum = {
|
||||
...baseAlbum(),
|
||||
artist: 'Groove Armada',
|
||||
displayArtist: 'Underworld',
|
||||
};
|
||||
expect(deriveAlbumArtistRefs(album)).toEqual([{ id: 'ar-first', name: 'Underworld' }]);
|
||||
expect(albumArtistDisplayName(album)).toBe('Underworld');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveAlbumHeaderArtistRefs', () => {
|
||||
|
||||
@@ -13,11 +13,21 @@ function nonEmpty(refs: SubsonicOpenArtistRef[]): refs is SubsonicOpenArtistRef[
|
||||
export function deriveAlbumArtistRefs(album: SubsonicAlbum): SubsonicOpenArtistRef[] {
|
||||
const albumArtists = coerceOpenArtistRefs(album.artists);
|
||||
if (nonEmpty(albumArtists)) return albumArtists;
|
||||
const name = album.artist?.trim() || '—';
|
||||
const display = album.displayArtist?.trim();
|
||||
const legacy = album.artist?.trim();
|
||||
const name = display || legacy || '—';
|
||||
const id = album.artistId?.trim();
|
||||
return id ? [{ id, name }] : [{ name }];
|
||||
}
|
||||
|
||||
/** Single-line album-artist label for cards and rails (matches `deriveAlbumArtistRefs`). */
|
||||
export function albumArtistDisplayName(album: SubsonicAlbum): string {
|
||||
const parts = deriveAlbumArtistRefs(album)
|
||||
.map(r => r.name?.trim() ?? '')
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenSubsonic album credits for the album-detail header.
|
||||
* Prefer the album's `artists` array, then any child song's `albumArtists`
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getAlbumList } from '../../api/subsonicLibrary';
|
||||
import { coverArtRef } from '../../cover/ref';
|
||||
import { loadCoverBlobForExport } from '../../cover/integrations/export';
|
||||
import type { SubsonicAlbum } from '../../api/subsonicTypes';
|
||||
import { albumArtistDisplayName } from '../album/deriveAlbumHeaderArtistRefs';
|
||||
import { writeFile } from '@tauri-apps/plugin-fs';
|
||||
import { downloadDir, join } from '@tauri-apps/api/path';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
@@ -190,7 +191,7 @@ async function renderPage(
|
||||
const lineY = coverY + COVER_SIZE / 2 + 6;
|
||||
const sep = ' — ';
|
||||
|
||||
const artistClamp = clampText(ctx, album.artist, TEXT_W * 0.42);
|
||||
const artistClamp = clampText(ctx, albumArtistDisplayName(album), TEXT_W * 0.42);
|
||||
const artistW = ctx.measureText(artistClamp).width;
|
||||
const sepW = ctx.measureText(sep).width;
|
||||
const remaining = TEXT_W - artistW - sepW;
|
||||
|
||||
@@ -276,7 +276,10 @@ export async function hydrateOfflineLibraryCards(
|
||||
? (group.pinSource.displayName ?? first?.artist ?? '')
|
||||
: pinKind === 'playlist'
|
||||
? ''
|
||||
: (first?.artist ?? first?.albumArtist ?? '');
|
||||
: (pinnedMeta?.artist?.trim()
|
||||
|| first?.albumArtist?.trim()
|
||||
|| first?.artist?.trim()
|
||||
|| '');
|
||||
|
||||
let coverArt: string | undefined;
|
||||
let coverQuadIds: (string | null)[] | undefined;
|
||||
|
||||
@@ -63,7 +63,7 @@ export function buildAlbumFromTracks(
|
||||
return {
|
||||
id: albumId,
|
||||
name: first.album ?? albumId,
|
||||
artist: first.artist ?? first.albumArtist ?? '',
|
||||
artist: first.albumArtist ?? first.artist ?? '',
|
||||
artistId: first.artistId ?? '',
|
||||
coverArt: resolveTrackCoverArtId(first) ?? albumId,
|
||||
year: first.year ?? undefined,
|
||||
|
||||
Reference in New Issue
Block a user