Merge pull request #147: feat(discovery): Navidrome AudioMuse-AI integration

Resolves conflict in ContextMenu.tsx: keep useShallow import from main
alongside getSimilarSongs added by this PR.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Psychotoxical
2026-04-10 14:09:13 +02:00
17 changed files with 673 additions and 48 deletions
+84 -13
View File
@@ -57,6 +57,7 @@ export default function ArtistDetail() {
const [openedLink, setOpenedLink] = useState<string | null>(null);
const [similarArtists, setSimilarArtists] = useState<SubsonicArtist[]>([]);
const [similarLoading, setSimilarLoading] = useState(false);
const [artistInfoLoading, setArtistInfoLoading] = useState(false);
const [featuredLoading, setFeaturedLoading] = useState(false);
const [lightboxOpen, setLightboxOpen] = useState(false);
const [bioExpanded, setBioExpanded] = useState(false);
@@ -73,6 +74,9 @@ export default function ArtistDetail() {
const downloadArtist = useOfflineStore(s => s.downloadArtist);
const bulkProgress = useOfflineJobStore(s => s.bulkProgress);
const activeServerId = useAuthStore(s => s.activeServerId) ?? '';
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
);
const musicLibraryFilterVersion = useAuthStore(s => s.musicLibraryFilterVersion);
const entityRatingSupportByServer = useAuthStore(s => s.entityRatingSupportByServer);
const setEntityRatingSupport = useAuthStore(s => s.setEntityRatingSupport);
@@ -95,12 +99,6 @@ export default function ArtistDetail() {
// Render the page immediately from local data
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 => {
if (!cancelled) setTopSongs(songsData ?? []);
}).catch(() => {});
@@ -110,6 +108,23 @@ export default function ArtistDetail() {
return () => { cancelled = true; };
}, [id]);
useEffect(() => {
if (!id) return;
let cancelled = false;
setArtistInfoLoading(true);
getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
.then(artistInfo => {
if (!cancelled) setInfo(artistInfo ?? null);
})
.catch(() => {
if (!cancelled) setInfo(null);
})
.finally(() => {
if (!cancelled) setArtistInfoLoading(false);
});
return () => { cancelled = true; };
}, [id, audiomuseNavidromeEnabled]);
useEffect(() => {
if (!id) return;
if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0);
@@ -174,7 +189,7 @@ export default function ArtistDetail() {
}, [artist?.id, musicLibraryFilterVersion]);
useEffect(() => {
if (!artist || !lastfmIsConfigured()) return;
if (!artist || audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
setSimilarArtists([]);
setSimilarLoading(true);
lastfmGetSimilarArtists(artist.name).then(async names => {
@@ -197,7 +212,52 @@ export default function ArtistDetail() {
setSimilarArtists(found);
setSimilarLoading(false);
}).catch(() => setSimilarLoading(false));
}, [artist?.id, musicLibraryFilterVersion]);
}, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled]);
/** When AudioMuse is on but the server returns no similar artists, fall back to Last.fm (if configured). */
useEffect(() => {
if (!artist || !audiomuseNavidromeEnabled || !lastfmIsConfigured()) return;
if (artistInfoLoading) return;
if ((info?.similarArtist?.length ?? 0) > 0) return;
setSimilarArtists([]);
setSimilarLoading(true);
lastfmGetSimilarArtists(artist.name).then(async names => {
if (names.length === 0) { setSimilarLoading(false); return; }
const results = await Promise.all(
names.slice(0, 30).map(name =>
search(name, { artistCount: 3, albumCount: 0, songCount: 0 }).catch(() => ({ artists: [], albums: [], songs: [] }))
)
);
const seen = new Set<string>([artist.id]);
const found: SubsonicArtist[] = [];
for (let i = 0; i < results.length; i++) {
const targetName = names[i].toLowerCase();
const match = results[i].artists.find(a => a.name.toLowerCase() === targetName);
if (match && !seen.has(match.id)) {
seen.add(match.id);
found.push(match);
}
}
setSimilarArtists(found);
setSimilarLoading(false);
}).catch(() => setSimilarLoading(false));
}, [
artist?.id,
artist?.name,
musicLibraryFilterVersion,
audiomuseNavidromeEnabled,
artistInfoLoading,
info?.similarArtist?.length,
]);
useEffect(() => {
if (!audiomuseNavidromeEnabled) return;
if ((info?.similarArtist?.length ?? 0) > 0) {
setSimilarArtists([]);
setSimilarLoading(false);
}
}, [id, audiomuseNavidromeEnabled, info?.similarArtist?.length]);
const openLink = (url: string, key: string) => {
open(url);
@@ -331,6 +391,18 @@ export default function ArtistDetail() {
const coverId = artist.coverArt || artist.id;
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 =
lastfmIsConfigured() &&
(!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) &&
(similarLoading || similarArtists.length > 0);
const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar;
return (
<div className="content-body animate-fade-in">
<button
@@ -564,20 +636,19 @@ export default function ArtistDetail() {
</>
)}
{/* Similar Artists (Last.fm) */}
{lastfmIsConfigured() && (similarLoading || similarArtists.length > 0) && (
{showSimilarSection && (
<>
<h2 className="section-title" style={{ marginTop: '2rem', marginBottom: '1rem' }}>
{t('artistDetail.similarArtists')}
</h2>
{similarLoading ? (
{showLastfmSimilar && similarLoading ? (
<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' }} />
{t('artistDetail.loading')}
</div>
) : (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{similarArtists.map(a => (
{(showAudiomuseSimilar ? serverSimilarArtists : similarArtists).map(a => (
<button
key={a.id}
className="artist-ext-link"
@@ -592,7 +663,7 @@ export default function ArtistDetail() {
)}
{/* 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) ? '2rem' : '0', marginBottom: '1rem' }}>
{t('artistDetail.albumsBy', { name: artist.name })}
</h2>
+18 -5
View File
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Wifi, WifiOff, Eye, EyeOff, Server } from 'lucide-react';
import { useAuthStore } from '../store/authStore';
import { pingWithCredentials } from '../api/subsonic';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { useTranslation } from 'react-i18next';
const PsysonicLogo = () => (
@@ -36,16 +36,16 @@ export default function Login() {
// Test connection directly with entered credentials — don't touch the store yet.
// This avoids any race condition with Zustand's async store rehydration.
let ok = false;
let ping: Awaited<ReturnType<typeof pingWithCredentials>> = { ok: false };
try {
ok = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
ping = await pingWithCredentials(profile.url.trim(), profile.username.trim(), profile.password);
} catch {
ok = false;
ping = { ok: false };
}
setConnecting(false);
if (ok) {
if (ping.ok) {
// Connection succeeded — now persist to store
const existing = servers.find(s => s.url === profile.url.trim() && s.username === profile.username.trim());
let serverId: string;
@@ -63,6 +63,19 @@ export default function Login() {
password: profile.password,
});
}
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
useAuthStore.getState().setSubsonicServerIdentity(serverId, identity);
scheduleInstantMixProbeForServer(
serverId,
profile.url.trim(),
profile.username.trim(),
profile.password,
identity,
);
setActiveServer(serverId);
setLoggedIn(true);
setStatus('ok');
+8 -2
View File
@@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Music, Star, ExternalLink, MicVocal, Heart, Cast, Users, Radio, Clock, SkipForward } from 'lucide-react';
import { usePlayerStore } from '../store/playerStore';
import { useAuthStore } from '../store/authStore';
import { useLyricsStore } from '../store/lyricsStore';
import {
buildCoverArtUrl, coverArtCacheKey, getSong, star, unstar,
@@ -219,6 +220,9 @@ export default function NowPlaying() {
const activeTab = useLyricsStore(s => s.activeTab);
const isQueueVisible = usePlayerStore(s => s.isQueueVisible);
const toggleQueue = usePlayerStore(s => s.toggleQueue);
const audiomuseNavidromeEnabled = useAuthStore(
s => !!(s.activeServerId && s.audiomuseNavidromeByServer[s.activeServerId]),
);
const stableNavigate = useCallback((path: string) => navigate(path), [navigate]);
@@ -236,8 +240,10 @@ export default function NowPlaying() {
const [artistInfo, setArtistInfo] = useState<SubsonicArtistInfo | null>(null);
useEffect(() => {
if (!currentTrack?.artistId) { setArtistInfo(null); return; }
getArtistInfo(currentTrack.artistId).then(setArtistInfo).catch(() => setArtistInfo(null));
}, [currentTrack?.artistId]);
getArtistInfo(currentTrack.artistId, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined })
.then(setArtistInfo)
.catch(() => setArtistInfo(null));
}, [currentTrack?.artistId, audiomuseNavidromeEnabled]);
// Album tracks
const [albumTracks, setAlbumTracks] = useState<SubsonicSong[]>([]);
+100 -9
View File
@@ -5,7 +5,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import {
Wifi, WifiOff, Globe, Music2, Sliders, LogOut, CheckCircle2, FolderOpen,
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, AlertTriangle
} from 'lucide-react';
import { exportBackup, importBackup } from '../utils/backup';
import { showToast } from '../utils/toast';
@@ -29,14 +29,17 @@ import { useSidebarStore, DEFAULT_SIDEBAR_ITEMS, SidebarItemConfig } from '../st
import { useHomeStore, HomeSectionId } from '../store/homeStore';
import { useDragDrop, useDragSource } from '../contexts/DragDropContext';
import { ALL_NAV_ITEMS } from '../components/Sidebar';
import { pingWithCredentials } from '../api/subsonic';
import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { useTranslation } from 'react-i18next';
import { Trans, useTranslation } from 'react-i18next';
import Equalizer from '../components/Equalizer';
import StarRating from '../components/StarRating';
import { showAudiomuseNavidromeServerSetting } from '../utils/subsonicServerIdentity';
const AUDIOBOOK_GENRES_DISPLAY = ['Hörbuch', 'Hoerbuch', 'Hörspiel', 'Hoerspiel', 'Audiobook', 'Audio Book', 'Spoken Word', 'Spokenword', 'Podcast', 'Kapitel', 'Thriller', 'Krimi', 'Speech', 'Fantasy', 'Comedy', 'Literature'];
const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin';
const CONTRIBUTORS = [
{
github: 'jiezhuo',
@@ -318,8 +321,17 @@ export default function Settings() {
const testConnection = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
try {
const ok = await pingWithCredentials(server.url, server.username, server.password);
setConnStatus(s => ({ ...s, [server.id]: ok ? 'ok' : 'error' }));
const ping = await pingWithCredentials(server.url, server.username, server.password);
if (ping.ok) {
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
auth.setSubsonicServerIdentity(server.id, identity);
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
}
setConnStatus(s => ({ ...s, [server.id]: ping.ok ? 'ok' : 'error' }));
} catch {
setConnStatus(s => ({ ...s, [server.id]: 'error' }));
}
@@ -328,8 +340,15 @@ export default function Settings() {
const switchToServer = async (server: ServerProfile) => {
setConnStatus(s => ({ ...s, [server.id]: 'testing' }));
try {
const ok = await pingWithCredentials(server.url, server.username, server.password);
if (ok) {
const ping = await pingWithCredentials(server.url, server.username, server.password);
if (ping.ok) {
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
auth.setSubsonicServerIdentity(server.id, identity);
scheduleInstantMixProbeForServer(server.id, server.url, server.username, server.password, identity);
auth.setActiveServer(server.id);
auth.setLoggedIn(true);
navigate('/');
@@ -352,9 +371,16 @@ export default function Settings() {
const tempId = '_new';
setConnStatus(s => ({ ...s, [tempId]: 'testing' }));
try {
const ok = await pingWithCredentials(data.url, data.username, data.password);
if (ok) {
const ping = await pingWithCredentials(data.url, data.username, data.password);
if (ping.ok) {
const id = auth.addServer(data);
const identity = {
type: ping.type,
serverVersion: ping.serverVersion,
openSubsonic: ping.openSubsonic,
};
auth.setSubsonicServerIdentity(id, identity);
scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity);
auth.setActiveServer(id);
auth.setLoggedIn(true);
setConnStatus(s => ({ ...s, [id]: 'ok' }));
@@ -1642,6 +1668,71 @@ export default function Settings() {
</button>
</div>
</div>
{showAudiomuseNavidromeServerSetting(
auth.subsonicServerIdentityByServer[srv.id],
auth.instantMixProbeByServer[srv.id],
) && (
<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>
{!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && (
<AlertTriangle
size={16}
style={{ color: 'var(--color-warning, #f59e0b)', flexShrink: 0 }}
data-tooltip={t('settings.audiomuseIssueHint')}
aria-label={t('settings.audiomuseIssueHint')}
/>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
<Trans
i18nKey="settings.audiomuseDesc"
components={{
pluginLink: (
<a
href={AUDIOMUSE_NV_PLUGIN_URL}
onClick={e => {
e.preventDefault();
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
}}
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
/>
),
}}
/>
</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>
);
})}