From 36f3d42dbe73d5d8c425aba792dbf6d8527ca3d6 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 10 Apr 2026 12:07:32 +0300 Subject: [PATCH 1/2] 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: () => { From 4de577eede80ebc07b8654d05d5cdf5d676b8013 Mon Sep 17 00:00:00 2001 From: Maxim Isaev Date: Fri, 10 Apr 2026 13:02:46 +0300 Subject: [PATCH 2/2] feat(navidrome): Instant Mix probe, ping identity, and AudioMuse UX Parse type and serverVersion from Subsonic ping; persist per-server identity and run a background Instant Mix probe (random songs + getSimilarSongs) for Navidrome 0.60+. Hide the AudioMuse server toggle when the probe returns no similar tracks; clear prefs when the server is no longer eligible. Artist pages fall back to Last.fm similar artists when AudioMuse is enabled but the server returns none. Instant Mix failures set a per-server issue flag with a settings warning and toast. Settings description links the official plugin repo via i18n Trans. --- src/api/subsonic.ts | 113 +++++++++++++++++++++- src/components/ContextMenu.tsx | 5 + src/hooks/useConnectionStatus.ts | 18 +++- src/locales/de.ts | 5 +- src/locales/en.ts | 5 +- src/locales/fr.ts | 5 +- src/locales/nb.ts | 5 +- src/locales/nl.ts | 5 +- src/locales/ru.ts | 5 +- src/locales/zh.ts | 5 +- src/pages/ArtistDetail.tsx | 67 +++++++++++++- src/pages/Login.tsx | 23 ++++- src/pages/Settings.tsx | 139 +++++++++++++++++++--------- src/store/authStore.ts | 78 +++++++++++++++- src/utils/subsonicServerIdentity.ts | 53 +++++++++++ 15 files changed, 463 insertions(+), 68 deletions(-) create mode 100644 src/utils/subsonicServerIdentity.ts diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 7bbe2e2e..f9286c5d 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -3,6 +3,11 @@ import md5 from 'md5'; import { invoke } from '@tauri-apps/api/core'; import { useAuthStore } from '../store/authStore'; import { version } from '../../package.json'; +import { + isNavidromeAudiomuseSoftwareEligible, + type InstantMixProbeResult, + type SubsonicServerIdentity, +} from '../utils/subsonicServerIdentity'; // ─── Secure random salt ──────────────────────────────────────── function secureRandomSalt(): string { @@ -265,8 +270,14 @@ export async function ping(): Promise { } } +export type PingWithCredentialsResult = SubsonicServerIdentity & { ok: boolean }; + /** Test a connection with explicit credentials — does NOT depend on store state. */ -export async function pingWithCredentials(serverUrl: string, username: string, password: string): Promise { +export async function pingWithCredentials( + serverUrl: string, + username: string, + password: string, +): Promise { try { const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`; const salt = secureRandomSalt(); @@ -277,12 +288,108 @@ export async function pingWithCredentials(serverUrl: string, username: string, p timeout: 15000, }); const data = resp.data?.['subsonic-response']; - return data?.status === 'ok'; + const ok = data?.status === 'ok'; + return { + ok, + type: typeof data?.type === 'string' ? data.type : undefined, + serverVersion: typeof data?.serverVersion === 'string' ? data.serverVersion : undefined, + openSubsonic: data?.openSubsonic === true, + }; } catch { - return false; + return { ok: false }; } } +function restBaseFromUrl(serverUrl: string): string { + const base = serverUrl.startsWith('http') ? serverUrl.replace(/\/$/, '') : `http://${serverUrl.replace(/\/$/, '')}`; + return `${base}/rest`; +} + +async function apiWithCredentials( + serverUrl: string, + username: string, + password: string, + endpoint: string, + extra: Record = {}, + timeout = 15000, +): Promise { + const params = { ...getAuthParams(username, password), ...extra }; + const resp = await axios.get(`${restBaseFromUrl(serverUrl)}/${endpoint}`, { + params, + paramsSerializer: { indexes: null }, + timeout, + }); + const data = resp.data?.['subsonic-response']; + if (!data) throw new Error('Invalid response from server (possibly not a Subsonic server)'); + if (data.status !== 'ok') throw new Error(data.error?.message ?? 'Subsonic API error'); + return data as T; +} + +const INSTANT_MIX_PROBE_RANDOM_SIZE = 8; +const INSTANT_MIX_PROBE_SIMILAR_COUNT = 12; +const INSTANT_MIX_PROBE_MAX_TRACKS = 4; + +/** + * Probes whether `getSimilarSongs` returns any tracks (Instant Mix / Navidrome agent chain). + * Does not pass `musicFolderId` — probes the whole library as seen by the account. + * Note: if `ND_AGENTS` includes Last.fm, a positive result does not prove AudioMuse alone. + */ +export async function probeInstantMixWithCredentials( + serverUrl: string, + username: string, + password: string, +): Promise { + try { + const data = await apiWithCredentials<{ randomSongs: { song: SubsonicSong | SubsonicSong[] } }>( + serverUrl, + username, + password, + 'getRandomSongs.view', + { size: INSTANT_MIX_PROBE_RANDOM_SIZE, _t: Date.now() }, + 12000, + ); + const raw = data.randomSongs?.song; + const songs: SubsonicSong[] = !raw ? [] : Array.isArray(raw) ? raw : [raw]; + if (songs.length === 0) return 'skipped'; + + let anyError = false; + for (const song of songs.slice(0, INSTANT_MIX_PROBE_MAX_TRACKS)) { + try { + const simData = await apiWithCredentials<{ similarSongs: { song: SubsonicSong | SubsonicSong[] } }>( + serverUrl, + username, + password, + 'getSimilarSongs.view', + { id: song.id, count: INSTANT_MIX_PROBE_SIMILAR_COUNT }, + 12000, + ); + const sRaw = simData.similarSongs?.song; + const list: SubsonicSong[] = !sRaw ? [] : Array.isArray(sRaw) ? sRaw : [sRaw]; + if (list.some(s => s.id !== song.id)) return 'ok'; + } catch { + anyError = true; + } + } + return anyError ? 'error' : 'empty'; + } catch { + return 'error'; + } +} + +/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */ +export function scheduleInstantMixProbeForServer( + serverId: string, + serverUrl: string, + username: string, + password: string, + identity: SubsonicServerIdentity, +): void { + if (!isNavidromeAudiomuseSoftwareEligible(identity)) return; + void probeInstantMixWithCredentials(serverUrl, username, password).then(result => + useAuthStore.getState().setInstantMixProbe(serverId, result), + ); +} + export async function getRandomAlbums(size = 6): Promise { const data = await api<{ albumList2: { album: SubsonicAlbum[] } }>('getAlbumList2.view', { type: 'random', diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx index e180edaf..1209d933 100644 --- a/src/components/ContextMenu.tsx +++ b/src/components/ContextMenu.tsx @@ -14,6 +14,7 @@ import { join } from '@tauri-apps/api/path'; import { invoke } from '@tauri-apps/api/core'; import { useZipDownloadStore } from '../store/zipDownloadStore'; import { useTranslation } from 'react-i18next'; +import { showToast } from '../utils/toast'; function sanitizeFilename(name: string): string { return name @@ -317,8 +318,10 @@ export default function ContextMenu() { } else { playTrack(song, [song]); } + const serverId = useAuthStore.getState().activeServerId; try { const similar = await getSimilarSongs(song.id, 50); + if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, false); const shuffled = shuffleArray( similar .filter(s => s.id !== song.id) @@ -330,6 +333,8 @@ export default function ContextMenu() { } } catch (e) { console.error('Instant mix failed', e); + if (serverId) useAuthStore.getState().setAudiomuseNavidromeIssue(serverId, true); + showToast(t('contextMenu.instantMixFailed'), 5000, 'error'); } }; diff --git a/src/hooks/useConnectionStatus.ts b/src/hooks/useConnectionStatus.ts index 5a9eee9d..5553a99f 100644 --- a/src/hooks/useConnectionStatus.ts +++ b/src/hooks/useConnectionStatus.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import { useAuthStore } from '../store/authStore'; -import { pingWithCredentials } from '../api/subsonic'; +import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../api/subsonic'; export type ConnectionStatus = 'connected' | 'disconnected' | 'checking'; @@ -37,8 +37,20 @@ export function useConnectionStatus() { return; } - const ok = await pingWithCredentials(server.url, server.username, server.password); - setStatus(ok ? 'connected' : 'disconnected'); + const ping = await pingWithCredentials(server.url, server.username, server.password); + if (ping.ok) { + const sid = useAuthStore.getState().activeServerId; + if (sid) { + const identity = { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + }; + useAuthStore.getState().setSubsonicServerIdentity(sid, identity); + scheduleInstantMixProbeForServer(sid, server.url, server.username, server.password, identity); + } + } + setStatus(ping.ok ? 'connected' : 'disconnected'); }, []); const retry = useCallback(async () => { diff --git a/src/locales/de.ts b/src/locales/de.ts index d3b4135e..6c4c96ba 100644 --- a/src/locales/de.ts +++ b/src/locales/de.ts @@ -95,6 +95,7 @@ export const deTranslation = { enqueueAlbum: 'Ganzes Album einreihen', startRadio: 'Radio starten', instantMix: 'Instant Mix', + instantMixFailed: 'Instant Mix konnte nicht erstellt werden — Server- oder Pluginfehler.', lfmLove: 'Auf Last.fm liken', lfmUnlove: 'Last.fm-Like entfernen', favorite: 'Favorisieren', @@ -399,7 +400,9 @@ export const deTranslation = { 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.', + '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.', + audiomuseIssueHint: + 'Instant Mix ist kürzlich fehlgeschlagen — Navidrome-Plugin und AudioMuse-API prüfen. Ohne Server-Treffer werden ähnliche Künstler über Last.fm geladen.', connected: 'Verbunden', failed: 'Fehlgeschlagen', eqTitle: 'Equalizer', diff --git a/src/locales/en.ts b/src/locales/en.ts index 5a89f688..784375df 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -96,6 +96,7 @@ export const enTranslation = { enqueueAlbum: 'Enqueue Album', startRadio: 'Start Radio', instantMix: 'Instant Mix', + instantMixFailed: 'Could not build Instant Mix — server or plugin error.', lfmLove: 'Love on Last.fm', lfmUnlove: 'Unlove on Last.fm', favorite: 'Favorite', @@ -400,7 +401,9 @@ export const enTranslation = { 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.', + '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.', + audiomuseIssueHint: + 'Instant Mix failed recently — check the Navidrome plugin and AudioMuse API. Similar artists fall back to Last.fm when the server returns none.', connected: 'Connected', failed: 'Failed', eqTitle: 'Equalizer', diff --git a/src/locales/fr.ts b/src/locales/fr.ts index 71117955..15462e1b 100644 --- a/src/locales/fr.ts +++ b/src/locales/fr.ts @@ -95,6 +95,7 @@ export const frTranslation = { enqueueAlbum: 'Mettre l\'album en file', startRadio: 'Démarrer la radio', instantMix: 'Mix instantané', + instantMixFailed: 'Impossible de créer le mix instantané — erreur serveur ou plugin.', lfmLove: 'Aimer sur Last.fm', lfmUnlove: 'Ne plus aimer sur Last.fm', favorite: 'Favori', @@ -399,7 +400,9 @@ export const frTranslation = { 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.', + '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.', + audiomuseIssueHint: + 'Le mix instantané a échoué récemment — vérifiez le plugin Navidrome et l’API AudioMuse. Les artistes similaires utilisent Last.fm si le serveur ne renvoie rien.', connected: 'Connecté', failed: 'Échec', eqTitle: 'Égaliseur', diff --git a/src/locales/nb.ts b/src/locales/nb.ts index f45124cc..8ac5287e 100644 --- a/src/locales/nb.ts +++ b/src/locales/nb.ts @@ -95,6 +95,7 @@ export const nbTranslation = { enqueueAlbum: 'Legg albumet i kø', startRadio: 'Start radio', instantMix: 'Instant Mix', + instantMixFailed: 'Kunne ikke lage Instant Mix — server- eller pluginfeil.', lfmLove: 'Lik på Last.fm', lfmUnlove: 'Fjern fra likte på Last.fm', favorite: 'Favoritt', @@ -399,7 +400,9 @@ export const nbTranslation = { 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.', + '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.', + audiomuseIssueHint: + 'Instant Mix feilet nylig — sjekk Navidrome-plugin og AudioMuse API. Lignende artister hentes fra Last.fm hvis serveren ikke returnerer noe.', connected: 'Tilkoblet', failed: 'Mislyktes', eqTitle: 'Jevnstiller', diff --git a/src/locales/nl.ts b/src/locales/nl.ts index 71a5fe45..48377097 100644 --- a/src/locales/nl.ts +++ b/src/locales/nl.ts @@ -95,6 +95,7 @@ export const nlTranslation = { enqueueAlbum: 'Album in wachtrij', startRadio: 'Radio starten', instantMix: 'Instant Mix', + instantMixFailed: 'Instant Mix mislukt — server- of pluginfout.', lfmLove: 'Liken op Last.fm', lfmUnlove: 'Niet meer liken op Last.fm', favorite: 'Favoriet', @@ -399,7 +400,9 @@ export const nlTranslation = { 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 artiestpagina’s.', + '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 artiestpagina’s.', + audiomuseIssueHint: + 'Instant Mix is onlangs mislukt — controleer de Navidrome-plugin en AudioMuse API. Zonder serverresultaten worden vergelijkbare artiesten via Last.fm geladen.', connected: 'Verbonden', failed: 'Mislukt', eqTitle: 'Equalizer', diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 90289837..e8882099 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -96,6 +96,7 @@ export const ruTranslation = { enqueueAlbum: 'Альбом в очередь', startRadio: 'Радио по похожим', instantMix: 'Instant Mix', + instantMixFailed: 'Не удалось собрать Instant Mix — ошибка сервера или плагина.', lfmLove: 'Любимое на Last.fm', lfmUnlove: 'Убрать с Last.fm', favorite: 'В избранное', @@ -414,7 +415,9 @@ export const ruTranslation = { serverCompatible: 'Совместимость: Navidrome · Gonic · Airsonic · Subsonic', audiomuseTitle: 'AudioMuse-AI (Navidrome)', audiomuseDesc: - 'Включите, если на этом сервере настроен плагин AudioMuse-AI для Navidrome. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.', + 'Включите, если на этом сервере настроен плагин AudioMuse-AI для Navidrome. Появится Instant Mix для треков, а на странице исполнителя похожие будут браться с сервера вместо Last.fm.', + audiomuseIssueHint: + 'Недавно не удалось собрать Instant Mix — проверьте плагин Navidrome и API AudioMuse. Похожие исполнители подтянутся с Last.fm, если сервер ничего не вернёт.', connected: 'Подключено', failed: 'Ошибка', eqTitle: 'Эквалайзер', diff --git a/src/locales/zh.ts b/src/locales/zh.ts index f70d0b6f..f3d9c4a1 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -95,6 +95,7 @@ export const zhTranslation = { enqueueAlbum: '专辑加入队列', startRadio: '开始电台', instantMix: '即时混音', + instantMixFailed: '无法生成即时混音 — 服务器或插件出错。', lfmLove: '在 Last.fm 上标记喜欢', lfmUnlove: '取消 Last.fm 喜欢标记', favorite: '收藏', @@ -395,7 +396,9 @@ export const zhTranslation = { serverCompatible: '兼容:Navidrome · Gonic · Airsonic · Subsonic', audiomuseTitle: 'AudioMuse-AI(Navidrome)', audiomuseDesc: - '若此服务器已配置 AudioMuse-AI Navidrome 插件请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。', + '若此服务器已配置 AudioMuse-AI Navidrome 插件请开启。可从曲目启动即时混音,并在艺人页使用服务器返回的相似艺人,而非 Last.fm。', + audiomuseIssueHint: + '近期即时混音失败 — 请检查 Navidrome 插件与 AudioMuse API。若服务器无结果,将回退使用 Last.fm 的相似艺人。', connected: '已连接', failed: '失败', eqTitle: '均衡器', diff --git a/src/pages/ArtistDetail.tsx b/src/pages/ArtistDetail.tsx index 02210b72..292a850d 100644 --- a/src/pages/ArtistDetail.tsx +++ b/src/pages/ArtistDetail.tsx @@ -57,6 +57,7 @@ export default function ArtistDetail() { const [openedLink, setOpenedLink] = useState(null); const [similarArtists, setSimilarArtists] = useState([]); 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); @@ -110,9 +111,17 @@ export default function ArtistDetail() { useEffect(() => { if (!id) return; let cancelled = false; - getArtistInfo(id, { similarArtistCount: audiomuseNavidromeEnabled ? 24 : undefined }).then(artistInfo => { - if (!cancelled) setInfo(artistInfo ?? null); - }).catch(() => {}); + 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]); @@ -205,6 +214,51 @@ export default function ArtistDetail() { }).catch(() => setSimilarLoading(false)); }, [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([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); setOpenedLink(key); @@ -343,7 +397,10 @@ export default function ArtistDetail() { albumCount: sa.albumCount, })); const showAudiomuseSimilar = audiomuseNavidromeEnabled && serverSimilarArtists.length > 0; - const showLastfmSimilar = !audiomuseNavidromeEnabled && lastfmIsConfigured() && (similarLoading || similarArtists.length > 0); + const showLastfmSimilar = + lastfmIsConfigured() && + (!audiomuseNavidromeEnabled || serverSimilarArtists.length === 0) && + (similarLoading || similarArtists.length > 0); const showSimilarSection = showAudiomuseSimilar || showLastfmSimilar; return ( @@ -606,7 +663,7 @@ export default function ArtistDetail() { )} {/* Albums */} -

0 || showSimilarSection || (lastfmIsConfigured() && !audiomuseNavidromeEnabled)) ? '2rem' : '0', marginBottom: '1rem' }}> +

