From 36f3d42dbe73d5d8c425aba792dbf6d8527ca3d6 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 10 Apr 2026 12:07:32 +0300 Subject: [PATCH] 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. --- .gitignore | 3 ++ src/api/subsonic.ts | 98 +++++++++++++++++++++++++++++++--- src/components/ContextMenu.tsx | 51 +++++++++++++++--- src/locales/de.ts | 4 ++ src/locales/en.ts | 4 ++ src/locales/fr.ts | 4 ++ src/locales/nb.ts | 4 ++ src/locales/nl.ts | 4 ++ src/locales/ru.ts | 4 ++ src/locales/zh.ts | 4 ++ src/pages/ArtistDetail.tsx | 40 +++++++++----- src/pages/NowPlaying.tsx | 10 +++- src/pages/Settings.tsx | 38 ++++++++++++- src/store/authStore.ts | 20 +++++++ 14 files changed, 259 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index afce1c1b..7ad62314 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,6 @@ memory/ # Local scratchpad / notes (not committed) tmp/ + +# Third-party clones for local research (not committed) +research/ diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index feaf025e..7bbe2e2e 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -309,6 +309,68 @@ export async function getAlbumList( 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; +} | null = null; + +async function albumIdsInActiveLibraryScope(): Promise | 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(); + 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 { + 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 { const params: Record = { size, _t: Date.now(), ...libraryFilterParams() }; if (genre) params.genre = genre; @@ -590,15 +652,21 @@ export async function getArtist(id: string): Promise<{ artist: SubsonicArtist; a return { artist, albums: album ?? [] }; } -export async function getArtistInfo(id: string): Promise { - const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count: 5 }); +export async function getArtistInfo(id: string, options?: { similarArtistCount?: number }): Promise { + const count = options?.similarArtistCount ?? 5; + const data = await api<{ artistInfo2: SubsonicArtistInfo }>('getArtistInfo2.view', { id, count, ...libraryFilterParams() }); return data.artistInfo2 ?? {}; } export async function getTopSongs(artist: string): Promise { try { - const data = await api<{ topSongs: { song: SubsonicSong[] } }>('getTopSongs.view', { artist, count: 5 }); - return data.topSongs?.song ?? []; + const { activeServerId, musicLibraryFilterByServer } = useAuthStore.getState(); + 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 { return []; } @@ -606,8 +674,26 @@ export async function getTopSongs(artist: string): Promise { export async function getSimilarSongs2(id: string, count = 50): Promise { try { - const data = await api<{ similarSongs2: { song: SubsonicSong[] } }>('getSimilarSongs2.view', { id, count }); - return data.similarSongs2?.song ?? []; + const requestCount = similarSongsRequestCount(count); + 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 { + 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 { return []; } diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index b1e4066f..e180edaf 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -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(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() {
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> {t('contextMenu.startRadio')}
+ {audiomuseNavidromeEnabled && ( +
handleAction(() => startInstantMix(song))}> + {t('contextMenu.instantMix')} +
+ )}
handleAction(() => { const starred = isStarred(song.id, song.starred); setStarredOverride(song.id, !starred); @@ -539,6 +571,11 @@ export default function ContextMenu() {
handleAction(() => startRadio(song.artistId ?? song.artist, song.artist, song))}> {t('contextMenu.startRadio')}
+ {audiomuseNavidromeEnabled && ( +
handleAction(() => startInstantMix(song))}> + {t('contextMenu.instantMix')} +
+ )}
e.stopPropagation()}> 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 +98,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 +107,15 @@ export default function ArtistDetail() { return () => { cancelled = true; }; }, [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(() => { if (!id) return; if (artist && artist.id === id) setArtistEntityRating(artist.userRating ?? 0); @@ -174,7 +180,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 +203,7 @@ export default function ArtistDetail() { setSimilarArtists(found); setSimilarLoading(false); }).catch(() => setSimilarLoading(false)); - }, [artist?.id, musicLibraryFilterVersion]); + }, [artist?.id, musicLibraryFilterVersion, audiomuseNavidromeEnabled]); const openLink = (url: string, key: string) => { open(url); @@ -331,6 +337,15 @@ 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 = !audiomuseNavidromeEnabled && lastfmIsConfigured() && (similarLoading || similarArtists.length > 0); + const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar; + return (
+
+
+ +
+
+ {t('settings.audiomuseTitle')} + + {t('settings.hotCacheAlphaBadge')} + +
+
{t('settings.audiomuseDesc')}
+
+
+ +
); })} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 7834660f..8c4aef58 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -104,6 +104,13 @@ interface AuthState { entityRatingSupportByServer: Record; 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; + setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void; + // Status isLoggedIn: boolean; isConnecting: boolean; @@ -250,6 +257,7 @@ export const useAuthStore = create()( musicLibraryFilterByServer: {}, musicLibraryFilterVersion: 0, entityRatingSupportByServer: {}, + audiomuseNavidromeByServer: {}, isLoggedIn: false, isConnecting: false, connectionError: null, @@ -272,11 +280,13 @@ export const useAuthStore = create()( const newServers = s.servers.filter(srv => srv.id !== id); const switchedAway = s.activeServerId === id; const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer; + const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; return { servers: newServers, activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId, isLoggedIn: switchedAway ? false : s.isLoggedIn, entityRatingSupportByServer: entityRatingRest, + audiomuseNavidromeByServer: audiomuseRest, }; }); }, @@ -403,6 +413,16 @@ export const useAuthStore = create()( 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: [] }), getBaseUrl: () => {