mirror of
https://github.com/kilyabin/psysonic.git
synced 2026-07-22 06:25:41 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user