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
+3
View File
@@ -40,3 +40,6 @@ memory/
# Local scratchpad / notes (not committed) # Local scratchpad / notes (not committed)
tmp/ tmp/
# Third-party clones for local research (not committed)
research/
+92 -6
View File
@@ -309,6 +309,68 @@ export async function getAlbumList(
return data.albumList2?.album ?? []; return data.albumList2?.album ?? [];
} }
/**
* Navidrome (and some servers) ignore `musicFolderId` on getSimilarSongs / getSimilarSongs2 / getTopSongs,
* so similar tracks can leak from other libraries. When the user scoped to one folder, we keep a set of
* album ids in that scope (paginated getAlbumList2) and drop songs whose albumId is not in the set.
*/
let scopedLibraryAlbumIdCache: {
serverId: string;
folderId: string;
filterVersion: number;
ids: Set<string>;
} | null = null;
async function albumIdsInActiveLibraryScope(): Promise<Set<string> | null> {
const { activeServerId, musicLibraryFilterByServer, musicLibraryFilterVersion } = useAuthStore.getState();
if (!activeServerId) return null;
const folder = musicLibraryFilterByServer[activeServerId];
if (folder === undefined || folder === 'all') {
scopedLibraryAlbumIdCache = null;
return null;
}
const hit = scopedLibraryAlbumIdCache;
if (
hit &&
hit.serverId === activeServerId &&
hit.folderId === folder &&
hit.filterVersion === musicLibraryFilterVersion
) {
return hit.ids;
}
const ids = new Set<string>();
const pageSize = 500;
let offset = 0;
for (;;) {
const albums = await getAlbumList('alphabeticalByName', pageSize, offset);
for (const a of albums) ids.add(a.id);
if (albums.length < pageSize) break;
offset += pageSize;
if (offset > 500_000) break;
}
scopedLibraryAlbumIdCache = {
serverId: activeServerId,
folderId: folder,
filterVersion: musicLibraryFilterVersion,
ids,
};
return ids;
}
export async function filterSongsToActiveLibrary(songs: SubsonicSong[]): Promise<SubsonicSong[]> {
const allowed = await albumIdsInActiveLibraryScope();
if (!allowed || allowed.size === 0) return songs;
return songs.filter(s => s.albumId && allowed.has(s.albumId));
}
/** When scoped to one library, ask the server for more similar tracks — many will be filtered out client-side. */
function similarSongsRequestCount(desired: number): number {
const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
const f = activeServerId ? musicLibraryFilterByServer[activeServerId] : undefined;
if (f === undefined || f === 'all') return desired;
return Math.min(300, Math.max(desired, desired * 4));
}
export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> { export async function getRandomSongs(size = 50, genre?: string, timeout = 15000): Promise<SubsonicSong[]> {
const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() }; const params: Record<string, string | number> = { size, _t: Date.now(), ...libraryFilterParams() };
if (genre) params.genre = genre; if (genre) params.genre = genre;
@@ -590,15 +652,21 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a
return { artist, albums: album ?? [] }; return { artist, albums: album ?? [] };
} }
export async function getArtistInfo(id: string): Promise<SubsonicArtistInfo> { export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise<SubsonicArtistInfo> {
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count: 5 }); const count = options?.similarArtistCount ?? 5;
const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() });
return data.artistInfo2 ?? {}; return data.artistInfo2 ?? {};
} }
export async function getTopSongs(artist: string): Promise<SubsonicSong[]> { export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
try { try {
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: 5 }); const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState();
return data.topSongs?.song ?? []; const scoped = activeServerId && musicLibraryFilterByServer[activeServerId] && musicLibraryFilterByServer[activeServerId] !== 'all';
const topCount = scoped ? 20 : 5;
const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: topCount, ...libraryFilterParams() });
const raw = data.topSongs?.song ?? [];
const filtered = await filterSongsToActiveLibrary(raw);
return filtered.slice(0, 5);
} catch { } catch {
return []; return [];
} }
@@ -606,8 +674,26 @@ export async function getTopSongs(artist: string): Promise<SubsonicSong[]> {
export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> { export async function getSimilarSongs2(id: string, count = 50): Promise<SubsonicSong[]> {
try { try {
const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count }); const requestCount = similarSongsRequestCount(count);
return data.similarSongs2?.song ?? []; const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count: requestCount, ...libraryFilterParams() });
const raw = data.similarSongs2?.song ?? [];
const filtered = await filterSongsToActiveLibrary(raw);
return filtered.slice(0, count);
} catch {
return [];
}
}
/** Similar tracks for a song id (Subsonic `getSimilarSongs`) — Navidrome + AudioMuse Instant Mix. */
export async function getSimilarSongs(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 raw = data.similarSongs?.song;
if (!raw) return [];
const list = Array.isArray(raw) ? raw : [raw];
const filtered = await filterSongsToActiveLibrary(list);
return filtered.slice(0, count);
} catch { } catch {
return []; return [];
} }
+44 -7
View File
@@ -1,10 +1,10 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; 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 LastfmIcon from './LastfmIcon';
import StarRating from './StarRating'; import StarRating from './StarRating';
import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm'; import { lastfmLoveTrack, lastfmUnloveTrack } from '../api/lastfm';
import { usePlayerStore, Track, songToTrack } from '../store/playerStore'; 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 { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useDownloadModalStore } from '../store/downloadModalStore'; import { useDownloadModalStore } from '../store/downloadModalStore';
@@ -177,6 +177,7 @@ export default function ContextMenu() {
const { t } = useTranslation(); const { t } = useTranslation();
const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore(); const { contextMenu, closeContextMenu, playTrack, enqueue, queue, currentTrack, removeTrack, lastfmLovedCache, setLastfmLovedForSong, starredOverrides, setStarredOverride, openSongInfo, userRatingOverrides, setUserRatingOverride } = usePlayerStore();
const auth = useAuthStore(); const auth = useAuthStore();
const audiomuseNavidromeEnabled = !!(auth.activeServerId && auth.audiomuseNavidromeByServer[auth.activeServerId]);
const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder); const requestDownloadFolder = useDownloadModalStore(s => s.requestFolder);
const navigate = useNavigate(); const navigate = useNavigate();
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -235,12 +236,15 @@ export default function ContextMenu() {
// same "Top 5" in the same order every time. // same "Top 5" in the same order every time.
try { try {
const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]); const [similar, top] = await Promise.all([getSimilarSongs2(artistId), getTopSongs(artistName)]);
const radioTracks = shuffleArray( // Keep artist top songs and similar-by-artist in two blocks (each shuffled), not one blended pile —
[...top, ...similar] // otherwise this feels the same as Instant Mix (track-based similar only).
.map(songToTrack) const topTracks = shuffleArray(
.filter(t => t.id !== seedTrack.id) top.map(songToTrack).filter(t => t.id !== seedTrack.id).map(t => ({ ...t, radioAdded: true as const }))
.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); if (radioTracks.length > 0) usePlayerStore.getState().enqueueRadio(radioTracks, artistId);
} catch (e) { } catch (e) {
console.error('Failed to load radio queue', 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 downloadAlbum = async (albumName: string, albumId: string) => {
const folder = auth.downloadFolder || await requestDownloadFolder(); const folder = auth.downloadFolder || await requestDownloadFolder();
if (!folder) return; 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))}> <div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')} <Radio size={14} /> {t('contextMenu.startRadio')}
</div> </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(() => { <div className="context-menu-item" onClick={() => handleAction(() => {
const starred = isStarred(song.id, song.starred); const starred = isStarred(song.id, song.starred);
setStarredOverride(song.id, !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))}> <div className="context-menu-item" onClick={() => handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}>
<Radio size={14} /> {t('contextMenu.startRadio')} <Radio size={14} /> {t('contextMenu.startRadio')}
</div> </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-divider" />
<div className="context-menu-rating-row" onClick={e => e.stopPropagation()}> <div className="context-menu-rating-row" onClick={e => e.stopPropagation()}>
<StarRating <StarRating
+4
View File
@@ -94,6 +94,7 @@ export const deTranslation = {
addToQueue: 'Zur Warteschlange hinzufügen', addToQueue: 'Zur Warteschlange hinzufügen',
enqueueAlbum: 'Ganzes Album einreihen', enqueueAlbum: 'Ganzes Album einreihen',
startRadio: 'Radio starten', startRadio: 'Radio starten',
instantMix: 'Instant Mix',
lfmLove: 'Auf Last.fm liken', lfmLove: 'Auf Last.fm liken',
lfmUnlove: 'Last.fm-Like entfernen', lfmUnlove: 'Last.fm-Like entfernen',
favorite: 'Favorisieren', favorite: 'Favorisieren',
@@ -396,6 +397,9 @@ export const deTranslation = {
testBtn: 'Verbindung testen', testBtn: 'Verbindung testen',
testingBtn: 'Teste…', testingBtn: 'Teste…',
serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic', serverCompatible: 'Kompatibel mit: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Aktivieren, wenn dieser Server das AudioMuse-AI-Navidrome-Plugin nutzt. Schaltet Instant Mix pro Titel frei und nutzt ähnliche Künstler vom Server statt Last.fm auf Künstlerseiten.',
connected: 'Verbunden', connected: 'Verbunden',
failed: 'Fehlgeschlagen', failed: 'Fehlgeschlagen',
eqTitle: 'Equalizer', eqTitle: 'Equalizer',
+4
View File
@@ -95,6 +95,7 @@ export const enTranslation = {
addToQueue: 'Add to Queue', addToQueue: 'Add to Queue',
enqueueAlbum: 'Enqueue Album', enqueueAlbum: 'Enqueue Album',
startRadio: 'Start Radio', startRadio: 'Start Radio',
instantMix: 'Instant Mix',
lfmLove: 'Love on Last.fm', lfmLove: 'Love on Last.fm',
lfmUnlove: 'Unlove on Last.fm', lfmUnlove: 'Unlove on Last.fm',
favorite: 'Favorite', favorite: 'Favorite',
@@ -397,6 +398,9 @@ export const enTranslation = {
testBtn: 'Test Connection', testBtn: 'Test Connection',
testingBtn: 'Testing…', testingBtn: 'Testing…',
serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic', serverCompatible: 'Compatible with: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Turn on if this server has the AudioMuse-AI Navidrome plugin configured. Enables Instant Mix from tracks and uses server-side similar artists instead of Last.fm on artist pages.',
connected: 'Connected', connected: 'Connected',
failed: 'Failed', failed: 'Failed',
eqTitle: 'Equalizer', eqTitle: 'Equalizer',
+4
View File
@@ -94,6 +94,7 @@ export const frTranslation = {
addToQueue: 'Ajouter à la file', addToQueue: 'Ajouter à la file',
enqueueAlbum: 'Mettre l\'album en file', enqueueAlbum: 'Mettre l\'album en file',
startRadio: 'Démarrer la radio', startRadio: 'Démarrer la radio',
instantMix: 'Mix instantané',
lfmLove: 'Aimer sur Last.fm', lfmLove: 'Aimer sur Last.fm',
lfmUnlove: 'Ne plus aimer sur Last.fm', lfmUnlove: 'Ne plus aimer sur Last.fm',
favorite: 'Favori', favorite: 'Favori',
@@ -396,6 +397,9 @@ export const frTranslation = {
testBtn: 'Tester la connexion', testBtn: 'Tester la connexion',
testingBtn: 'Test en cours…', testingBtn: 'Test en cours…',
serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic', serverCompatible: 'Compatible avec : Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Activez si ce serveur utilise le plugin Navidrome AudioMuse-AI. Active le mix instantané depuis un morceau et affiche les artistes similaires côté serveur au lieu de Last.fm sur les pages artiste.',
connected: 'Connecté', connected: 'Connecté',
failed: 'Échec', failed: 'Échec',
eqTitle: 'Égaliseur', eqTitle: 'Égaliseur',
+4
View File
@@ -94,6 +94,7 @@ export const nbTranslation = {
addToQueue: 'Legg til i kø', addToQueue: 'Legg til i kø',
enqueueAlbum: 'Legg albumet i kø', enqueueAlbum: 'Legg albumet i kø',
startRadio: 'Start radio', startRadio: 'Start radio',
instantMix: 'Instant Mix',
lfmLove: 'Lik på Last.fm', lfmLove: 'Lik på Last.fm',
lfmUnlove: 'Fjern fra likte på Last.fm', lfmUnlove: 'Fjern fra likte på Last.fm',
favorite: 'Favoritt', favorite: 'Favoritt',
@@ -396,6 +397,9 @@ export const nbTranslation = {
testBtn: 'Test tilkobling', testBtn: 'Test tilkobling',
testingBtn: 'Tester…', testingBtn: 'Tester…',
serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic', serverCompatible: 'Kompatibel med: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Slå på hvis denne serveren bruker AudioMuse-AI Navidrome-plugin. Aktiverer Instant Mix fra spor og henter lignende artister fra serveren i stedet for Last.fm på artistsider.',
connected: 'Tilkoblet', connected: 'Tilkoblet',
failed: 'Mislyktes', failed: 'Mislyktes',
eqTitle: 'Jevnstiller', eqTitle: 'Jevnstiller',
+4
View File
@@ -94,6 +94,7 @@ export const nlTranslation = {
addToQueue: 'Aan wachtrij toevoegen', addToQueue: 'Aan wachtrij toevoegen',
enqueueAlbum: 'Album in wachtrij', enqueueAlbum: 'Album in wachtrij',
startRadio: 'Radio starten', startRadio: 'Radio starten',
instantMix: 'Instant Mix',
lfmLove: 'Liken op Last.fm', lfmLove: 'Liken op Last.fm',
lfmUnlove: 'Niet meer liken op Last.fm', lfmUnlove: 'Niet meer liken op Last.fm',
favorite: 'Favoriet', favorite: 'Favoriet',
@@ -396,6 +397,9 @@ export const nlTranslation = {
testBtn: 'Verbinding testen', testBtn: 'Verbinding testen',
testingBtn: 'Testen…', testingBtn: 'Testen…',
serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic', serverCompatible: 'Compatibel met: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Zet aan als deze server de AudioMuse-AI Navidrome-plugin gebruikt. Schakelt Instant Mix per nummer in en toont vergelijkbare artiesten van de server i.p.v. Last.fm op artiestpaginas.',
connected: 'Verbonden', connected: 'Verbonden',
failed: 'Mislukt', failed: 'Mislukt',
eqTitle: 'Equalizer', eqTitle: 'Equalizer',
+4
View File
@@ -95,6 +95,7 @@ export const ruTranslation = {
addToQueue: 'В конец очереди', addToQueue: 'В конец очереди',
enqueueAlbum: 'Альбом в очередь', enqueueAlbum: 'Альбом в очередь',
startRadio: 'Радио по похожим', startRadio: 'Радио по похожим',
instantMix: 'Instant Mix',
lfmLove: 'Любимое на Last.fm', lfmLove: 'Любимое на Last.fm',
lfmUnlove: 'Убрать с Last.fm', lfmUnlove: 'Убрать с Last.fm',
favorite: 'В избранное', favorite: 'В избранное',
@@ -411,6 +412,9 @@ export const ruTranslation = {
testBtn: 'Проверить', testBtn: 'Проверить',
testingBtn: 'Проверка…', testingBtn: 'Проверка…',
serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic', serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AI (Navidrome)',
audiomuseDesc:
'Включите, если на этом сервере настроен плагин AudioMuse-AI для Navidrome. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.',
connected: 'Подключено', connected: 'Подключено',
failed: 'Ошибка', failed: 'Ошибка',
eqTitle: 'Эквалайзер', eqTitle: 'Эквалайзер',
+4
View File
@@ -94,6 +94,7 @@ export const zhTranslation = {
addToQueue: '添加到队列', addToQueue: '添加到队列',
enqueueAlbum: '专辑加入队列', enqueueAlbum: '专辑加入队列',
startRadio: '开始电台', startRadio: '开始电台',
instantMix: '即时混音',
lfmLove: '在 Last.fm 上标记喜欢', lfmLove: '在 Last.fm 上标记喜欢',
lfmUnlove: '取消 Last.fm 喜欢标记', lfmUnlove: '取消 Last.fm 喜欢标记',
favorite: '收藏', favorite: '收藏',
@@ -392,6 +393,9 @@ export const zhTranslation = {
testBtn: '测试连接', testBtn: '测试连接',
testingBtn: '正在测试…', testingBtn: '正在测试…',
serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic', serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic',
audiomuseTitle: 'AudioMuse-AINavidrome',
audiomuseDesc:
'若此服务器已配置 AudioMuse-AI Navidrome 插件请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。',
connected: '已连接', connected: '已连接',
failed: '失败', failed: '失败',
eqTitle: '均衡器', eqTitle: '均衡器',
+27 -13
View File
@@ -73,6 +73,9 @@ export default function ArtistDetail() {
const downloadArtist = useOfflineStore(s => s.downloadArtist); const downloadArtist = useOfflineStore(s => s.downloadArtist);
const bulkProgress = useOfflineJobStore(s => s.bulkProgress); const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
const activeServerId = useAuthStore(s => s.activeServerId) ?? ''; const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion); const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer); const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport); const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
@@ -95,12 +98,6 @@ export default function ArtistDetail() {
// Render the page immediately from local data // Render the page immediately from local data
setLoading(false); setLoading(false);
// Fetch artist info (may trigger slow external lookup on the server)
// and top songs in the background — do not block rendering
getArtistInfo(id).then(artistInfo => {
if (!cancelled) setInfo(artistInfo ?? null);
}).catch(() => {});
getTopSongs(artistData.artist.name).then(songsData => { getTopSongs(artistData.artist.name).then(songsData => {
if (!cancelled) setTopSongs(songsData ?? []); if (!cancelled) setTopSongs(songsData ?? []);
}).catch(() => {}); }).catch(() => {});
@@ -110,6 +107,15 @@ export default function ArtistDetail() {
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [id]); }, [id]);
useEffect(() => {
if (!id) return;
let cancelled = false;
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined }).then(artistInfo => {
if (!cancelled) setInfo(artistInfo ?? null);
}).catch(() => {});
return () => { cancelled = true; };
}, [id, audiomuseNavidromeEnabled]);
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0); if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
@@ -174,7 +180,7 @@ export default function ArtistDetail() {
}, [artist?.id, musicLibraryFilterVersion]); }, [artist?.id, musicLibraryFilterVersion]);
useEffect(() => { useEffect(() => {
if (!artist || !lastfmIsConfigured()) return; if (!artist || audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
setSimilarArtists([]); setSimilarArtists([]);
setSimilarLoading(true); setSimilarLoading(true);
lastfmGetSimilarArtists(artist.name).then(async names => { lastfmGetSimilarArtists(artist.name).then(async names => {
@@ -197,7 +203,7 @@ export default function ArtistDetail() {
setSimilarArtists(found); setSimilarArtists(found);
setSimilarLoading(false); setSimilarLoading(false);
}).catch(() => setSimilarLoading(false)); }).catch(() => setSimilarLoading(false));
}, [artist?.id, musicLibraryFilterVersion]); }, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled]);
const openLink = (url: string, key: string) => { const openLink = (url: string, key: string) => {
open(url); open(url);
@@ -331,6 +337,15 @@ export default function ArtistDetail() {
const coverId = artist.coverArt || artist.id; const coverId = artist.coverArt || artist.id;
const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`; const wikiUrl = `https://en.wikipedia.org/wiki/${encodeURIComponent(artist.name)}`;
const serverSimilarArtists: SubsonicArtist[] = (info?.similarArtist ?? []).map(sa => ({
id: sa.id,
name: sa.name,
albumCount: sa.albumCount,
}));
const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0;
const showLastfmSimilar = !audiomuseNavidromeEnabled && lastfmIsConfigured() && (similarLoading || similarArtists.length > 0);
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
return ( return (
<div className="content-body animate-fade-in"> <div className="content-body animate-fade-in">
<button <button
@@ -564,20 +579,19 @@ export default function ArtistDetail() {
</> </>
)} )}
{/* Similar Artists (Last.fm) */} {showSimilarSection && (
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
<> <>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}> <h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.similarArtists')} {t('artistDetail.similarArtists')}
</h2> </h2>
{similarLoading ? ( {showLastfmSimilar && similarLoading ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} /> <div className="spinner" style={{ width: 16, height: 16, borderTopColor: 'currentColor' }} />
{t('artistDetail.loading')} {t('artistDetail.loading')}
</div> </div>
) : ( ) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{similarArtists.map(a => ( {(showAudiomuseSimilar ? serverSimilarArtists : similarArtists).map(a => (
<button <button
key={a.id} key={a.id}
className="artist-ext-link" className="artist-ext-link"
@@ -592,7 +606,7 @@ export default function ArtistDetail() {
)} )}
{/* Albums */} {/* Albums */}
<h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || lastfmIsConfigured()) ? '2rem' : '0', marginBottom: '1rem' }}> <h2 className="section-title" style={{ marginTop: (info?.biography || topSongs.length > 0 || showSimilarSection || (lastfmIsConfigured() && !audiomuseNavidromeEnabled)) ? '2rem' : '0', marginBottom: '1rem' }}>
{t('artistDetail.albumsBy', { name: artist.name })} {t('artistDetail.albumsBy', { name: artist.name })}
</h2> </h2>
+8 -2
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react'; import { Music, Star, ExternalLink, MicVocal, Heart } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore'; import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore'; import { useLyricsStore } from '../store/lyricsStore';
import { import {
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar, buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
@@ -217,6 +218,9 @@ export default function NowPlaying() {
const activeTab = useLyricsStore(s => s.activeTab); const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible); const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue); const toggleQueue = usePlayerStore(s => s.toggleQueue);
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
);
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]); const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
@@ -231,8 +235,10 @@ export default function NowPlaying() {
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null); const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
useEffect(() => { useEffect(() => {
if (!currentTrack?.artistId) { setArtistInfo(null); return; } if (!currentTrack?.artistId) { setArtistInfo(null); return; }
getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null)); getArtistInfo(currentTrack.artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
}, [currentTrack?.artistId]); .then(setArtistInfo)
.catch(() => setArtistInfo(null));
}, [currentTrack?.artistId, audiomuseNavidromeEnabled]);
// Album tracks // Album tracks
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]); const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
+37 -1
View File
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import { import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen, Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown, Palette, Server, Plus, Trash2, Eye, EyeOff, Info, ExternalLink, Shuffle, X, Play, Type, Keyboard, ChevronDown,
GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn GripVertical, PanelLeft, RotateCcw, LayoutGrid, AppWindow, HardDrive, Upload, Download, Waves, Star, Clock, ZoomIn, Sparkles
} from 'lucide-react'; } from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup'; import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast'; import { showToast } from '../utils/toast';
@@ -1656,6 +1656,42 @@ export default function Settings() {
</button> </button>
</div> </div>
</div> </div>
<div
className="settings-toggle-row"
style={{ marginTop: '0.75rem', paddingTop: '0.75rem', borderTop: '1px solid color-mix(in srgb, var(--text-muted) 18%, transparent)' }}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '0.5rem', minWidth: 0 }}>
<Sparkles size={16} style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 2 }} />
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{t('settings.audiomuseTitle')}
<span
style={{
fontSize: 10,
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.04em',
padding: '2px 6px',
borderRadius: 4,
background: 'color-mix(in srgb, var(--color-warning, #f59e0b) 22%, transparent)',
color: 'var(--text-primary)',
}}
>
{t('settings.hotCacheAlphaBadge')}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>{t('settings.audiomuseDesc')}</div>
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
<input
type="checkbox"
checked={!!auth.audiomuseNavidromeByServer[srv.id]}
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
/>
<span className="toggle-track" />
</label>
</div>
</div> </div>
); );
})} })}
+20
View File
@@ -104,6 +104,13 @@ interface AuthState {
entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>; entityRatingSupportByServer: Record<string, EntityRatingSupportLevel>;
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void; setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
/**
* Per server: Navidrome has the AudioMuse-AI plugin use `getSimilarSongs` (Instant Mix) and
* `getArtistInfo2` similar artists instead of Last.fm for discovery on this server.
*/
audiomuseNavidromeByServer: Record<string, boolean>;
setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void;
// Status // Status
isLoggedIn: boolean; isLoggedIn: boolean;
isConnecting: boolean; isConnecting: boolean;
@@ -250,6 +257,7 @@ export const useAuthStore = create<AuthState>()(
musicLibraryFilterByServer: {}, musicLibraryFilterByServer: {},
musicLibraryFilterVersion: 0, musicLibraryFilterVersion: 0,
entityRatingSupportByServer: {}, entityRatingSupportByServer: {},
audiomuseNavidromeByServer: {},
isLoggedIn: false, isLoggedIn: false,
isConnecting: false, isConnecting: false,
connectionError: null, connectionError: null,
@@ -272,11 +280,13 @@ export const useAuthStore = create<AuthState>()(
const newServers = s.servers.filter(srv => srv.id !== id); const newServers = s.servers.filter(srv => srv.id !== id);
const switchedAway = s.activeServerId === id; const switchedAway = s.activeServerId === id;
const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer; const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer;
const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
return { return {
servers: newServers, servers: newServers,
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId, activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
isLoggedIn: switchedAway ? false : s.isLoggedIn, isLoggedIn: switchedAway ? false : s.isLoggedIn,
entityRatingSupportByServer: entityRatingRest, entityRatingSupportByServer: entityRatingRest,
audiomuseNavidromeByServer: audiomuseRest,
}; };
}); });
}, },
@@ -403,6 +413,16 @@ export const useAuthStore = create<AuthState>()(
entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level }, entityRatingSupportByServer: { ...s.entityRatingSupportByServer, [serverId]: level },
})), })),
setAudiomuseNavidromeEnabled: (serverId, enabled) =>
set(s => ({
audiomuseNavidromeByServer: enabled
? { ...s.audiomuseNavidromeByServer, [serverId]: true }
: (() => {
const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer;
return rest;
})(),
})),
logout: () => set({ isLoggedIn: false, musicFolders: [] }), logout: () => set({ isLoggedIn: false, musicFolders: [] }),
getBaseUrl: () => { getBaseUrl: () => {