feat(discovery): Navidrome AudioMuse-AI client integration and library scoping

Add per-server toggle for AudioMuse-style discovery: Instant Mix via
getSimilarSongs, server similar artists instead of Last.fm on artist pages,
and higher similar-artist count in Now Playing when enabled.

When browsing a single music folder, filter similar/top song results by
album ids from that scope (Navidrome does not apply musicFolderId to those
endpoints). Request larger similar batches under a narrow scope.

Keep radio-from-track as two blocks (shuffled top songs, then shuffled
similar-by-artist) so it differs from Instant Mix. Show Alpha badge beside
the setting. Add research/ to .gitignore.
This commit is contained in:
Maxim Isaev
2026-04-10 12:07:32 +03:00
parent 74985fe331
commit 36f3d42dbe
14 changed files with 259 additions and 29 deletions
+44 -7
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info } from 'lucide-react';
import { Play, ListPlus, Radio, Heart, Download, ChevronRight, User, Disc3, ListMusic, Plus, Info, Sparkles } from 'lucide-react';
import LastfmIcon from './LastfmIcon';
import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { SubsonicAlbum, SubsonicArtist, star, unstar, getSimilarSongs2, getSimilarSongs, getTopSongs, buildDownloadUrl, getAlbum, getPlaylists, getPlaylist, createPlaylist, updatePlaylist, SubsonicPlaylist, setRating } from '../api/subsonic';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -177,6 +177,7 @@ export default function ContextMenu() {
const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
const auth = useAuthStore();
const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null);
@@ -235,12 +236,15 @@ export default function ContextMenu() {
// same "Top 5" in the same order every time.
try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
const radioTracks = shuffleArray(
[...top, ...similar]
.map(songToTrack)
.filter(t => t.id !== seedTrack.id)
.map(t => ({ ...t, radioAdded: true as const }))
// Keep artist top songs and similar-by-artist in two blocks (each shuffled), not one blended pile —
// otherwise this feels the same as Instant Mix (track-based similar only).
const topTracks = shuffleArray(
top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
);
const similarTracks = shuffleArray(
similar.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
);
const radioTracks = [...topTracks, ...similarTracks];
if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
} catch (e) {
console.error('Failed to load radio queue', e);
@@ -306,6 +310,29 @@ export default function ContextMenu() {
}
};
const startInstantMix = async (song: Track) => {
const state = usePlayerStore.getState();
if (state.currentTrack?.id === song.id) {
if (!state.isPlaying) state.resume();
} else {
playTrack(song, [song]);
}
try {
const similar = await getSimilarSongs(song.id, 50);
const shuffled = shuffleArray(
similar
.filter(s => s.id !== song.id)
.map(s => ({ ...songToTrack(s), radioAdded: true as const }))
);
if (shuffled.length > 0) {
const aid = song.artistId?.trim() || undefined;
usePlayerStore.getState().enqueueRadio(shuffled, aid);
}
} catch (e) {
console.error('Instant mix failed', e);
}
};
const downloadAlbum = async (albumName: string, albumId: string) => {
const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return;
@@ -389,6 +416,11 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
{audiomuseNavidromeEnabled && (
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
<Sparkles size={14} /> {t('contextMenu.instantMix')}
</div>
)}
<div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !starred);
@@ -539,6 +571,11 @@ export default function ContextMenu() {
<div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')}
</div>
{audiomuseNavidromeEnabled && (
<div className="context-menu-item" onClick={() => handleAction(() => startInstantMix(song))}>
<Sparkles size={14} /> {t('contextMenu.instantMix')}
</div>
)}
<div className="context-menu-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<StarRating