fix(context-menu): preserve item server ownership

This commit is contained in:
cucadmuh
2026-07-19 04:42:56 +03:00
parent 645a1c6293
commit 8e8cf72f23
10 changed files with 320 additions and 70 deletions
@@ -9,8 +9,8 @@ import StarRating from '@/ui/StarRating';
import { AlbumToPlaylistSubmenu } from '@/features/contextMenu/components/AlbumArtistToPlaylistSubmenu';
import { MultiAlbumToPlaylistSubmenu } from '@/features/contextMenu/components/MultiAlbumToPlaylistSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
import { buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { buildAlbumDetailPath, buildArtistDetailPath } from '@/lib/navigation/detailServerScope';
import { ownedEntityKey, ownedOverrideValue } from '@/lib/util/ownedEntityKey';
export default function AlbumContextItems(props: ContextMenuItemsProps) {
const {
@@ -33,7 +33,9 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
const albumRatingDisabled = entityRatingSupport === 'track_only';
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(`/album/${album.id}`))}>
<div className="context-menu-item" onClick={() => handleAction(() => goLibrary(
buildAlbumDetailPath(album.id, { serverId: album.serverId }),
))}>
<Play size={14} /> {t('contextMenu.openAlbum')}
</div>
<div className="context-menu-item" onClick={() => handleAction(async () => {
@@ -92,7 +94,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
<StarRating
value={keyboardRating?.kind === 'album' && keyboardRating.id === album.id
? keyboardRating.value
: userRatingOverrides[album.id] ?? album.userRating ?? 0}
: ownedOverrideValue(userRatingOverrides, album) ?? album.userRating ?? 0}
disabled={albumRatingDisabled}
labelKey="entityRating.albumAriaLabel"
onChange={r => { setKeyboardRating({ kind: 'album', id: album.id, value: r }); applyAlbumRating(album, r); }}
@@ -100,11 +102,11 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink('album', album.id, album.serverId))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
{offlinePolicy.canDownload && (
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => downloadAlbum(album.name, album.id, album.serverId))}>
<Download size={14} /> {t('contextMenu.download')}
</div>
)}
@@ -134,7 +136,7 @@ export default function AlbumContextItems(props: ContextMenuItemsProps) {
const multiAlbumRatingId = [...albumIds].sort().join('\x1e');
const unifiedAlbumRating = (() => {
if (albums.length === 0) return 0;
const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
const vals = albums.map(a => ownedOverrideValue(userRatingOverrides, a) ?? a.userRating ?? 0);
const first = vals[0];
return vals.every(v => v === first) ? first : 0;
})();
@@ -6,7 +6,7 @@ import StarRating from '@/ui/StarRating';
import { ArtistToPlaylistSubmenu } from '@/features/contextMenu/components/AlbumArtistToPlaylistSubmenu';
import { MultiArtistToPlaylistSubmenu } from '@/features/contextMenu/components/MultiArtistToPlaylistSubmenu';
import type { ContextMenuItemsProps } from '@/features/contextMenu/components/contextMenuItemTypes';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
import { ownedEntityKey, ownedOverrideValue } from '@/lib/util/ownedEntityKey';
export default function ArtistContextItems(props: ContextMenuItemsProps) {
const {
@@ -27,7 +27,12 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
const artistRatingDisabled = entityRatingSupport === 'track_only';
return (
<>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(artist.id, artist.name))}>
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(
artist.id,
artist.name,
undefined,
artist.serverId,
))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
{offlinePolicy.canAddToPlaylist && (
@@ -44,7 +49,11 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
)}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(shareKindOverride ?? 'artist', artist.id))}>
<div className="context-menu-item" onClick={() => handleAction(() => copyShareLink(
shareKindOverride ?? 'artist',
artist.id,
artist.serverId,
))}>
<Share2 size={14} /> {t('contextMenu.shareLink')}
</div>
{(offlinePolicy.canFavorite || offlinePolicy.canRate) && (
@@ -79,7 +88,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
<StarRating
value={keyboardRating?.kind === 'artist' && keyboardRating.id === artist.id
? keyboardRating.value
: userRatingOverrides[artist.id] ?? artist.userRating ?? 0}
: ownedOverrideValue(userRatingOverrides, artist) ?? artist.userRating ?? 0}
disabled={artistRatingDisabled}
labelKey="entityRating.artistAriaLabel"
onChange={r => { setKeyboardRating({ kind: 'artist', id: artist.id, value: r }); applyArtistRating(artist, r); }}
@@ -100,7 +109,7 @@ export default function ArtistContextItems(props: ContextMenuItemsProps) {
const multiArtistRatingId = [...artistIds].sort().join('\x1e');
const unifiedArtistRating = (() => {
if (artists.length === 0) return 0;
const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
const vals = artists.map(a => ownedOverrideValue(userRatingOverrides, a) ?? a.userRating ?? 0);
const first = vals[0];
return vals.every(v => v === first) ? first : 0;
})();
@@ -41,6 +41,15 @@ function contextMenuSurfaceForType(type: string | null): OfflineSurface {
}
}
function contextMenuServerIds(item: unknown): string[] {
const items = Array.isArray(item) ? item : [item];
return [...new Set(items.flatMap(candidate => {
if (!candidate || typeof candidate !== 'object' || !('serverId' in candidate)) return [];
const serverId = (candidate as { serverId?: unknown }).serverId;
return typeof serverId === 'string' && serverId ? [serverId] : [];
}))];
}
export { AddToPlaylistSubmenu };
@@ -69,9 +78,6 @@ export default function ContextMenu() {
}))
);
const auth = useAuthStore();
const entityRatingSupport =
auth.activeServerId ? auth.entityRatingSupportByServer[auth.activeServerId] ?? 'unknown' : 'unknown';
const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]);
const menuRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
@@ -175,6 +181,22 @@ export default function ContextMenu() {
shareKindOverride,
pinToPlaybackServer = false,
} = contextMenu;
const itemServerIds = contextMenuServerIds(item);
const capabilityServerIds = itemServerIds.length > 0
? itemServerIds
: auth.activeServerId ? [auth.activeServerId] : [];
const ratingSupports = capabilityServerIds.map(
serverId => auth.entityRatingSupportByServer[serverId] ?? 'unknown',
);
const entityRatingSupport = ratingSupports.includes('track_only')
? 'track_only'
: ratingSupports.length > 0 && ratingSupports.every(value => value === 'full')
? 'full'
: 'unknown';
const singleOwnerServerId = capabilityServerIds.length === 1 ? capabilityServerIds[0] : undefined;
const audiomuseNavidromeEnabled = Boolean(
singleOwnerServerId && auth.audiomuseNavidromeByServer[singleOwnerServerId],
);
const navigateLibrary = pinToPlaybackServer
? navigatePlaybackLibrary
: (path: string) => { navigate(path); };
@@ -212,8 +234,8 @@ export default function ContextMenu() {
[t],
);
const startRadio = (artistId: string, artistName: string, seedTrack?: Track) =>
startRadioAction(artistId, artistName, playTrack, seedTrack);
const startRadio = (artistId: string, artistName: string, seedTrack?: Track, serverId?: string) =>
startRadioAction(artistId, artistName, playTrack, seedTrack, serverId);
const startInstantMix = (song: Track) => startInstantMixAction(song, t);
@@ -50,9 +50,9 @@ export interface ContextMenuItemsProps {
applyAlbumRating: (album: SubsonicAlbum, rating: number) => void;
applyArtistRating: (artist: SubsonicArtist, rating: number) => void;
handleAction: (action: () => void | Promise<void>) => Promise<void>;
startRadio: (artistId: string, artistName: string, seedTrack?: Track) => void;
startRadio: (artistId: string, artistName: string, seedTrack?: Track, serverId?: string) => void;
startInstantMix: (song: Track) => void;
downloadAlbum: (albumName: string, albumId: string) => Promise<void>;
downloadAlbum: (albumName: string, albumId: string, serverId?: string) => Promise<void>;
copyShareLink: (kind: EntityShareKind, id: string, serverId?: string) => void;
isStarred: (id: string, itemStarred?: string, serverId?: string) => boolean;
/** When true, album/artist links switch to the queue server before routing. */
@@ -38,10 +38,11 @@ export function useContextMenuRating({
}, []);
const applyAlbumRating = useCallback((album: SubsonicAlbum, rating: number) => {
setUserRatingOverride(album.id, rating);
const ownerServerId = album.serverId ?? activeServerId ?? undefined;
setUserRatingOverride(ownedEntityKey(album), rating);
if (entityRatingSupport !== 'full') return;
setRating(album.id, rating, { serverId: album.serverId ?? activeServerId ?? undefined, kind: 'album' }).catch(err => {
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
setRating(album.id, rating, { serverId: ownerServerId, kind: 'album' }).catch(err => {
if (ownerServerId) setEntityRatingSupport(ownerServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
@@ -51,10 +52,11 @@ export function useContextMenuRating({
}, [setUserRatingOverride, entityRatingSupport, activeServerId, setEntityRatingSupport, t]);
const applyArtistRating = useCallback((artist: SubsonicArtist, rating: number) => {
setUserRatingOverride(artist.id, rating);
const ownerServerId = artist.serverId ?? activeServerId ?? undefined;
setUserRatingOverride(ownedEntityKey(artist), rating);
if (entityRatingSupport !== 'full') return;
setRating(artist.id, rating, { serverId: artist.serverId ?? activeServerId ?? undefined, kind: 'artist' }).catch(err => {
if (activeServerId) setEntityRatingSupport(activeServerId, 'track_only');
setRating(artist.id, rating, { serverId: ownerServerId, kind: 'artist' }).catch(err => {
if (ownerServerId) setEntityRatingSupport(ownerServerId, 'track_only');
showToast(
typeof err === 'string' ? err : err instanceof Error ? err.message : t('entityRating.saveFailed'),
4500,
@@ -70,27 +72,27 @@ export function useContextMenuRating({
}
if (kind === 'album' && type === 'album') {
const album = item as SubsonicAlbum;
if (album.id === id) return userRatingOverrides[id] ?? album.userRating ?? 0;
if (album.id === id) return userRatingOverrides[ownedEntityKey(album)] ?? userRatingOverrides[id] ?? album.userRating ?? 0;
}
if (kind === 'album' && type === 'multi-album') {
const albums = item as SubsonicAlbum[];
const compositeId = [...albums.map(a => a.id)].sort().join('\x1e');
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
if (albums.length === 0) return 0;
const vals = albums.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
const vals = albums.map(a => userRatingOverrides[ownedEntityKey(a)] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0);
const first = vals[0];
return vals.every(v => v === first) ? first : 0;
}
if (kind === 'artist' && type === 'artist') {
const artist = item as SubsonicArtist;
if (artist.id === id) return userRatingOverrides[id] ?? artist.userRating ?? 0;
if (artist.id === id) return userRatingOverrides[ownedEntityKey(artist)] ?? userRatingOverrides[id] ?? artist.userRating ?? 0;
}
if (kind === 'artist' && type === 'multi-artist') {
const artists = item as SubsonicArtist[];
const compositeId = [...artists.map(a => a.id)].sort().join('\x1e');
if (id !== compositeId) return userRatingOverrides[id] ?? 0;
if (artists.length === 0) return 0;
const vals = artists.map(a => userRatingOverrides[a.id] ?? a.userRating ?? 0);
const vals = artists.map(a => userRatingOverrides[ownedEntityKey(a)] ?? userRatingOverrides[a.id] ?? a.userRating ?? 0);
const first = vals[0];
return vals.every(v => v === first) ? first : 0;
}
@@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Track } from '@/lib/media/trackTypes';
const mocks = vi.hoisted(() => ({
getSimilarForServer: vi.fn(),
getTopForServer: vi.fn(),
enqueueRadio: vi.fn(),
resume: vi.fn(),
setRadioArtistId: vi.fn(),
buildDownloadUrlForServer: vi.fn(),
downloadZip: vi.fn(),
zipStart: vi.fn(),
zipComplete: vi.fn(),
zipFail: vi.fn(),
state: {
currentTrack: null as Track | null,
isPlaying: false,
queueItems: [],
queueIndex: 0,
},
}));
vi.mock('@/lib/api/subsonicArtists', () => ({
fetchSimilarTracksRoutedForServer: vi.fn(),
getSimilarSongs2ForServer: mocks.getSimilarForServer,
getTopSongsForServer: mocks.getTopForServer,
}));
vi.mock('@/features/playback/store/playerStore', () => ({
usePlayerStore: {
getState: () => ({
...mocks.state,
enqueueRadio: mocks.enqueueRadio,
resume: mocks.resume,
setRadioArtistId: mocks.setRadioArtistId,
}),
},
}));
vi.mock('@tauri-apps/api/path', () => ({ join: vi.fn(async (...parts: string[]) => parts.join('/')) }));
vi.mock('@/lib/api/subsonicStreamUrl', () => ({
buildDownloadUrlForServer: mocks.buildDownloadUrlForServer,
}));
vi.mock('@/lib/api/downloadZip', () => ({ downloadZip: mocks.downloadZip }));
vi.mock('@/features/offline', () => ({
useDownloadModalStore: { getState: () => ({ requestFolder: vi.fn() }) },
useZipDownloadStore: {
getState: () => ({
start: mocks.zipStart,
complete: mocks.zipComplete,
fail: mocks.zipFail,
}),
},
}));
import { downloadAlbum, startRadio } from './contextMenuActions';
import { useAuthStore } from '@/store/authStore';
const SEED: Track = {
id: 'shared',
serverId: 'srv-owner',
title: 'Seed',
artist: 'Artist',
artistId: 'artist-1',
album: 'Album',
albumId: 'album-1',
duration: 120,
};
describe('context-menu radio ownership', () => {
beforeEach(() => {
mocks.getSimilarForServer.mockReset().mockResolvedValue([]);
mocks.getTopForServer.mockReset().mockResolvedValue([]);
mocks.enqueueRadio.mockReset();
mocks.resume.mockReset();
mocks.setRadioArtistId.mockReset();
mocks.buildDownloadUrlForServer.mockReset().mockReturnValue('https://owner.test/download');
mocks.downloadZip.mockReset().mockResolvedValue(undefined);
mocks.zipStart.mockReset();
mocks.zipComplete.mockReset();
mocks.zipFail.mockReset();
mocks.state.currentTrack = null;
mocks.state.isPlaying = false;
mocks.state.queueItems = [];
mocks.state.queueIndex = 0;
useAuthStore.setState({ downloadFolder: '/downloads' });
});
it('loads seed radio candidates from the seed owner', async () => {
mocks.getSimilarForServer.mockResolvedValue([{ id: 'similar', title: 'Similar', serverId: 'srv-owner' }]);
const playTrack = vi.fn();
await startRadio('artist-1', 'Artist', playTrack, SEED);
expect(playTrack).toHaveBeenCalledWith(SEED, [SEED]);
expect(mocks.getSimilarForServer).toHaveBeenCalledWith('srv-owner', 'artist-1');
expect(mocks.getTopForServer).toHaveBeenCalledWith('srv-owner', 'Artist');
expect(mocks.enqueueRadio).toHaveBeenCalledWith(
[expect.objectContaining({ id: 'similar', serverId: 'srv-owner', radioAdded: true })],
'artist-1',
);
});
it('ignores an older artist-radio request that resolves after a newer owner', async () => {
let resolveFirstTop: ((songs: Array<{ id: string; title: string; serverId: string }>) => void) | undefined;
mocks.getTopForServer
.mockImplementationOnce(() => new Promise(resolve => { resolveFirstTop = resolve; }))
.mockResolvedValueOnce([{ id: 'new', title: 'New', serverId: 'srv-b' }]);
const playTrack = vi.fn();
const first = startRadio('artist-a', 'Artist A', playTrack, undefined, 'srv-a');
await startRadio('artist-b', 'Artist B', playTrack, undefined, 'srv-b');
resolveFirstTop?.([{ id: 'old', title: 'Old', serverId: 'srv-a' }]);
await first;
expect(playTrack).toHaveBeenCalledTimes(1);
expect(playTrack).toHaveBeenCalledWith(
expect.objectContaining({ id: 'new', serverId: 'srv-b' }),
[expect.objectContaining({ id: 'new', serverId: 'srv-b' })],
);
});
it('builds album downloads with the album owner', async () => {
await downloadAlbum('Owner Album', 'shared', 'srv-owner');
expect(mocks.buildDownloadUrlForServer).toHaveBeenCalledWith('srv-owner', 'shared');
expect(mocks.downloadZip).toHaveBeenCalledWith(expect.objectContaining({
url: 'https://owner.test/download',
}));
expect(mocks.zipComplete).toHaveBeenCalledOnce();
});
});
@@ -1,8 +1,12 @@
import { join } from '@tauri-apps/api/path';
import { downloadZip } from '@/lib/api/downloadZip';
import { getSimilarSongs2, fetchSimilarTracksRouted, getTopSongs } from '@/lib/api/subsonicArtists';
import {
fetchSimilarTracksRoutedForServer,
getSimilarSongs2ForServer,
getTopSongsForServer,
} from '@/lib/api/subsonicArtists';
import { filterSongsForLuckyMixRatings, getMixMinRatingsConfigFromAuth } from '@/features/playback/utils/mixRatingFilter';
import { buildDownloadUrl } from '@/lib/api/subsonicStreamUrl';
import { buildDownloadUrlForServer } from '@/lib/api/subsonicStreamUrl';
import { useAuthStore } from '@/store/authStore';
import { usePlayerStore } from '@/features/playback/store/playerStore';
import type { Track } from '@/lib/media/trackTypes';
@@ -14,6 +18,9 @@ import { copyEntityShareLink } from '@/lib/share/copyEntityShareLink';
import { sanitizeFilename, shuffleArray } from '@/features/contextMenu/utils/contextMenuHelpers';
import { songToTrack } from '@/lib/media/songToTrack';
import { showToast } from '@/lib/dom/toast';
import { ownedEntityKey } from '@/lib/util/ownedEntityKey';
let contextRadioGeneration = 0;
export async function copyShareLink(
kind: EntityShareKind,
@@ -31,16 +38,24 @@ export async function startRadio(
artistName: string,
playTrack: (track: Track, queue: Track[]) => void,
seedTrack?: Track,
serverId?: string,
) {
const ownerServerId = seedTrack?.serverId ?? serverId ?? useAuthStore.getState().activeServerId;
if (!ownerServerId) return;
const generation = ++contextRadioGeneration;
if (seedTrack) {
const state = usePlayerStore.getState();
if (state.currentTrack?.id === seedTrack.id) {
if (state.currentTrack && ownedEntityKey(state.currentTrack) === ownedEntityKey(seedTrack)) {
if (!state.isPlaying) state.resume();
} else {
playTrack(seedTrack, [seedTrack]);
}
try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
const [similar, top] = await Promise.all([
getSimilarSongs2ForServer(ownerServerId, artistId),
getTopSongsForServer(ownerServerId, artistName),
]);
if (generation !== contextRadioGeneration) return;
const similarTracks = shuffleArray(
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const })),
);
@@ -57,14 +72,17 @@ export async function startRadio(
}
// Artist radio without seed
const similarPromise = getSimilarSongs2(artistId).catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2>>);
const similarPromise = getSimilarSongs2ForServer(ownerServerId, artistId)
.catch(() => [] as Awaited<ReturnType<typeof getSimilarSongs2ForServer>>);
try {
const top = await getTopSongs(artistName);
const top = await getTopSongsForServer(ownerServerId, artistName);
if (generation !== contextRadioGeneration) return;
const topTracks = shuffleArray(
top.map(t => ({ ...songToTrack(t), radioAdded: true as const })),
);
if (topTracks.length === 0) {
const similar = await similarPromise;
if (generation !== contextRadioGeneration) return;
const fallback = shuffleArray(
similar.map(t => ({ ...songToTrack(t), radioAdded: true as const })),
);
@@ -86,6 +104,7 @@ export async function startRadio(
playTrack(topTracks[0], [topTracks[0]]);
}
similarPromise.then(similar => {
if (generation !== contextRadioGeneration) return;
const similarTracks = shuffleArray(
similar
.map(t => ({ ...songToTrack(t), radioAdded: true as const }))
@@ -110,11 +129,13 @@ export async function startInstantMix(
song: Track,
t: (key: string) => string,
) {
contextRadioGeneration += 1;
usePlayerStore.getState().reseedQueueForInstantMix(song);
const serverId = useAuthStore.getState().activeServerId;
const serverId = song.serverId ?? useAuthStore.getState().activeServerId;
if (!serverId) return;
try {
const similar = await fetchSimilarTracksRouted(song.id, 50);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const similar = await fetchSimilarTracksRoutedForServer(serverId, song.id, 50);
useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false);
const mixCfg = getMixMinRatingsConfigFromAuth();
const ratedFiltered = await filterSongsForLuckyMixRatings(
similar.filter(s => s.id !== song.id),
@@ -129,12 +150,12 @@ export async function startInstantMix(
}
} catch (e) {
console.error('Instant mix failed', e);
if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true);
showToast(t('contextMenu.instantMixFailed'), 5000, 'error');
}
}
export async function downloadAlbum(albumName: string, albumId: string) {
export async function downloadAlbum(albumName: string, albumId: string, serverId?: string) {
const auth = useAuthStore.getState();
const requestDownloadFolder = useDownloadModalStore.getState().requestFolder;
const folder = auth.downloadFolder || await requestDownloadFolder();
@@ -142,7 +163,9 @@ export async function downloadAlbum(albumName: string, albumId: string) {
const filename = `${sanitizeFilename(albumName)}.zip`;
const destPath = await join(folder, filename);
const url = buildDownloadUrl(albumId);
const ownerServerId = serverId ?? auth.activeServerId;
if (!ownerServerId) return;
const url = buildDownloadUrlForServer(ownerServerId, albumId);
const id = crypto.randomUUID();
const { start, complete, fail } = useZipDownloadStore.getState();
+12 -12
View File
@@ -14,12 +14,12 @@ vi.mock('@/lib/api/subsonicLibrary', () => ({
similarSongsRequestCount: (count: number) => count,
}));
import { api } from '@/lib/api/subsonicClient';
import { apiForServer } from '@/lib/api/subsonicClient';
import { fetchSimilarTracksRouted } from '@/lib/api/subsonicArtists';
import { useAuthStore } from '@/store/authStore';
const SID = 'srv-router';
const apiMock = vi.mocked(api);
const apiForServerMock = vi.mocked(apiForServer);
function seedServer(identity: Record<string, unknown>, probes: Record<string, unknown>) {
useAuthStore.setState({
@@ -37,44 +37,44 @@ const SIMILAR_RESPONSE = { similarSongs: { song: [{ id: 'legacy-1', title: 'Lega
describe('fetchSimilarTracksRouted', () => {
beforeEach(() => {
apiMock.mockReset();
apiForServerMock.mockReset();
});
it('prefers sonicSimilarity on Navidrome 0.62 with plugin', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'present' },
});
apiMock.mockImplementation(async (endpoint: string) =>
apiForServerMock.mockImplementation(async (_serverId: string, endpoint: string) =>
(endpoint === 'getSonicSimilarTracks.view' ? SONIC_RESPONSE : SIMILAR_RESPONSE) as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['sonic-1']);
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).not.toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
expect(apiForServerMock).toHaveBeenCalledWith(SID, 'getSonicSimilarTracks.view', expect.anything());
expect(apiForServerMock).not.toHaveBeenCalledWith(SID, 'getSimilarSongs.view', expect.anything());
});
it('falls back to legacy when sonic returns empty', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'present' },
});
apiMock.mockImplementation(async (endpoint: string) =>
apiForServerMock.mockImplementation(async (_serverId: string, endpoint: string) =>
(endpoint === 'getSonicSimilarTracks.view' ? { sonicMatch: [] } : SIMILAR_RESPONSE) as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['legacy-1']);
expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
expect(apiForServerMock).toHaveBeenCalledWith(SID, 'getSonicSimilarTracks.view', expect.anything());
expect(apiForServerMock).toHaveBeenCalledWith(SID, 'getSimilarSongs.view', expect.anything());
});
it('uses legacy only on Navidrome 0.62 without plugin', async () => {
seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, {
audiomusePluginProbeByServer: { [SID]: 'absent' },
});
apiMock.mockImplementation(async () => SIMILAR_RESPONSE as never);
apiForServerMock.mockImplementation(async () => SIMILAR_RESPONSE as never);
const result = await fetchSimilarTracksRouted('seed', 10);
expect(result.map(s => s.id)).toEqual(['legacy-1']);
expect(apiMock).not.toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything());
expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything());
expect(apiForServerMock).not.toHaveBeenCalledWith(SID, 'getSonicSimilarTracks.view', expect.anything());
expect(apiForServerMock).toHaveBeenCalledWith(SID, 'getSimilarSongs.view', expect.anything());
});
});
@@ -42,6 +42,7 @@ import {
getArtistForServer,
getArtistInfoForServer,
getArtistsForServer,
getSimilarSongs2ForServer,
getTopSongsForServer,
uploadArtistImageForServer,
} from '@/lib/api/subsonicArtists';
@@ -135,6 +136,21 @@ describe('explicit-server artist wrappers', () => {
]);
});
it('loads and stamps similar songs for one explicit server', async () => {
apiForServerMock.mockResolvedValue({
similarSongs2: { song: [{ id: 'similar-1', title: 'Similar' }] },
});
await expect(getSimilarSongs2ForServer('srv-similar', 'seed', 12)).resolves.toEqual([
{ id: 'similar-1', title: 'Similar', serverId: 'srv-similar' },
]);
expect(apiForServerMock).toHaveBeenCalledWith(
'srv-similar',
'getSimilarSongs2.view',
expect.objectContaining({ id: 'seed', count: 12 }),
);
});
it('uploads an artist image with the explicit server credentials', async () => {
findServerMock.mockReturnValue({
id: 'srv-owner',
+63 -17
View File
@@ -7,8 +7,7 @@ import {
libraryFilterParamsForServer,
librarySelectionForServer,
} from '@/lib/api/subsonicClient';
import { filterSongsToServerLibrary } from '@/lib/api/subsonicLibrary';
import { filterSongsToActiveLibrary, similarSongsRequestCount } from '@/lib/api/subsonicLibrary';
import { filterSongsToServerLibrary, similarSongsRequestCount } from '@/lib/api/subsonicLibrary';
import {
FEATURE_AUDIOMUSE_SIMILAR_TRACKS,
OP_SIMILAR_TRACKS,
@@ -203,12 +202,26 @@ export async function getTopSongsForServer(
}
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) return [];
return getSimilarSongs2ForServer(serverId, id, count);
}
export async function getSimilarSongs2ForServer(
serverId: string,
id: string,
count = 50,
): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
const data = await apiForServer<{ similarSongs2: { song: SubsonicSong[] } }>(
serverId,
'getSimilarSongs2.view',
{ id, count: requestCount, ...libraryFilterParamsForServer(serverId) },
);
const raw = data.similarSongs2?.song ?? [];
const filtered = await filterSongsToActiveLibrary(raw);
return filtered.slice(0, count);
const filtered = await filterSongsToServerLibrary(raw, serverId);
return filtered.slice(0, count).map(song => ({ ...song, serverId }));
} catch {
return [];
}
@@ -216,14 +229,28 @@ export async function getSimilarSongs2(id: string, count = 50): Promise<Subsonic
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicSong[]> {
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) return [];
return getSimilarSongsForServer(serverId, id, count);
}
export async function getSimilarSongsForServer(
serverId: string,
id: string,
count = 50,
): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>('getSimilarSongs.view', { id, count: requestCount, ...libraryFilterParams() });
const data = await apiForServer<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>(
serverId,
'getSimilarSongs.view',
{ id, count: requestCount, ...libraryFilterParamsForServer(serverId) },
);
const raw = data.similarSongs?.song;
if (!raw) return [];
const list = Array.isArray(raw) ? raw : [raw];
const filtered = await filterSongsToActiveLibrary(list);
return filtered.slice(0, count);
const filtered = await filterSongsToServerLibrary(list, serverId);
return filtered.slice(0, count).map(song => ({ ...song, serverId }));
} catch {
return [];
}
@@ -235,18 +262,29 @@ export async function getSimilarSongs(id: string, count = 50): Promise<SubsonicS
* has no provider (HTTP 404) so callers can fall back.
*/
export async function getSonicSimilarTracks(id: string, count = 50): Promise<SubsonicSong[]> {
const serverId = useAuthStore.getState().activeServerId;
if (!serverId) return [];
return getSonicSimilarTracksForServer(serverId, id, count);
}
export async function getSonicSimilarTracksForServer(
serverId: string,
id: string,
count = 50,
): Promise<SubsonicSong[]> {
try {
const requestCount = similarSongsRequestCount(count);
const data = await api<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>(
const data = await apiForServer<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>(
serverId,
'getSonicSimilarTracks.view',
{ id, count: requestCount, ...libraryFilterParams() },
{ id, count: requestCount, ...libraryFilterParamsForServer(serverId) },
);
const raw = data.sonicMatch;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
const songs = list.map(m => m.entry).filter((e): e is SubsonicSong => !!e);
if (songs.length === 0) return [];
const filtered = await filterSongsToActiveLibrary(songs);
return filtered.slice(0, count);
const filtered = await filterSongsToServerLibrary(songs, serverId);
return filtered.slice(0, count).map(song => ({ ...song, serverId }));
} catch {
return [];
}
@@ -259,13 +297,21 @@ export async function getSonicSimilarTracks(id: string, count = 50): Promise<Sub
*/
export async function fetchSimilarTracksRouted(songId: string, count = 50): Promise<SubsonicSong[]> {
const { activeServerId } = useAuthStore.getState();
if (!activeServerId) return getSimilarSongs(songId, count);
const routes = resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS);
if (routes.length === 0) return getSimilarSongs(songId, count);
if (!activeServerId) return [];
return fetchSimilarTracksRoutedForServer(activeServerId, songId, count);
}
export async function fetchSimilarTracksRoutedForServer(
serverId: string,
songId: string,
count = 50,
): Promise<SubsonicSong[]> {
const routes = resolveCallRoutesForServer(serverId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS);
if (routes.length === 0) return getSimilarSongsForServer(serverId, songId, count);
for (const route of routes) {
const songs = route.transport === 'opensubsonic'
? await getSonicSimilarTracks(songId, count)
: await getSimilarSongs(songId, count);
? await getSonicSimilarTracksForServer(serverId, songId, count)
: await getSimilarSongsForServer(serverId, songId, count);
if (songs.length > 0) return songs;
}
return [];