mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
feat(album): preserve scoped detail ownership
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 effect’s 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,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user