mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-22 15:25:46 +00:00
fix(context-menu): preserve item server ownership
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
Reference in New Issue
Block a user