0 || showSimilarSection) ? '2rem' : '0', marginBottom: '1rem' }}> {t('artistDetail.albumsBy', { name: artist.name })}

diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index c4b6b2ff..e56cee2a 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -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> = { 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'); diff --git a/src/pages/Settings.tsx b/src/pages/Settings.tsx index de921a66..cf7b369e 100644 --- a/src/pages/Settings.tsx +++ b/src/pages/Settings.tsx @@ -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, Sparkles + 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', @@ -313,8 +316,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' })); } @@ -323,8 +335,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('/'); @@ -347,9 +366,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' })); @@ -1656,42 +1682,71 @@ export default function Settings() {
-
-
- -
-
- {t('settings.audiomuseTitle')} - - {t('settings.hotCacheAlphaBadge')} - + {showAudiomuseNavidromeServerSetting( + auth.subsonicServerIdentityByServer[srv.id], + auth.instantMixProbeByServer[srv.id], + ) && ( +
+
+ +
+
+ {t('settings.audiomuseTitle')} + + {t('settings.hotCacheAlphaBadge')} + + {!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && ( + + )} +
+
+ { + e.preventDefault(); + void openUrl(AUDIOMUSE_NV_PLUGIN_URL); + }} + style={{ color: 'var(--accent)', textDecoration: 'underline' }} + /> + ), + }} + /> +
-
{t('settings.audiomuseDesc')}
+
- -
+ )}
); })} diff --git a/src/store/authStore.ts b/src/store/authStore.ts index 8c4aef58..912dd47e 100644 --- a/src/store/authStore.ts +++ b/src/store/authStore.ts @@ -2,6 +2,11 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import { invoke } from '@tauri-apps/api/core'; import type { EntityRatingSupportLevel } from '../api/subsonic'; +import { + isNavidromeAudiomuseSoftwareEligible, + type InstantMixProbeResult, + type SubsonicServerIdentity, +} from '../utils/subsonicServerIdentity'; import { usePlayerStore } from './playerStore'; export interface ServerProfile { @@ -111,6 +116,20 @@ interface AuthState { audiomuseNavidromeByServer: Record; setAudiomuseNavidromeEnabled: (serverId: string, enabled: boolean) => void; + /** From `ping` — used to show the AudioMuse toggle only on Navidrome ≥ 0.60. */ + subsonicServerIdentityByServer: Record; + setSubsonicServerIdentity: (serverId: string, identity: SubsonicServerIdentity) => void; + + /** Instant Mix / similar path failed while this server had AudioMuse enabled (cleared on success or toggle off). */ + audiomuseNavidromeIssueByServer: Record; + setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void; + + /** + * `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection. + */ + instantMixProbeByServer: Record; + setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void; + // Status isLoggedIn: boolean; isConnecting: boolean; @@ -258,6 +277,9 @@ export const useAuthStore = create()( musicLibraryFilterVersion: 0, entityRatingSupportByServer: {}, audiomuseNavidromeByServer: {}, + subsonicServerIdentityByServer: {}, + audiomuseNavidromeIssueByServer: {}, + instantMixProbeByServer: {}, isLoggedIn: false, isConnecting: false, connectionError: null, @@ -281,12 +303,18 @@ export const useAuthStore = create()( const switchedAway = s.activeServerId === id; const { [id]: _r, ...entityRatingRest } = s.entityRatingSupportByServer; const { [id]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; + const { [id]: _idn, ...identityRest } = s.subsonicServerIdentityByServer; + const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer; + const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer; return { servers: newServers, activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId, isLoggedIn: switchedAway ? false : s.isLoggedIn, entityRatingSupportByServer: entityRatingRest, audiomuseNavidromeByServer: audiomuseRest, + subsonicServerIdentityByServer: identityRest, + audiomuseNavidromeIssueByServer: issueRest, + instantMixProbeByServer: probeRest, }; }); }, @@ -414,14 +442,58 @@ export const useAuthStore = create()( })), setAudiomuseNavidromeEnabled: (serverId, enabled) => - set(s => ({ - audiomuseNavidromeByServer: enabled + set(s => { + const audiomuseNavidromeByServer = enabled ? { ...s.audiomuseNavidromeByServer, [serverId]: true } : (() => { const { [serverId]: _removed, ...rest } = s.audiomuseNavidromeByServer; return rest; + })(); + const { [serverId]: _issueRm, ...issueRest } = s.audiomuseNavidromeIssueByServer; + return { audiomuseNavidromeByServer, audiomuseNavidromeIssueByServer: issueRest }; + }), + + setSubsonicServerIdentity: (serverId, identity) => + set(s => { + const subsonicServerIdentityByServer = { ...s.subsonicServerIdentityByServer, [serverId]: { ...identity } }; + if (!isNavidromeAudiomuseSoftwareEligible(identity)) { + const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; + const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer; + const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer; + return { + subsonicServerIdentityByServer, + audiomuseNavidromeByServer: audiomuseRest, + audiomuseNavidromeIssueByServer: issueRest, + instantMixProbeByServer: probeRest, + }; + } + return { subsonicServerIdentityByServer }; + }), + + setInstantMixProbe: (serverId, result) => + set(s => { + const instantMixProbeByServer = { ...s.instantMixProbeByServer, [serverId]: result }; + if (result === 'empty') { + const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer; + const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer; + return { + instantMixProbeByServer, + audiomuseNavidromeByServer: audiomuseRest, + audiomuseNavidromeIssueByServer: issueRest, + }; + } + return { instantMixProbeByServer }; + }), + + setAudiomuseNavidromeIssue: (serverId, hasIssue) => + set(s => + hasIssue + ? { audiomuseNavidromeIssueByServer: { ...s.audiomuseNavidromeIssueByServer, [serverId]: true } } + : (() => { + const { [serverId]: _rm, ...rest } = s.audiomuseNavidromeIssueByServer; + return { audiomuseNavidromeIssueByServer: rest }; })(), - })), + ), logout: () => set({ isLoggedIn: false, musicFolders: [] }), diff --git a/src/utils/subsonicServerIdentity.ts b/src/utils/subsonicServerIdentity.ts new file mode 100644 index 00000000..93ffe225 --- /dev/null +++ b/src/utils/subsonicServerIdentity.ts @@ -0,0 +1,53 @@ +/** Fields from Subsonic `ping` / any `subsonic-response` root (Navidrome sets type + serverVersion). */ +export type SubsonicServerIdentity = { + type?: string; + serverVersion?: string; + openSubsonic?: boolean; +}; + +/** Result of `getRandomSongs` + `getSimilarSongs` probe (Instant Mix / agent chain). */ +export type InstantMixProbeResult = 'ok' | 'empty' | 'error' | 'skipped'; + +const NAVIDROME_MIN_FOR_PLUGINS: [number, number, number] = [0, 60, 0]; + +function parseLeadingSemver(version: string | undefined): [number, number, number] | null { + if (!version) return null; + const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim()); + if (!m) return null; + return [Number(m[1]), Number(m[2]), Number(m[3])]; +} + +function semverGte(a: [number, number, number], b: [number, number, number]): boolean { + for (let i = 0; i < 3; i++) { + if (a[i] > b[i]) return true; + if (a[i] < b[i]) return false; + } + return true; +} + +/** + * Navidrome version from ping supports the plugin system (≥ 0.60). Unknown `type` stays permissive + * until the first successful ping with metadata. + */ +export function isNavidromeAudiomuseSoftwareEligible(identity: SubsonicServerIdentity | undefined): boolean { + if (!identity?.type?.trim()) return true; + const t = identity.type.trim().toLowerCase(); + if (t !== 'navidrome') return false; + const parsed = parseLeadingSemver(identity.serverVersion); + if (!parsed) return true; + return semverGte(parsed, NAVIDROME_MIN_FOR_PLUGINS); +} + +/** + * Whether to show the per-server AudioMuse (Navidrome plugin) toggle in Settings. + * Uses software eligibility from ping plus an optional Instant Mix probe: if the server returns no + * similar tracks for several random songs, the row stays hidden (typical when no plugin / no agents). + */ +export function showAudiomuseNavidromeServerSetting( + identity: SubsonicServerIdentity | undefined, + instantMixProbe: InstantMixProbeResult | undefined, +): boolean { + if (!isNavidromeAudiomuseSoftwareEligible(identity)) return false; + if (instantMixProbe === 'empty') return false; + return true; +}