feat(album): preserve scoped detail ownership

This commit is contained in:
cucadmuh
2026-07-19 22:57:48 +03:00
parent fbb4546c85
commit ca1980005e
23 changed files with 715 additions and 212 deletions
@@ -1553,9 +1553,9 @@ fn lookup_artist_key(
.map(Option::flatten)
}
/// Caller must pre-sort `candidates` by scope priority (lowest index first).
fn merge_album_by_priority(candidates: &[LibraryAlbumDto]) -> LibraryAlbumDto {
let mut out = candidates.first().cloned().unwrap_or_else(|| LibraryAlbumDto {
/// Caller must pre-sort `candidates` by full scope-pair priority (lowest index first).
fn priority_album_candidate(candidates: &[LibraryAlbumDto]) -> LibraryAlbumDto {
candidates.first().cloned().unwrap_or_else(|| LibraryAlbumDto {
server_id: String::new(),
id: String::new(),
name: String::new(),
@@ -1569,22 +1569,67 @@ fn merge_album_by_priority(candidates: &[LibraryAlbumDto]) -> LibraryAlbumDto {
starred_at: None,
synced_at: 0,
raw_json: Value::Null,
});
for c in candidates.iter().skip(1) {
merge_optional_text(&mut out.name, &c.name);
merge_optional(&mut out.artist, &c.artist);
merge_optional(&mut out.artist_id, &c.artist_id);
merge_optional(&mut out.genre, &c.genre);
merge_optional(&mut out.cover_art_id, &c.cover_art_id);
merge_optional_i64(&mut out.year, c.year);
merge_optional_i64(&mut out.starred_at, c.starred_at);
merge_optional_i64(&mut out.song_count, c.song_count);
merge_optional_i64(&mut out.duration_sec, c.duration_sec);
if out.synced_at < c.synced_at {
out.synced_at = c.synced_at;
}
}
out
})
}
fn overlay_priority_album_row(
conn: &rusqlite::Connection,
album: &mut LibraryAlbumDto,
) -> rusqlite::Result<()> {
let row = conn
.query_row(
"SELECT name, artist, artist_id, song_count, duration_sec, year, genre, cover_art_id, \
starred_at, synced_at, raw_json \
FROM album WHERE server_id = ?1 AND id = ?2",
rusqlite::params![&album.server_id, &album.id],
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, Option<String>>(1)?,
r.get::<_, Option<String>>(2)?,
r.get::<_, Option<i64>>(3)?,
r.get::<_, Option<i64>>(4)?,
r.get::<_, Option<i64>>(5)?,
r.get::<_, Option<String>>(6)?,
r.get::<_, Option<String>>(7)?,
r.get::<_, Option<i64>>(8)?,
r.get::<_, i64>(9)?,
r.get::<_, Option<String>>(10)?,
))
},
)
.optional()?;
let Some((
name,
artist,
artist_id,
song_count,
duration_sec,
year,
genre,
cover_art_id,
starred_at,
synced_at,
raw_json,
)) = row
else {
return Ok(());
};
album.name = name;
album.artist = artist;
album.artist_id = artist_id;
album.song_count = song_count;
album.duration_sec = duration_sec;
album.year = year;
album.genre = genre;
album.cover_art_id = cover_art_id;
album.starred_at = starred_at;
album.synced_at = synced_at;
album.raw_json = raw_json
.as_deref()
.and_then(|raw| serde_json::from_str(raw).ok())
.unwrap_or(Value::Null);
Ok(())
}
fn merge_optional_text(dst: &mut String, src: &str) {
@@ -1756,9 +1801,9 @@ pub fn album_detail(
.into_iter()
.map(|(_, album)| album)
.collect();
let mut album = merge_album_by_priority(&albums);
album.starred_at =
read_album_starred_at(conn, server_id, album_id).unwrap_or(None);
let mut album = priority_album_candidate(&albums);
overlay_priority_album_row(conn, &mut album)?;
album.starred_at = read_album_starred_at(conn, &album.server_id, &album.id).unwrap_or(None);
let tracks = fetch_scope_deduped_tracks_for_album_key(
conn,
scopes,
@@ -2680,7 +2725,7 @@ mod tests {
}
#[test]
fn album_detail_aggregates_metadata_by_priority() {
fn album_detail_keeps_priority_owner_metadata_coherent() {
let store = LibraryStore::open_in_memory();
seed_and_rebuild(
&store,
@@ -2725,8 +2770,8 @@ mod tests {
)
.unwrap();
assert_eq!(detail.album.year, Some(2001));
assert_eq!(detail.album.genre.as_deref(), Some("Jazz"));
assert_eq!(detail.album.cover_art_id.as_deref(), Some("cov-b"));
assert_eq!(detail.album.genre, None);
assert_eq!(detail.album.cover_art_id, None);
assert_eq!(detail.tracks.len(), 1);
}
@@ -2788,7 +2833,7 @@ mod tests {
}
#[test]
fn album_detail_star_reads_anchor_album_id() {
fn album_detail_star_reads_priority_owner_album_id() {
let store = LibraryStore::open_in_memory();
seed_and_rebuild(
&store,
@@ -2838,14 +2883,61 @@ mod tests {
&store,
&LibraryScopeAlbumDetailRequest {
scopes: vec![scope_pair("s1", "lib-a"), scope_pair("s1", "lib-b")],
album_id: "alb-a".into(),
album_id: "alb-b".into(),
server_id: "s1".into(),
},
)
.unwrap();
assert_eq!(detail.album.id, "alb-a");
assert_eq!(detail.album.starred_at, Some(1111));
}
#[test]
fn album_detail_preserves_priority_owner_raw_json() {
let store = LibraryStore::open_in_memory();
seed_and_rebuild(
&store,
&[
track(
"s1", "t-a1", "Song", Some("Artist"), "Album", "alb-a",
Some("art1"), 200, "lib-a", Some(2001), None, None,
),
track(
"s2", "t-b1", "Song", Some("Artist"), "Album", "alb-b",
Some("art2"), 200, "lib-b", Some(2002), Some("Jazz"), Some("cov-b"),
),
],
);
store
.with_conn("test", |c| {
c.execute(
"INSERT INTO album (server_id, id, name, artist, artist_id, year, starred_at, synced_at, raw_json) \
VALUES ('s1', 'alb-a', 'Album', 'Artist', 'art1', 2001, 1111, 1, \
'{\"recordLabel\":\"Primary Records\"}'), \
('s2', 'alb-b', 'Album', 'Artist', 'art2', 2002, 2222, 1, \
'{\"recordLabel\":\"Secondary Records\"}')",
[],
)?;
Ok(())
})
.unwrap();
let detail = album_detail(
&store,
&LibraryScopeAlbumDetailRequest {
scopes: vec![scope_pair("s1", "lib-a"), scope_pair("s2", "lib-b")],
album_id: "alb-b".into(),
server_id: "s2".into(),
},
)
.unwrap();
assert_eq!(detail.album.server_id, "s1");
assert_eq!(detail.album.id, "alb-a");
assert_eq!(detail.album.starred_at, Some(1111));
assert_eq!(detail.album.raw_json["recordLabel"], "Primary Records");
}
#[test]
fn canonical_artist_album_key_merges_discography_and_preserves_track_owners() {
let store = LibraryStore::open_in_memory();
+36
View File
@@ -95,6 +95,20 @@ describe('resolveDistinctDiscCoversForAlbum', () => {
]);
expect(resolveDistinctDiscCoversForAlbum('al-same')).toBe(false);
});
it('isolates equal album ids by owner server', () => {
rememberAlbumDistinctDiscCovers('shared', [
{ id: 'a1', albumId: 'shared', coverArt: 'mf-a', discNumber: 1 },
{ id: 'a2', albumId: 'shared', coverArt: 'mf-b', discNumber: 2 },
], 'srv-a');
rememberAlbumDistinctDiscCovers('shared', [
{ id: 'b1', albumId: 'shared', coverArt: 'mf-same', discNumber: 1 },
{ id: 'b2', albumId: 'shared', coverArt: 'mf-same', discNumber: 2 },
], 'srv-b');
expect(resolveDistinctDiscCoversForAlbum('shared', 'srv-a')).toBe(true);
expect(resolveDistinctDiscCoversForAlbum('shared', 'srv-b')).toBe(false);
});
});
describe('albumCoverRefForSong', () => {
@@ -140,4 +154,26 @@ describe('albumCoverRefForPlayback', () => {
);
expect(ref?.cacheEntityId).toBe('mf-b');
});
it('uses the playing track owner for duplicate album ids', () => {
rememberAlbumDistinctDiscCovers('shared', [
{ id: 'a1', albumId: 'shared', coverArt: 'mf-a', discNumber: 1 },
{ id: 'a2', albumId: 'shared', coverArt: 'mf-b', discNumber: 2 },
], 'srv-a');
expect(albumCoverRefForPlayback({
albumId: 'shared',
coverArt: 'mf-b',
id: 'a2',
discNumber: 2,
serverId: 'srv-a',
}, { kind: 'active' })?.cacheEntityId).toBe('mf-b');
expect(albumCoverRefForPlayback({
albumId: 'shared',
coverArt: 'mf-b',
id: 'b2',
discNumber: 2,
serverId: 'srv-b',
}, { kind: 'active' })?.cacheEntityId).toBe('shared');
});
});
+23 -10
View File
@@ -22,17 +22,27 @@ export type AlbumCoverRefOptions = {
const albumDistinctDiscCoversByAlbumId = new Map<string, boolean>();
function distinctDiscCoverKey(albumId: string, serverId?: string | null): string {
const id = albumId.trim();
const owner = serverId?.trim();
return owner ? `${owner}\u0000${id}` : id;
}
export function rememberAlbumDistinctDiscCovers(
albumId: string,
songs: ReadonlyArray<Pick<SubsonicSong, 'discNumber' | 'coverArt' | 'id' | 'albumId'>>,
serverId?: string | null,
): void {
const id = albumId.trim();
if (!id) return;
albumDistinctDiscCoversByAlbumId.set(id, albumHasDistinctDiscCovers(songs));
albumDistinctDiscCoversByAlbumId.set(
distinctDiscCoverKey(id, serverId),
albumHasDistinctDiscCovers(songs),
);
}
export function forgetAlbumDistinctDiscCovers(albumId: string): void {
albumDistinctDiscCoversByAlbumId.delete(albumId.trim());
export function forgetAlbumDistinctDiscCovers(albumId: string, serverId?: string | null): void {
albumDistinctDiscCoversByAlbumId.delete(distinctDiscCoverKey(albumId, serverId));
}
/**
@@ -49,8 +59,11 @@ export function forgetAlbumDistinctDiscCovers(albumId: string): void {
* there). Trust only the value remembered from a known tracklist; default to
* album-scoped and let the library index correct genuine per-disc albums.
*/
export function resolveDistinctDiscCoversForAlbum(albumId: string): boolean {
return albumDistinctDiscCoversByAlbumId.get(albumId.trim()) === true;
export function resolveDistinctDiscCoversForAlbum(
albumId: string,
serverId?: string | null,
): boolean {
return albumDistinctDiscCoversByAlbumId.get(distinctDiscCoverKey(albumId, serverId)) === true;
}
function resolveAlbumCoverRefOptions(
@@ -106,27 +119,27 @@ export function radioCoverRef(
}
export function albumCoverRefForSong(
song: Pick<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
song: Pick<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber' | 'serverId'>,
distinctDiscCovers?: boolean,
serverScope: CoverServerScope = { kind: 'active' },
): CoverArtRef | undefined {
const albumId = song.albumId?.trim();
const distinct =
distinctDiscCovers
?? (albumId ? resolveDistinctDiscCoversForAlbum(albumId) : false);
?? (albumId ? resolveDistinctDiscCoversForAlbum(albumId, song.serverId) : false);
const entry = resolveTrackCoverEntry(song, distinct);
return entry ? coverEntryToRef(entry, serverScope) : undefined;
}
export function albumCoverRefForPlayback(
track: Pick<SubsonicSong, 'coverArt' | 'id' | 'discNumber'> & { albumId?: string | null },
track: Pick<SubsonicSong, 'coverArt' | 'id' | 'discNumber' | 'serverId'> & { albumId?: string | null },
serverScope: CoverServerScope = resolvePlaybackCoverScope(),
): CoverArtRef | undefined {
const albumId = track.albumId?.trim();
if (!albumId) return undefined;
const distinctDiscCovers = resolveDistinctDiscCoversForAlbum(albumId);
const distinctDiscCovers = resolveDistinctDiscCoversForAlbum(albumId, track.serverId);
return albumCoverRefForSong(
{ ...track, albumId } as Pick<SubsonicSong, 'albumId' | 'coverArt' | 'id' | 'discNumber'>,
{ ...track, albumId },
distinctDiscCovers,
serverScope,
);
+10 -8
View File
@@ -212,7 +212,7 @@ export function useArtistCoverRef(
/** Track row / song card — album-scoped; multi-CD from library when indexed. */
export function useTrackCoverRef(
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'> | null | undefined,
song: Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber' | 'serverId'> | null | undefined,
serverScope: CoverServerScope = COVER_SCOPE_ACTIVE,
options?: LibraryCoverRefOptions,
): CoverArtRef | undefined {
@@ -222,22 +222,23 @@ export function useTrackCoverRef(
const albumId = song?.albumId;
const coverArt = song?.coverArt;
const discNumber = song?.discNumber;
const serverId = song?.serverId;
const distinctDiscCovers = useMemo(
() => (albumId?.trim() ? resolveDistinctDiscCoversForAlbum(albumId) : false),
[albumId],
() => (albumId?.trim() ? resolveDistinctDiscCoversForAlbum(albumId, serverId) : false),
[albumId, serverId],
);
const syncRef = useMemo(() => {
if (!songId?.trim() || !albumId?.trim()) return undefined;
return albumCoverRefForSong(
{ id: songId, albumId, coverArt, discNumber },
{ id: songId, albumId, coverArt, discNumber, serverId },
distinctDiscCovers,
serverScope,
);
// `serverScope` is keyed via stable `scopeKey`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [songId, albumId, coverArt, discNumber, distinctDiscCovers, scopeKey]);
}, [songId, albumId, coverArt, discNumber, serverId, distinctDiscCovers, scopeKey]);
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
@@ -324,6 +325,7 @@ export function usePlaybackTrackCoverRef(
const albumId = track?.albumId;
const coverArt = track?.coverArt;
const discNumber = track?.discNumber;
const serverId = track?.serverId;
const syncRef = useMemo(() => {
if (!albumId?.trim() || !track) return undefined;
@@ -331,7 +333,7 @@ export function usePlaybackTrackCoverRef(
// `scope` is keyed via the stable `scopeKey` string; the primitive track
// fields recompute the ref when the playing track changes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [track, trackId, albumId, coverArt, discNumber, scopeKey]);
}, [track, trackId, albumId, coverArt, discNumber, serverId, scopeKey]);
const [ref, setRef] = useState<CoverArtRef | undefined>(syncRef);
@@ -341,7 +343,7 @@ export function usePlaybackTrackCoverRef(
const al = albumId?.trim();
if (!tid || !al || !track) return;
let cancelled = false;
const distinctDiscCovers = resolveDistinctDiscCoversForAlbum(al);
const distinctDiscCovers = resolveDistinctDiscCoversForAlbum(al, serverId);
void resolveTrackCoverRefFromLibrary(
{ ...track, id: tid, albumId: al } as Pick<SubsonicSong, 'id' | 'albumId' | 'coverArt' | 'discNumber'>,
scope,
@@ -371,7 +373,7 @@ export function usePlaybackTrackCoverRef(
// `scope` is keyed via the stable `scopeKey` string; depending on the object
// directly would re-resolve from SQLite on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [track, trackId, albumId, coverArt, discNumber, scopeKey, syncRef]);
}, [track, trackId, albumId, coverArt, discNumber, serverId, scopeKey, syncRef]);
return ref;
}
@@ -0,0 +1,78 @@
import { act, screen, waitFor } from '@testing-library/react';
import type { TFunction } from 'i18next';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { useSelectionStore } from '@/store/selectionStore';
import { renderWithProviders } from '@/test/helpers/renderWithProviders';
import { AlbumDetailToolbar } from './AlbumDetailToolbar';
vi.mock('@/features/contextMenu/components/ContextMenu', () => ({
AddToPlaylistSubmenu: ({ serverId, songIds }: { serverId?: string; songIds: string[] }) => (
<div data-testid="playlist-submenu">{`${serverId}:${songIds.join(',')}`}</div>
),
}));
const t = ((key: string) => key) as TFunction;
const song = (serverId: string, title: string): SubsonicSong => ({
id: 'shared',
serverId,
title,
artist: 'Artist',
album: 'Album',
albumId: 'album',
duration: 100,
});
function renderToolbar(songs: SubsonicSong[]) {
return renderWithProviders(
<AlbumDetailToolbar
filterText=""
setFilterText={vi.fn()}
inSelectMode
selectedCount={2}
showPlPicker={false}
setShowPlPicker={vi.fn()}
t={t}
songs={songs}
/>,
);
}
describe('AlbumDetailToolbar owner-safe playlist actions', () => {
beforeEach(() => useSelectionStore.setState({ selectedIds: new Set() }));
it('hides add-to-playlist for a mixed-owner selection', () => {
const songs = [song('srv-a', 'A'), song('srv-b', 'B')];
useSelectionStore.setState({ selectedIds: new Set(songs.map(ownedEntityKey)) });
renderToolbar(songs);
expect(screen.queryByRole('button', { name: 'common.bulkAddToPlaylist' })).not.toBeInTheDocument();
});
it('reacts to same-count selection changes and keeps the concrete owner', async () => {
const songs = [song('srv-a', 'A'), song('srv-b', 'B')];
useSelectionStore.setState({ selectedIds: new Set([ownedEntityKey(songs[0])]) });
const setShowPlPicker = vi.fn();
renderWithProviders(
<AlbumDetailToolbar
filterText=""
setFilterText={vi.fn()}
inSelectMode
selectedCount={1}
showPlPicker
setShowPlPicker={setShowPlPicker}
t={t}
songs={songs}
/>,
);
expect(screen.getByTestId('playlist-submenu')).toHaveTextContent('srv-a:shared');
act(() => useSelectionStore.setState({
selectedIds: new Set([ownedEntityKey(songs[1])]),
}));
await waitFor(() => expect(screen.getByTestId('playlist-submenu')).toHaveTextContent('srv-b:shared'));
});
});
@@ -4,6 +4,9 @@ import type { TFunction } from 'i18next';
import { useSelectionStore } from '@/store/selectionStore';
import { AddToPlaylistSubmenu } from '@/features/contextMenu/components/ContextMenu';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
interface Props {
filterText: string;
@@ -14,7 +17,7 @@ interface Props {
setShowPlPicker: React.Dispatch<React.SetStateAction<boolean>>;
t: TFunction;
actionPolicy?: OfflineActionPolicy;
serverId?: string;
songs: SubsonicSong[];
}
/**
@@ -35,9 +38,19 @@ export function AlbumDetailToolbar({
setShowPlPicker,
t,
actionPolicy,
serverId,
songs,
}: Props) {
const policy = actionPolicy ?? offlineActionPolicy('albumDetail', false);
const selectedIds = useSelectionStore(s => s.selectedIds);
const selectedSongs = songs.filter(song => selectedIds.has(ownedEntityKey(song)));
const selectedOwnerKeys = new Set(
selectedSongs.map(song => song.serverId ? resolveIndexKey(song.serverId) : '').filter(Boolean),
);
const playlistOwner = selectedOwnerKeys.size === 1 ? selectedSongs[0]?.serverId : undefined;
const canAddSelectionToPlaylist = policy.canAddToPlaylist
&& selectedSongs.length > 0
&& selectedSongs.length === selectedIds.size
&& !!playlistOwner;
return (
<div className="album-track-toolbar">
<div className="album-track-toolbar-filter">
@@ -65,7 +78,7 @@ export function AlbumDetailToolbar({
<span className="bulk-action-count">
{t('common.bulkSelected', { count: selectedCount })}
</span>
{policy.canAddToPlaylist && (
{canAddSelectionToPlaylist && (
<div className="bulk-pl-picker-wrap">
<button
className="btn btn-surface btn-sm"
@@ -76,8 +89,8 @@ export function AlbumDetailToolbar({
</button>
{showPlPicker && (
<AddToPlaylistSubmenu
songIds={[...useSelectionStore.getState().selectedIds]}
serverId={serverId}
songIds={selectedSongs.map(song => song.id)}
serverId={playlistOwner}
onDone={() => { setShowPlPicker(false); useSelectionStore.getState().clearAll(); }}
dropDown
/>
@@ -6,6 +6,7 @@ import AlbumHeader from '@/features/album/components/AlbumHeader';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
const navigate = vi.fn();
const copyEntityShareLink = vi.fn();
vi.mock('react-router-dom', async importActual => {
const actual = await importActual<typeof import('react-router-dom')>();
@@ -20,6 +21,9 @@ vi.mock('@/store/themeStore', () => ({ useThemeStore: () => false }));
vi.mock('@/ui/StarRating', () => ({ default: () => null }));
vi.mock('@/ui/OpenArtistRefInline', () => ({ OpenArtistRefInline: () => null }));
vi.mock('@/cover/CoverArtImage', () => ({ CoverArtImage: () => null }));
vi.mock('@/lib/share/copyEntityShareLink', () => ({
copyEntityShareLink: (...args: unknown[]) => copyEntityShareLink(...args),
}));
function baseProps() {
return {
@@ -146,4 +150,25 @@ describe('AlbumHeader genres', () => {
expect(screen.queryByRole('menu')).not.toBeInTheDocument();
expect(more).toHaveFocus();
});
it('preserves the album owner in genre return and share actions', async () => {
navigate.mockClear();
copyEntityShareLink.mockReset().mockResolvedValue(true);
const user = userEvent.setup();
renderWithProviders(
<AlbumHeader
{...baseProps()}
info={albumInfo({ genres: [{ name: 'Rock' }] })}
serverId="srv-b"
/>,
);
await user.click(screen.getByRole('button', { name: 'More albums in Rock' }));
expect(navigate).toHaveBeenCalledWith('/genres/Rock', {
state: { returnTo: '/album/al1?server=srv-b' },
});
await user.click(screen.getByRole('button', { name: 'Share album' }));
expect(copyEntityShareLink).toHaveBeenCalledWith('album', 'al1', { serverId: 'srv-b' });
});
});
@@ -22,7 +22,7 @@ import { tooltipAttrs } from '@/ui/tooltipAttrs';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import { deriveAlbumGenreTags } from '@/lib/library/genreTags';
import { genreColor } from '@/lib/library/genreColor';
import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { buildAlbumDetailPath, buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
/** True when the album artist label means "no single artist" `getArtistInfo`
* has nothing meaningful to return for these, so the Artist Bio entry is hidden.
@@ -227,12 +227,14 @@ export default function AlbumHeader({
const genreMoreRef = useRef<HTMLButtonElement>(null);
const goToGenre = (genre: string) => {
setGenreMenuPos(null);
navigate(`/genres/${encodeURIComponent(genre)}`, { state: { returnTo: `/album/${info.id}` } });
navigate(`/genres/${encodeURIComponent(genre)}`, {
state: { returnTo: buildAlbumDetailPath(info.id, { serverId }) },
});
};
const handleShareAlbum = async () => {
try {
const ok = await copyEntityShareLink('album', info.id);
const ok = await copyEntityShareLink('album', info.id, { serverId });
if (ok) showToast(t('contextMenu.shareCopied'));
else showToast(t('contextMenu.shareCopyFailed'), 4000, 'error');
} catch {
@@ -350,7 +352,9 @@ export default function AlbumHeader({
<button
className="album-detail-artist-link"
data-tooltip={t('albumDetail.moreLabelAlbums', { label: info.recordLabel })}
onClick={() => navigate(`/label/${encodeURIComponent(info.recordLabel!)}`)}
onClick={() => navigate(
`/label/${encodeURIComponent(info.recordLabel!)}${serverId ? `?server=${encodeURIComponent(serverId)}` : ''}`,
)}
>
{info.recordLabel}
</button>
@@ -16,7 +16,7 @@ import { AlbumTrackListMobile } from '@/features/album/components/AlbumTrackList
import { TracklistColumnPicker } from '@/ui/TracklistColumnPicker';
import { TracklistHeaderRow } from '@/features/album/components/TracklistHeaderRow';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import { ownedOverrideValue } from '@/lib/util/ownedEntityKey';
import { ownedEntityKey, ownedOverrideValue } from '@/lib/util/ownedEntityKey';
export type { SortKey } from '@/features/album/utils/albumTrackListHelpers';
@@ -34,7 +34,7 @@ interface AlbumTrackListProps {
onPlaySong: (song: SubsonicSong) => void;
/** Optional dbl-click handler — currently set only in Orbit mode so the list knows to bind it. */
onDoubleClickSong?: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onRate: (song: SubsonicSong, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: (x: number, y: number, track: Track, type: 'song' | 'album' | 'artist' | 'queue-item' | 'album-song') => void;
sortKey?: SortKey;
@@ -68,7 +68,7 @@ export default function AlbumTrackList({
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const isMobile = useIsMobile();
const [contextMenuSongId, setContextMenuSongId] = useState<string | null>(null);
const [contextMenuSongKey, setContextMenuSongKey] = useState<string | null>(null);
const contextMenuOpen = usePlayerStore(s => s.contextMenu.isOpen);
const {
@@ -84,7 +84,7 @@ export default function AlbumTrackList({
useEffect(() => {
// React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effects dependencies change.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (!contextMenuOpen) setContextMenuSongId(null);
if (!contextMenuOpen) setContextMenuSongKey(null);
}, [contextMenuOpen]);
// ── Disc grouping ─────────────────────────────────────────────────────────
@@ -104,7 +104,6 @@ export default function AlbumTrackList({
(discTitles ?? []).filter(d => d.title?.trim()).map(d => [d.disc, d.title.trim()]),
);
const currentTrackId = currentTrack?.id ?? null;
const displayCols = useMemo(
() => (policy.canFavorite ? visibleCols : visibleCols.filter(c => c.key !== 'favorite')),
[policy.canFavorite, visibleCols],
@@ -117,10 +116,10 @@ export default function AlbumTrackList({
discs={discs}
discTitleByNum={discTitleByNum}
isMultiDisc={isMultiDisc}
currentTrackId={currentTrackId}
currentTrack={currentTrack}
isPlaying={isPlaying}
contextMenuSongId={contextMenuSongId}
setContextMenuSongId={setContextMenuSongId}
contextMenuSongKey={contextMenuSongKey}
setContextMenuSongKey={setContextMenuSongKey}
onPlaySong={onPlaySong}
onContextMenu={onContextMenu}
/>
@@ -177,19 +176,20 @@ export default function AlbumTrackList({
)}
{discs.get(discNum)!.map(song => {
const globalIdx = songs.indexOf(song);
const songKey = ownedEntityKey(song);
return (
<TrackRow
key={song.id}
key={songKey}
song={song}
globalIdx={globalIdx}
visibleCols={displayCols}
gridStyle={gridStyle}
currentTrackId={currentTrackId}
currentTrack={currentTrack}
isPlaying={isPlaying}
ratingValue={ratings[song.id] ?? ownedOverrideValue(userRatingOverrides, song) ?? song.userRating ?? 0}
isStarred={starredSongs.has(song.id)}
ratingValue={ratings[songKey] ?? ownedOverrideValue(userRatingOverrides, song) ?? song.userRating ?? 0}
isStarred={starredSongs.has(songKey)}
inSelectMode={inSelectMode}
isContextMenuSong={contextMenuSongId === song.id}
isContextMenuSong={contextMenuSongKey === songKey}
onPlaySong={onPlaySong}
onDoubleClickSong={onDoubleClickSong}
onRate={onRate}
@@ -197,7 +197,7 @@ export default function AlbumTrackList({
onContextMenu={onContextMenu}
onToggleSelect={onToggleSelect}
onDragStart={onDragStart}
setContextMenuSongId={setContextMenuSongId}
setContextMenuSongKey={setContextMenuSongKey}
actionPolicy={policy}
/>
);
@@ -4,16 +4,18 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import type { Track } from '@/lib/media/trackTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { formatLongDuration } from '@/lib/format/formatDuration';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { sameQueueTrack } from '@/features/playback';
interface Props {
discNums: number[];
discs: Map<number, SubsonicSong[]>;
discTitleByNum: Map<number, string>;
isMultiDisc: boolean;
currentTrackId: string | null;
currentTrack: Track | null;
isPlaying: boolean;
contextMenuSongId: string | null;
setContextMenuSongId: (id: string | null) => void;
contextMenuSongKey: string | null;
setContextMenuSongKey: (id: string | null) => void;
onPlaySong: (song: SubsonicSong) => void;
onContextMenu: (
x: number,
@@ -34,10 +36,10 @@ export function AlbumTrackListMobile({
discs,
discTitleByNum,
isMultiDisc,
currentTrackId,
currentTrack,
isPlaying,
contextMenuSongId,
setContextMenuSongId,
contextMenuSongKey,
setContextMenuSongKey,
onPlaySong,
onContextMenu,
}: Props) {
@@ -54,15 +56,16 @@ export function AlbumTrackListMobile({
</div>
)}
{discs.get(discNum)!.map(song => {
const isActive = currentTrackId === song.id;
const songKey = ownedEntityKey(song);
const isActive = sameQueueTrack(currentTrack, song);
return (
<div
key={song.id}
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongId === song.id ? ' context-active' : ''}`}
key={songKey}
className={`tracklist-mobile-row${isActive ? ' active' : ''}${contextMenuSongKey === songKey ? ' context-active' : ''}`}
onClick={() => onPlaySong(song)}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
setContextMenuSongKey(songKey);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
>
+17 -14
View File
@@ -17,6 +17,8 @@ import i18n from '@/lib/i18n';
import { offlineActionPolicy, type OfflineActionPolicy } from '@/features/offline';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { sameQueueTrack } from '@/features/playback';
type ContextMenuFn = (
x: number,
@@ -30,7 +32,7 @@ interface TrackRowProps {
globalIdx: number;
visibleCols: readonly ColDef[];
gridStyle: React.CSSProperties;
currentTrackId: string | null;
currentTrack: Track | null;
isPlaying: boolean;
ratingValue: number;
isStarred: boolean;
@@ -38,12 +40,12 @@ interface TrackRowProps {
isContextMenuSong: boolean;
onPlaySong: (song: SubsonicSong) => void;
onDoubleClickSong?: (song: SubsonicSong) => void;
onRate: (songId: string, rating: number) => void;
onRate: (song: SubsonicSong, rating: number) => void;
onToggleSongStar: (song: SubsonicSong, e: React.MouseEvent) => void;
onContextMenu: ContextMenuFn;
onToggleSelect: (id: string, globalIdx: number, shift: boolean) => void;
onDragStart: (song: SubsonicSong, me: MouseEvent) => void;
setContextMenuSongId: (id: string | null) => void;
setContextMenuSongKey: (id: string | null) => void;
actionPolicy?: OfflineActionPolicy;
}
@@ -57,7 +59,7 @@ export const TrackRow = React.memo(function TrackRow({
globalIdx,
visibleCols,
gridStyle,
currentTrackId,
currentTrack,
isPlaying,
ratingValue,
isStarred,
@@ -70,17 +72,18 @@ export const TrackRow = React.memo(function TrackRow({
onContextMenu,
onToggleSelect,
onDragStart,
setContextMenuSongId,
setContextMenuSongKey,
actionPolicy,
}: TrackRowProps) {
const policy = actionPolicy ?? offlineActionPolicy('trackRow', false);
const { t } = useTranslation();
const navigate = useNavigate();
const showBitrate = useThemeStore(s => s.showBitrate);
const isSelected = useSelectionStore(s => s.selectedIds.has(song.id));
const isActive = currentTrackId === song.id;
const isPreviewing = usePreviewStore(s => s.previewingId === song.id);
const isPreviewAudioStarted = usePreviewStore(s => s.previewingId === song.id && s.audioStarted);
const songKey = ownedEntityKey(song);
const isSelected = useSelectionStore(s => s.selectedIds.has(songKey));
const isActive = sameQueueTrack(currentTrack, song);
const isPreviewing = usePreviewStore(s => sameQueueTrack(s.previewingTrack, song));
const isPreviewAudioStarted = usePreviewStore(s => sameQueueTrack(s.previewingTrack, song) && s.audioStarted);
const renderCell = (colDef: ColDef) => {
const key = colDef.key as ColKey;
@@ -93,7 +96,7 @@ export const TrackRow = React.memo(function TrackRow({
>
<span
className={`bulk-check${isSelected ? ' checked' : ''}${inSelectMode ? ' bulk-check-visible' : ''}`}
onClick={e => { e.stopPropagation(); onToggleSelect(song.id, globalIdx, e.shiftKey); }}
onClick={e => { e.stopPropagation(); onToggleSelect(songKey, globalIdx, e.shiftKey); }}
/>
{isActive && isPlaying ? (
<span className="track-num-eq">
@@ -178,7 +181,7 @@ export const TrackRow = React.memo(function TrackRow({
<StarRating
key="rating"
value={ratingValue}
onChange={r => onRate(song.id, r)}
onChange={r => onRate(song, r)}
disabled={!policy.canRate}
/>
);
@@ -232,9 +235,9 @@ export const TrackRow = React.memo(function TrackRow({
onClick={e => {
if ((e.target as HTMLElement).closest('button, a, input')) return;
if (e.ctrlKey || e.metaKey) {
onToggleSelect(song.id, globalIdx, false);
onToggleSelect(songKey, globalIdx, false);
} else if (inSelectMode) {
onToggleSelect(song.id, globalIdx, e.shiftKey);
onToggleSelect(songKey, globalIdx, e.shiftKey);
} else {
onPlaySong(song);
}
@@ -246,7 +249,7 @@ export const TrackRow = React.memo(function TrackRow({
} : undefined}
onContextMenu={e => {
e.preventDefault();
setContextMenuSongId(song.id);
setContextMenuSongKey(songKey);
onContextMenu(e.clientX, e.clientY, songToTrack(song), 'album-song');
}}
role="row"
@@ -1,8 +1,9 @@
import { renderHook, waitFor } from '@testing-library/react';
import { act, renderHook, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/store/authStore';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
const tryLoadAlbumDetailMultiScopeMock = vi.fn();
const resolveAlbumMock = vi.fn();
@@ -35,6 +36,12 @@ function routerWrapper({ children }: { children: React.ReactNode }) {
return React.createElement(MemoryRouter, null, children);
}
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>(res => { resolve = res; });
return { promise, resolve };
}
describe('useAlbumDetailData — multi-library selection', () => {
beforeEach(() => {
tryLoadAlbumDetailMultiScopeMock.mockReset();
@@ -125,4 +132,85 @@ describe('useAlbumDetailData — multi-library selection', () => {
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(result.current.album).toBeNull();
});
it('ignores a late completion after the album id changes', async () => {
const first = deferred<Awaited<ReturnType<typeof tryLoadAlbumDetailMultiScopeMock>>>();
const second = deferred<Awaited<ReturnType<typeof tryLoadAlbumDetailMultiScopeMock>>>();
tryLoadAlbumDetailMultiScopeMock.mockImplementation((_: unknown, __: unknown, albumId: string) => (
albumId === 'alb-a' ? first.promise : second.promise
));
const { result, rerender } = renderHook(
({ albumId }) => useAlbumDetailData(albumId),
{ wrapper: routerWrapper, initialProps: { albumId: 'alb-a' } },
);
rerender({ albumId: 'alb-b' });
await act(async () => {
second.resolve({
album: { id: 'alb-b', name: 'Second', serverId: 'srv-2' },
songs: [],
});
});
await waitFor(() => expect(result.current.album?.album.id).toBe('alb-b'));
await act(async () => {
first.resolve({
album: { id: 'alb-a', name: 'First', serverId: 'srv-1' },
songs: [],
});
});
expect(result.current.album?.album.id).toBe('alb-b');
});
it('clears the previous album when the next authoritative lookup misses', async () => {
tryLoadAlbumDetailMultiScopeMock
.mockResolvedValueOnce({ album: { id: 'alb-a', name: 'First' }, songs: [] })
.mockResolvedValueOnce(null);
const { result, rerender } = renderHook(
({ albumId }) => useAlbumDetailData(albumId),
{ wrapper: routerWrapper, initialProps: { albumId: 'alb-a' } },
);
await waitFor(() => expect(result.current.album?.album.id).toBe('alb-a'));
rerender({ albumId: 'alb-b' });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.album).toBeNull();
});
it('tracks starred songs by owner-qualified identity', async () => {
tryLoadAlbumDetailMultiScopeMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Merged' },
songs: [
{ id: 'shared', title: 'A', serverId: 'srv-1', starred: '2026-01-01T00:00:00Z' },
{ id: 'shared', title: 'B', serverId: 'srv-2' },
],
});
const { result } = renderHook(() => useAlbumDetailData('alb-1'), { wrapper: routerWrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.starredSongs).toEqual(new Set([
ownedEntityKey({ id: 'shared', serverId: 'srv-1' }),
]));
});
it('does not fall back to the active server for an unknown explicit owner', async () => {
const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(
MemoryRouter,
{ initialEntries: ['/album/alb-1?server=missing'] },
children,
);
resolveAlbumMock.mockResolvedValue({
album: { id: 'alb-1', name: 'Wrong server' },
songs: [],
});
const { result } = renderHook(() => useAlbumDetailData('alb-1'), { wrapper });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(tryLoadAlbumDetailMultiScopeMock).not.toHaveBeenCalled();
expect(resolveAlbumMock).not.toHaveBeenCalled();
expect(result.current.album).toBeNull();
});
});
+57 -24
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import { useAuthStore } from '@/store/authStore';
@@ -26,6 +26,8 @@ import { tryLoadAlbumDetailMultiScope } from '@/features/album/hooks/loadAlbumDe
import { tryLoadArtistDetailMultiScope } from '@/lib/library/loadArtistDetailMultiScope';
import { getLibraryBrowseScope } from '@/lib/library/libraryBrowseScope';
import type { LibraryScopePair } from '@/lib/api/library/scopeReads';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { useLibraryScopeSyncRevision } from '@/store/offlineLocalLibrarySyncRevision';
type AlbumPayload = ResolvedAlbum;
@@ -48,31 +50,52 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
const [relatedAlbums, setRelatedAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const [starredSongs, setStarredSongs] = useState<Set<string>>(new Set());
const loadGenerationRef = useRef(0);
const favoritesOfflineEnabled = useAuthStore(s => s.favoritesOfflineEnabled);
const activeServerId = useAuthStore(s => s.activeServerId);
const libraryBrowseScopeVersion = useAuthStore(s => s.libraryBrowseScopeVersion);
const [searchParams] = useSearchParams();
const detailServerId = readDetailServerId(searchParams, activeServerId);
const invalidExplicitServer = searchParams.has('server') && !detailServerId;
const offlineBrowseActive = useOfflineBrowseContext().active && !!detailServerId;
const librarySyncRevision = useLibraryScopeSyncRevision(getLibraryBrowseScope().serverIds);
useEffect(() => {
if (!id) return;
const generation = ++loadGenerationRef.current;
const isCurrent = () => loadGenerationRef.current === generation;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
setAlbum(null);
setRelatedAlbums([]);
setStarredSongs(new Set());
const applyAlbumPayload = (data: AlbumPayload) => {
if (invalidExplicitServer) {
setLoading(false);
return () => {
if (loadGenerationRef.current === generation) loadGenerationRef.current += 1;
};
}
const applyAlbumPayload = (data: AlbumPayload): boolean => {
if (!isCurrent()) return false;
setAlbum(data);
const initialStarred = new Set<string>();
data.songs.forEach(s => { if (s.starred) initialStarred.add(s.id); });
data.songs.forEach(s => { if (s.starred) initialStarred.add(ownedEntityKey(s)); });
setStarredSongs(initialStarred);
setLoading(false);
return true;
};
const finishWithoutAlbum = () => {
if (isCurrent()) setLoading(false);
};
const loadRelatedAlbums = async (
serverId: string | null,
artistId: string | undefined,
currentAlbum: Pick<SubsonicAlbum, 'id' | 'serverId'>,
useLocalArtist: boolean,
localBytesOnly: boolean,
scopes?: LibraryScopePair[],
@@ -81,23 +104,25 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
try {
if (serverId && scopes?.length) {
const scoped = await tryLoadArtistDetailMultiScope(scopes, serverId, artistId);
if (scoped) setRelatedAlbums(scoped.albums.filter(a => a.id !== id));
if (scoped && isCurrent()) {
setRelatedAlbums(scoped.albums.filter(a => ownedEntityKey(a) !== ownedEntityKey(currentAlbum)));
}
return;
}
if (useLocalArtist && serverId) {
const artistLocal = localBytesOnly
? await loadArtistFromLocalPlayback(serverId, artistId)
: await loadArtistFromLibraryIndex(serverId, artistId);
if (artistLocal) {
setRelatedAlbums(artistLocal.albums.filter(a => a.id !== id));
if (artistLocal && isCurrent()) {
setRelatedAlbums(artistLocal.albums.filter(a => ownedEntityKey(a) !== ownedEntityKey(currentAlbum)));
return;
}
}
const relatedServerId = serverId ?? detailServerId ?? activeServerId;
if (!relatedServerId) return;
const artistData = await resolveArtist(relatedServerId, artistId);
if (artistData) {
setRelatedAlbums(artistData.albums.filter(a => a.id !== id));
if (artistData && isCurrent()) {
setRelatedAlbums(artistData.albums.filter(a => ownedEntityKey(a) !== ownedEntityKey(currentAlbum)));
}
} catch (e) {
console.error('Failed to fetch related albums', e);
@@ -109,33 +134,35 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
if (offlineBrowseActive && detailServerId) {
const local = await resolveAlbum(detailServerId, id);
if (local) {
applyAlbumPayload(local);
if (!applyAlbumPayload(local)) return;
await loadRelatedAlbums(
detailServerId,
local.album.artistId,
local.album,
true,
offlineLocalBrowseEnabled(detailServerId),
);
return;
}
setLoading(false);
finishWithoutAlbum();
return;
}
if (detailServerId && browseScope.pairs.length > 0) {
const multi = await tryLoadAlbumDetailMultiScope(browseScope.pairs, detailServerId, id);
if (multi) {
applyAlbumPayload(multi);
if (!applyAlbumPayload(multi)) return;
await loadRelatedAlbums(
multi.album.serverId ?? detailServerId,
multi.album.artistId,
multi.album,
true,
false,
browseScope.pairs,
);
return;
}
setLoading(false);
finishWithoutAlbum();
return;
}
@@ -149,8 +176,8 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
try {
const local = await resolveAlbum(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
if (!applyAlbumPayload(local)) return;
await loadRelatedAlbums(detailServerId, local.album.artistId, local.album, true, false);
return;
}
} catch { /* fall through */ }
@@ -165,49 +192,55 @@ export function useAlbumDetailData(id: string | undefined): UseAlbumDetailDataRe
try {
const local = await resolveAlbum(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
if (!applyAlbumPayload(local)) return;
await loadRelatedAlbums(detailServerId, local.album.artistId, local.album, true, false);
return;
}
} catch { /* ignore */ }
}
setLoading(false);
finishWithoutAlbum();
return;
}
try {
const sid = detailServerId ?? activeServerId;
if (!sid) {
setLoading(false);
finishWithoutAlbum();
return;
}
const data = await resolveAlbum(sid, id);
if (!data) {
setLoading(false);
finishWithoutAlbum();
return;
}
applyAlbumPayload(data);
await loadRelatedAlbums(detailServerId, data.album.artistId, false, false);
if (!applyAlbumPayload(data)) return;
await loadRelatedAlbums(data.album.serverId ?? sid, data.album.artistId, data.album, false, false);
} catch {
if (canLoadLocal && detailServerId) {
try {
const local = await loadAlbumFromLibraryIndex(detailServerId, id);
if (local) {
applyAlbumPayload(local);
await loadRelatedAlbums(detailServerId, local.album.artistId, true, false);
if (!applyAlbumPayload(local)) return;
await loadRelatedAlbums(detailServerId, local.album.artistId, local.album, true, false);
return;
}
} catch { /* ignore */ }
}
setLoading(false);
finishWithoutAlbum();
}
})();
return () => {
if (loadGenerationRef.current === generation) loadGenerationRef.current += 1;
};
}, [
activeServerId,
detailServerId,
favoritesOfflineEnabled,
id,
invalidExplicitServer,
libraryBrowseScopeVersion,
librarySyncRevision,
offlineBrowseActive,
searchParams,
]);
@@ -0,0 +1,47 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useAlbumDetailSort } from './useAlbumDetailSort';
const song = (serverId: string, title: string): SubsonicSong => ({
id: 'shared',
serverId,
title,
artist: 'Artist',
album: 'Album',
albumId: 'album',
duration: 100,
});
describe('useAlbumDetailSort owner identity', () => {
it('sorts equal raw ids using owner-qualified favorite and rating state', () => {
const a = song('srv-a', 'A');
const b = song('srv-b', 'B');
const { result, rerender } = renderHook(
props => useAlbumDetailSort(props),
{
initialProps: {
songs: [a, b],
filterText: '',
starredSongs: new Set([ownedEntityKey(b)]),
ratings: { [ownedEntityKey(a)]: 5, [ownedEntityKey(b)]: 1 },
userRatingOverrides: {},
},
},
);
act(() => result.current.handleSort('favorite'));
expect(result.current.displayedSongs.map(item => item.serverId)).toEqual(['srv-a', 'srv-b']);
rerender({
songs: [a, b],
filterText: '',
starredSongs: new Set([ownedEntityKey(b)]),
ratings: { [ownedEntityKey(a)]: 5, [ownedEntityKey(b)]: 1 },
userRatingOverrides: {},
});
act(() => result.current.handleSort('rating'));
expect(result.current.displayedSongs.map(item => item.serverId)).toEqual(['srv-b', 'srv-a']);
});
});
@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { ownedOverrideValue } from '@/lib/util/ownedEntityKey';
import { ownedEntityKey, ownedOverrideValue } from '@/lib/util/ownedEntityKey';
export type AlbumSortKey = 'natural' | 'title' | 'artist' | 'album' | 'favorite' | 'rating' | 'duration' | 'playCount' | 'lastPlayed' | 'bpm';
@@ -73,12 +73,12 @@ export function useAlbumDetailSort({
case 'artist': av = a.artist ?? ''; bv = b.artist ?? ''; break;
case 'album': av = a.album ?? ''; bv = b.album ?? ''; break;
case 'favorite':
av = starredSongs.has(a.id) ? 1 : 0;
bv = starredSongs.has(b.id) ? 1 : 0;
av = starredSongs.has(ownedEntityKey(a)) ? 1 : 0;
bv = starredSongs.has(ownedEntityKey(b)) ? 1 : 0;
break;
case 'rating':
av = ratings[a.id] ?? ownedOverrideValue(userRatingOverrides, a) ?? a.userRating ?? 0;
bv = ratings[b.id] ?? ownedOverrideValue(userRatingOverrides, b) ?? b.userRating ?? 0;
av = ratings[ownedEntityKey(a)] ?? ownedOverrideValue(userRatingOverrides, a) ?? a.userRating ?? 0;
bv = ratings[ownedEntityKey(b)] ?? ownedOverrideValue(userRatingOverrides, b) ?? b.userRating ?? 0;
break;
case 'duration': av = a.duration ?? 0; bv = b.duration ?? 0; break;
case 'playCount': av = a.playCount ?? 0; bv = b.playCount ?? 0; break;
@@ -3,6 +3,7 @@ import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { useSelectionStore } from '@/store/selectionStore';
import { useDragDrop } from '@/lib/dnd/DragDropContext';
import { songToTrack } from '@/lib/media/songToTrack';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
interface UseAlbumTrackListSelectionArgs {
songs: SubsonicSong[];
@@ -68,7 +69,7 @@ export function useAlbumTrackListSelection({
if (shift && lastSelectedIdxRef.current !== null) {
const from = Math.min(lastSelectedIdxRef.current, globalIdx);
const to = Math.max(lastSelectedIdxRef.current, globalIdx);
songs.slice(from, to + 1).forEach(s => next.add(s.id));
songs.slice(from, to + 1).forEach(s => next.add(ownedEntityKey(s)));
} else {
if (next.has(id)) next.delete(id);
else next.add(id);
@@ -80,9 +81,10 @@ export function useAlbumTrackListSelection({
const onDragStart = useCallback((song: SubsonicSong, me: MouseEvent) => {
const { selectedIds } = useSelectionStore.getState();
if (selectedIds.has(song.id) && selectedIds.size > 1) {
const songKey = ownedEntityKey(song);
if (selectedIds.has(songKey) && selectedIds.size > 1) {
const tracks = songs
.filter(s => selectedIds.has(s.id))
.filter(s => selectedIds.has(ownedEntityKey(s)))
.map(s => songToTrack(s));
psyDrag.startDrag(
{ data: JSON.stringify({ type: 'songs', tracks }), label: `${tracks.length} Songs` },
@@ -100,7 +102,7 @@ export function useAlbumTrackListSelection({
if (allSelected) {
useSelectionStore.getState().clearAll();
} else {
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(s => s.id)));
useSelectionStore.getState().setSelectedIds(() => new Set(songs.map(ownedEntityKey)));
}
}, [allSelected, songs]);
+111 -70
View File
@@ -1,8 +1,8 @@
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl';
import { setRating, star, unstar } from '@/lib/api/subsonicStarRating';
import { queueSongStar, queueSongRating } from '@/features/playback/store/pendingStarSync';
import { getAlbumForServer } from '@/lib/api/subsonicLibrary';
import { getArtistInfo } from '@/lib/api/subsonicArtists';
import { getArtistInfoForServer } from '@/lib/api/subsonicArtists';
import type { SubsonicSong } from '@/lib/api/subsonicTypes';
import { songToTrack } from '@/lib/media/songToTrack';
import { shuffleArray } from '@/lib/util/shuffleArray';
@@ -51,6 +51,8 @@ import { isLosslessMode } from '@/lib/library/losslessMode';
import { readDetailServerId } from '@/lib/navigation/detailServerScope';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
import { resolveIndexKey } from '@/lib/server/serverIndexKey';
import { sameQueueTrack } from '@/features/playback';
export default function AlbumDetail() {
const { t } = useTranslation();
@@ -77,12 +79,15 @@ export default function AlbumDetail() {
const [ratings, setRatings] = useState<Record<string, number>>({});
const [bio, setBio] = useState<string | null>(null);
const [bioOpen, setBioOpen] = useState(false);
const bioRequestRef = useRef(0);
const downloadAlbum = useOfflineStore(s => s.downloadAlbum);
const deleteAlbum = useOfflineStore(s => s.deleteAlbum);
const serverId = readDetailServerId(searchParams, auth.activeServerId) ?? '';
const routeServerId = readDetailServerId(searchParams, auth.activeServerId) ?? '';
const albumOwnerServerId = album?.album.serverId ?? routeServerId;
const albumOwnerId = album?.album.id ?? '';
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
const albumEntityRatingSupport = entityRatingSupportByServer[serverId] ?? 'unknown';
const albumEntityRatingSupport = entityRatingSupportByServer[albumOwnerServerId] ?? 'unknown';
const offlineCtx = useOfflineBrowseContext();
const albumActionPolicy = offlineActionPolicy('albumDetail', offlineCtx.active);
const userMetadataMutationRef = useRef(false);
@@ -93,7 +98,7 @@ export default function AlbumDetail() {
const inSelectMode = selectedCount > 0;
// Derive a stable albumId for the selectors below (empty string when not yet loaded).
const albumId = album?.album.id ?? '';
const albumId = albumOwnerId;
const onReconcileApplied = useCallback((id: string) => {
usePlayerStore.setState(s => {
@@ -101,14 +106,14 @@ export default function AlbumDetail() {
const userRatingOverrides = { ...s.userRatingOverrides };
delete starredOverrides[id];
delete userRatingOverrides[id];
delete starredOverrides[ownedEntityKey({ id, serverId })];
delete userRatingOverrides[ownedEntityKey({ id, serverId })];
delete starredOverrides[ownedEntityKey({ id, serverId: albumOwnerServerId })];
delete userRatingOverrides[ownedEntityKey({ id, serverId: albumOwnerServerId })];
return { starredOverrides, userRatingOverrides };
});
}, [serverId]);
}, [albumOwnerServerId]);
useAlbumServerMetadataReconcile({
serverId,
serverId: albumOwnerServerId,
albumId,
album: album?.album,
setAlbum,
@@ -119,17 +124,17 @@ export default function AlbumDetail() {
const isStarred = useMemo(() => {
if (!albumId) return false;
const override = ownedOverrideValue(starredOverrides, { id: albumId, serverId });
const override = ownedOverrideValue(starredOverrides, { id: albumId, serverId: albumOwnerServerId });
if (override !== undefined) return override;
return !!album?.album.starred;
}, [albumId, album?.album.starred, serverId, starredOverrides]);
}, [albumId, album?.album.starred, albumOwnerServerId, starredOverrides]);
const albumEntityRating = useMemo(() => {
if (!albumId) return 0;
const override = ownedOverrideValue(userRatingOverrides, { id: albumId, serverId });
const override = ownedOverrideValue(userRatingOverrides, { id: albumId, serverId: albumOwnerServerId });
if (override !== undefined) return override;
return album?.album.userRating ?? 0;
}, [albumId, album?.album.userRating, serverId, userRatingOverrides]);
}, [albumId, album?.album.userRating, albumOwnerServerId, userRatingOverrides]);
// React Compiler rule: manual memoization is intentional and must be preserved.
// eslint-disable-next-line react-hooks/preserve-manual-memoization
@@ -139,35 +144,52 @@ export default function AlbumDetail() {
return album.songs.filter(s => isLosslessSuffix(s.suffix));
}, [album?.songs, losslessOnly]);
const offlineSongIds = useMemo(
() => (effectiveSongs ?? album?.songs ?? []).map(s => s.id),
[effectiveSongs, album?.songs],
const representativeSongs = useMemo(
() => (effectiveSongs ?? album?.songs ?? []).filter(song => (
(!song.serverId
|| !albumOwnerServerId
|| resolveIndexKey(song.serverId) === resolveIndexKey(albumOwnerServerId))
&& (!song.albumId || !albumOwnerId || song.albumId === albumOwnerId)
)),
[effectiveSongs, album?.songs, albumOwnerId, albumOwnerServerId],
);
const offlineSongIds = useMemo(() => representativeSongs.map(s => s.id), [representativeSongs]);
const { resolvedOfflineStatus, offlineProgress } = useAlbumOfflineState(
albumId,
albumOwnerServerId,
offlineSongIds,
);
const { resolvedOfflineStatus, offlineProgress } = useAlbumOfflineState(albumId, serverId, offlineSongIds);
useEffect(() => {
if (!albumId || !album || offlineSongIds.length === 0) return;
const songs = effectiveSongs ?? album.songs;
let cancelled = false;
void reconcileLibraryTierForAlbum(
serverId,
songs,
albumOwnerServerId,
representativeSongs,
{ kind: 'album', sourceId: albumId, displayName: album.album.name },
).then(() => {
if (cancelled) return;
if (!isOfflinePinComplete(albumId, serverId, offlineSongIds)) return;
if (!isOfflinePinComplete(albumId, albumOwnerServerId, offlineSongIds)) return;
useOfflineJobStore.setState(s => ({
jobs: s.jobs.filter(j => j.albumId !== albumId || j.serverId !== serverId),
jobs: s.jobs.filter(j => j.albumId !== albumId || j.serverId !== albumOwnerServerId),
}));
});
return () => { cancelled = true; };
}, [albumId, serverId, album, effectiveSongs, offlineSongIds]);
}, [albumId, albumOwnerServerId, album, representativeSongs, offlineSongIds]);
useEffect(() => {
if (!albumId || !effectiveSongs?.length) return;
rememberAlbumDistinctDiscCovers(albumId, effectiveSongs);
return () => forgetAlbumDistinctDiscCovers(albumId);
}, [albumId, effectiveSongs]);
if (!albumId || representativeSongs.length === 0) return;
rememberAlbumDistinctDiscCovers(albumId, representativeSongs, albumOwnerServerId);
return () => forgetAlbumDistinctDiscCovers(albumId, albumOwnerServerId);
}, [albumId, albumOwnerServerId, representativeSongs]);
useEffect(() => {
bioRequestRef.current += 1;
// React Compiler set-state-in-effect rule: reset route-owned async state.
// eslint-disable-next-line react-hooks/set-state-in-effect
setBio(null);
setBioOpen(false);
}, [albumOwnerServerId, album?.album.artistId]);
const handlePlayAll = () => {
if (!album || !effectiveSongs) return;
@@ -214,27 +236,29 @@ const handleShuffleAll = () => {
if (!t.genre && albumGenre) t.genre = albumGenre;
return t;
});
const track = tracks.find(t => t.id === song.id) || songToTrack(song);
playTrack(track, tracks);
const clickedTrack = songToTrack(song);
const track = tracks.find(t => sameQueueTrack(t, clickedTrack)) || clickedTrack;
playTrack(track, tracks);
};
const handleDoubleClickSong = (song: SubsonicSong) => addTrackToOrbit(song.id, song.serverId);
const handleRate = (songId: string, rating: number) => {
setRatings(r => ({ ...r, [songId]: rating }));
const handleRate = (song: SubsonicSong, rating: number) => {
setRatings(r => ({ ...r, [ownedEntityKey(song)]: rating }));
// F4: optimistic override + retried server sync via the central helper.
queueSongRating(songId, rating, serverId || undefined);
queueSongRating(song.id, rating, song.serverId ?? (albumOwnerServerId || undefined));
};
const handleAlbumEntityRating = async (rating: number) => {
if (!album || album.album.id !== id) return;
if (!album || !albumOwnerServerId) return;
const albumId = album.album.id;
const ratingAtStart = albumId in userRatingOverrides
? userRatingOverrides[albumId]
: (album.album.userRating ?? 0);
const albumOwner = { id: albumId, serverId: albumOwnerServerId };
const ratingAtStart = ownedOverrideValue(userRatingOverrides, albumOwner)
?? album.album.userRating
?? 0;
userMetadataMutationRef.current = true;
setUserRatingOverride(albumId, rating);
setUserRatingOverride(ownedEntityKey(albumOwner), rating);
if (albumEntityRatingSupport !== 'full') {
userMetadataMutationRef.current = false;
@@ -242,15 +266,15 @@ const handleShuffleAll = () => {
}
try {
await setRating(albumId, rating, { serverId, kind: 'album' });
await setRating(albumId, rating, { serverId: albumOwnerServerId, kind: 'album' });
setAlbum(cur =>
cur && cur.album.id === albumId
? { ...cur, album: { ...cur.album, userRating: rating } }
: cur,
);
} catch (err) {
setUserRatingOverride(albumId, ratingAtStart);
setEntityRatingSupport(serverId, 'track_only');
setUserRatingOverride(ownedEntityKey(albumOwner), ratingAtStart);
setEntityRatingSupport(albumOwnerServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
@@ -262,15 +286,17 @@ const handleShuffleAll = () => {
};
const handleBio = async () => {
if (!album) return;
if (!album || !albumOwnerServerId) return;
if (bio) { setBioOpen(true); return; }
const info = await getArtistInfo(album.album.artistId);
const generation = ++bioRequestRef.current;
const info = await getArtistInfoForServer(albumOwnerServerId, album.album.artistId);
if (bioRequestRef.current !== generation) return;
setBio(info.biography ?? t('albumDetail.noBio'));
setBioOpen(true);
};
const handleDownload = async () => {
if (!album) return;
if (!album || !albumOwnerServerId) return;
const { name, id: albumId } = album.album;
const folder = auth.downloadFolder || await requestDownloadFolder();
@@ -278,7 +304,7 @@ const handleShuffleAll = () => {
const filename = `${sanitizeFilename(name)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(albumId);
const url = buildDownloadUrlForServer(albumOwnerServerId, albumId);
const downloadId = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
@@ -293,12 +319,13 @@ const handleShuffleAll = () => {
};
const toggleStar = async () => {
if (!album) return;
if (!album || !albumOwnerServerId) return;
const wasStarred = isStarred;
const previousStarred = album.album.starred;
const nextStarred = !wasStarred;
const albumOwner = { id: album.album.id, serverId: albumOwnerServerId };
userMetadataMutationRef.current = true;
setStarredOverride(album.album.id, nextStarred);
setStarredOverride(ownedEntityKey(albumOwner), nextStarred);
setAlbum(prev => prev ? {
...prev,
album: {
@@ -308,7 +335,7 @@ const handleShuffleAll = () => {
} : prev);
try {
const meta = {
serverId: serverId || album.album.serverId,
serverId: albumOwnerServerId,
name: album.album.name,
artist: album.album.artist,
artistId: album.album.artistId,
@@ -319,7 +346,7 @@ const handleShuffleAll = () => {
else await star(album.album.id, 'album', meta);
} catch (e) {
console.error('Failed to toggle star', e);
setStarredOverride(album.album.id, wasStarred);
setStarredOverride(ownedEntityKey(albumOwner), wasStarred);
setAlbum(prev => prev ? {
...prev,
album: {
@@ -334,24 +361,25 @@ const handleShuffleAll = () => {
const toggleSongStar = (song: SubsonicSong, e: React.MouseEvent) => {
e.stopPropagation();
const wasStarred = starredSongs.has(song.id);
const songKey = ownedEntityKey(song);
const wasStarred = starredSongs.has(songKey);
const next = new Set(starredSongs);
if (wasStarred) next.delete(song.id); else next.add(song.id);
if (wasStarred) next.delete(songKey); else next.add(songKey);
setStarredSongs(next);
// F4: optimistic override + retried server sync via the central helper.
queueSongStar(song.id, !wasStarred, song.serverId ?? (serverId || undefined));
queueSongStar(song.id, !wasStarred, song.serverId ?? (albumOwnerServerId || undefined));
};
const handleCacheOffline = useCallback(async () => {
if (!album) return;
if (!album || !albumOwnerServerId) return;
if (resolvedOfflineStatus === 'queued') {
dequeueOfflinePin(album.album.id, serverId);
dequeueOfflinePin(album.album.id, albumOwnerServerId);
return;
}
let songs = effectiveSongs ?? album.songs;
if (serverId && shouldAttemptSubsonicForServer(serverId)) {
let songs = representativeSongs;
if (shouldAttemptSubsonicForServer(albumOwnerServerId)) {
try {
const fresh = await getAlbumForServer(serverId, album.album.id);
const fresh = await getAlbumForServer(albumOwnerServerId, album.album.id);
songs = losslessOnly
? fresh.songs.filter(s => isLosslessSuffix(s.suffix))
: fresh.songs;
@@ -359,20 +387,33 @@ const handleShuffleAll = () => {
/* keep album.songs from the page */
}
}
if (isOfflinePinComplete(album.album.id, serverId, songs.map(s => s.id))) return;
downloadAlbum(album.album.id, album.album.name, albumArtistDisplayName(album.album), album.album.coverArt, album.album.year, songs, serverId);
}, [album, downloadAlbum, serverId, effectiveSongs, losslessOnly, resolvedOfflineStatus]);
if (isOfflinePinComplete(album.album.id, albumOwnerServerId, songs.map(s => s.id))) return;
downloadAlbum(
album.album.id,
album.album.name,
albumArtistDisplayName(album.album),
album.album.coverArt,
album.album.year,
songs,
albumOwnerServerId,
);
}, [album, albumOwnerServerId, downloadAlbum, representativeSongs, losslessOnly, resolvedOfflineStatus]);
const handleRemoveOffline = () => {
if (!album) return;
deleteAlbum(album.album.id, serverId);
if (!album || !albumOwnerServerId) return;
deleteAlbum(album.album.id, albumOwnerServerId);
};
// Must be before early returns — hooks must be called unconditionally.
const mergedStarredSongs = useMemo(() => new Set([
...[...starredSongs].filter(id => starredOverrides[id] !== false),
...Object.entries(starredOverrides).filter(([, v]) => v).map(([k]) => k),
]), [starredSongs, starredOverrides]);
const mergedStarredSongs = useMemo(() => {
const merged = new Set<string>();
for (const song of effectiveSongs ?? album?.songs ?? []) {
const key = ownedEntityKey(song);
const override = ownedOverrideValue(starredOverrides, song);
if (override ?? starredSongs.has(key)) merged.add(key);
}
return merged;
}, [effectiveSongs, album?.songs, starredOverrides, starredSongs]);
const { sortKey, sortDir, handleSort, displayedSongs } = useAlbumDetailSort({
songs: effectiveSongs,
@@ -383,8 +424,8 @@ const handleShuffleAll = () => {
});
const albumCoverServerScope = useMemo(
() => coverServerScopeForServerId(album?.album.serverId || serverId),
[album?.album.serverId, serverId],
() => coverServerScopeForServerId(albumOwnerServerId),
[albumOwnerServerId],
);
const albumCoverRefResolved = useAlbumCoverRef(
album?.album.id,
@@ -422,7 +463,7 @@ const handleShuffleAll = () => {
<div className="album-detail animate-fade-in">
<AlbumHeader
info={info}
serverId={info.serverId ?? serverId}
serverId={albumOwnerServerId}
headerArtistRefs={headerArtistRefs}
songs={songs}
coverRef={albumCoverRefResolved}
@@ -459,7 +500,7 @@ const handleShuffleAll = () => {
setShowPlPicker={setShowPlPicker}
t={t}
actionPolicy={albumActionPolicy}
serverId={serverId}
songs={songs}
/>
)}
@@ -490,7 +531,7 @@ const handleShuffleAll = () => {
<h2 className="section-title album-related-title">{t('albumDetail.moreByArtist', { artist: info.artist })}</h2>
<VirtualCardGrid
items={relatedAlbums}
itemKey={(a, i) => `${a.id}-${i}`}
itemKey={a => ownedEntityKey(a)}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={relatedAlbums.length}
+24 -7
View File
@@ -1,7 +1,7 @@
import { search } from '@/lib/api/subsonicSearch';
import { search, searchForServer } from '@/lib/api/subsonicSearch';
import type { SubsonicAlbum } from '@/lib/api/subsonicTypes';
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useParams, useNavigate, useSearchParams } from 'react-router-dom';
import { ChevronLeft } from 'lucide-react';
import AlbumCard from '@/features/album/components/AlbumCard';
import { useTranslation } from 'react-i18next';
@@ -9,25 +9,41 @@ import { useAuthStore } from '@/store/authStore';
import { usePerfProbeFlags } from '@/lib/perf/perfFlags';
import { albumGridWarmCovers } from '@/cover/layoutSizes';
import { VirtualCardGrid } from '@/ui/VirtualCardGrid';
import { readDetailServerId } from '@/lib/navigation/detailServerScope';
export default function LabelAlbums() {
const { t } = useTranslation();
const perfFlags = usePerfProbeFlags();
const { name } = useParams<{ name: string }>();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [albums, setAlbums] = useState<SubsonicAlbum[]>([]);
const [loading, setLoading] = useState(true);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const activeServerId = useAuthStore(s => s.activeServerId);
const ownerServerId = readDetailServerId(searchParams, activeServerId);
const invalidExplicitServer = searchParams.has('server') && !ownerServerId;
useEffect(() => {
if (!name) return;
let cancelled = false;
// React Compiler set-state-in-effect rule: state set from an async result resolved in this effect.
// eslint-disable-next-line react-hooks/set-state-in-effect
setLoading(true);
if (invalidExplicitServer) {
setAlbums([]);
setLoading(false);
return;
}
// Search for the label name and ask for a large number of albums
search(name, { albumCount: 200, artistCount: 0, songCount: 0 })
const options = { albumCount: 200, artistCount: 0, songCount: 0 };
const request = ownerServerId
? searchForServer(ownerServerId, name, options)
: search(name, options);
request
.then(res => {
if (cancelled) return;
// Filter out albums that don't match the record label exactly if possible,
// to avoid unrelated search hits. We do case-insensitive comparison.
const matches = res.albums.filter(a =>
@@ -38,9 +54,10 @@ export default function LabelAlbums() {
// as a decent best-effort if our strict filter yields nothing.
setAlbums(matches.length > 0 ? matches : res.albums);
})
.catch(console.error)
.finally(() => setLoading(false));
}, [name, musicLibraryFilterVersion]);
.catch(error => { if (!cancelled) console.error(error); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [invalidExplicitServer, name, musicLibraryFilterVersion, ownerServerId]);
return (
<div className="animate-fade-in" style={{ padding: '0 var(--space-6)' }}>
@@ -61,7 +78,7 @@ export default function LabelAlbums() {
) : (
<VirtualCardGrid
items={albums}
itemKey={(a, _i) => a.id}
itemKey={a => `${a.serverId ?? ownerServerId ?? ''}\u0000${a.id}`}
rowVariant="album"
disableVirtualization={perfFlags.disableMainstageVirtualLists}
layoutSignal={albums.length}
@@ -23,7 +23,7 @@ import {
import { ownedOverrideValue } from '@/lib/util/ownedEntityKey';
import { useOfflineBrowseContext } from '@/features/offline';
import { offlineActionPolicy } from '@/features/offline';
import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { buildAlbumDetailPath, buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
interface Props {
currentTrack: Track | null;
@@ -149,7 +149,9 @@ export function PlayerTrackInfo({
: displayTitle}
className="player-track-name"
style={{ cursor: !isRadio && !showPreviewMeta && currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
onClick={() => !isRadio && !showPreviewMeta && currentTrack?.albumId && navigate(
buildAlbumDetailPath(currentTrack.albumId, { serverId: currentTrack.serverId }),
)}
onContextMenu={!isRadio && !showPreviewMeta && currentTrack
? (e) => {
e.preventDefault();
@@ -193,7 +195,9 @@ export function PlayerTrackInfo({
text={albumLine}
className="player-track-album"
style={{ cursor: currentTrack?.albumId ? 'pointer' : 'default' }}
onClick={() => currentTrack?.albumId && navigate(`/album/${currentTrack.albumId}`)}
onClick={() => currentTrack?.albumId && navigate(
buildAlbumDetailPath(currentTrack.albumId, { serverId: currentTrack.serverId }),
)}
/>
)}
{currentTrack && !isRadio && !showPreviewMeta && isLayoutVisible('starRating') && playerPolicy.canRate && (
+1
View File
@@ -14,3 +14,4 @@ export {
playbackProfileIdForTrack,
} from './utils/playback/playbackServer';
export { useVolumeToggle } from './hooks/useVolumeToggle';
export { sameQueueTrack } from './utils/playback/queueIdentity';
@@ -19,7 +19,7 @@ import { OpenArtistRefInline } from '@/ui/OpenArtistRefInline';
import { usePlaybackTrackCoverRef } from '@/cover/useLibraryCoverRef';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import { resolveTrackArtistRefs } from '@/features/playback/utils/playback/trackArtistRefs';
import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { buildAlbumDetailPath, buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { ownedOverrideValue } from '@/lib/util/ownedEntityKey';
interface Props {
@@ -245,7 +245,9 @@ export function QueueCurrentTrack({
</div>
<div
className={`queue-current-sub truncate${currentTrack.albumId ? ' is-link' : ''}`}
onClick={() => currentTrack.albumId && navigate(`/album/${currentTrack.albumId}`)}
onClick={() => currentTrack.albumId && navigate(buildAlbumDetailPath(currentTrack.albumId, {
serverId: currentTrack.serverId,
}))}
>{currentTrack.album}</div>
{currentTrack.year && (
<div className="queue-current-sub">{currentTrack.year}</div>
+2 -2
View File
@@ -26,9 +26,9 @@ describe('detailServerScope', () => {
expect(readDetailServerId(params, 'srv-active')).toBe('srv-b');
});
it('readDetailServerId falls back when server param is unknown', () => {
it('readDetailServerId fails closed when an explicit server param is unknown', () => {
const params = new URLSearchParams('server=missing');
expect(readDetailServerId(params, 'srv-active')).toBe('srv-active');
expect(readDetailServerId(params, 'srv-active')).toBeNull();
});
it('resolves route and fallback index keys to profile ids', () => {
+1 -2
View File
@@ -44,8 +44,7 @@ export function readDetailServerId(
fallback: string | null | undefined,
): string | null {
const raw = searchParams.get('server');
const explicit = raw ? findServerByIdOrIndexKey(raw)?.id : undefined;
if (explicit) return explicit;
if (raw) return findServerByIdOrIndexKey(raw)?.id ?? null;
if (!fallback) return null;
return findServerByIdOrIndexKey(fallback)?.id ?? null;
}