diff --git a/CHANGELOG.md b/CHANGELOG.md index 71016277..9533a55b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### PsyLab — Connections tab and Navidrome admin role + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)** + +* New **Connections** tab: session/endpoint status, active-server capability readout (OpenSubsonic, AudioMuse detection, provider/strategy, detection trust, resolved call route, and AudioMuse mode), and queue-playback server when it differs from the active profile. +* Navidrome **admin vs standard user** badge via native login probe — useful when diagnosing plugin/settings visibility. + + + +### Servers — capability framework with AudioMuse sonic routing (Navidrome ≥ 0.62) + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)** + +* New declarative **server-capability framework** (`src/serverCapabilities/`): a catalog picks a feature strategy per server generation, runs only the needed probes, and routes API calls — replacing scattered version checks in the UI and call sites. +* Navidrome **0.62+**: detect the AudioMuse-AI plugin from `getOpenSubsonicExtensions` when `sonicSimilarity` is advertised — the first reliable signal. Settings shows an **auto-managed status indicator** (no manual toggle); older Navidrome keeps the manual toggle and the legacy `getSimilarSongs` Instant Mix probe. +* **Path routing**: Instant Mix and Lucky Mix prefer the OpenSubsonic `getSonicSimilarTracks` endpoint when the plugin is present, falling back to legacy `getSimilarSongs`. + + + ## Fixed ### Servers — complete border on the active server card @@ -198,6 +217,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### PsyLab — tab bar no longer collapses on the Logs tab + +**By [@cucadmuh](https://github.com/cucadmuh), PR [#1033](https://github.com/Psychotoxical/psysonic/pull/1033)** + +* The PsyLab tab row keeps its height when the Logs flex layout fills the modal — tabs were previously squashed to a thin strip. + + + ## [1.47.0] > **🙏 Thank you to our amazing Discord community.** This release would not have been possible without your tireless support, quality checks, bug reports and all-round collaboration. Every report, every repro and every bit of feedback shaped what shipped here — thank you. Come join us: [discord.gg/AMnDRErm4u](https://discord.gg/AMnDRErm4u) diff --git a/src/api/subsonic.scheduleProbe.test.ts b/src/api/subsonic.scheduleProbe.test.ts new file mode 100644 index 00000000..e4e0eed6 --- /dev/null +++ b/src/api/subsonic.scheduleProbe.test.ts @@ -0,0 +1,89 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./subsonicOpenSubsonic', () => ({ + fetchOpenSubsonicExtensionsWithCredentials: vi.fn(), +})); + +import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic'; +import { scheduleInstantMixProbeForServer } from './subsonic'; +import { useAuthStore } from '../store/authStore'; +import type { SubsonicServerIdentity } from '../utils/server/subsonicServerIdentity'; + +const fetchMock = vi.mocked(fetchOpenSubsonicExtensionsWithCredentials); +const SID = 'srv-probe'; +const id062: SubsonicServerIdentity = { type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }; + +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +function reset() { + useAuthStore.setState({ + subsonicServerIdentityByServer: {}, + audiomusePluginProbeByServer: {}, + instantMixProbeByServer: {}, + audiomuseNavidromeByServer: {}, + audiomuseNavidromeIssueByServer: {}, + } as never); +} + +describe('scheduleInstantMixProbeForServer (idempotency)', () => { + beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockResolvedValue(['sonicSimilarity']); + reset(); + }); + + it('probes once, caches the result, then skips on the next poll', async () => { + scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062); + expect(fetchMock).toHaveBeenCalledTimes(1); + await flush(); + expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present'); + + scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('re-probes when forced (user-initiated refresh)', async () => { + scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062); + await flush(); + scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062, true); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('re-probes after a prior error', async () => { + fetchMock.mockResolvedValueOnce(null); + scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062); + await flush(); + expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('error'); + scheduleInstantMixProbeForServer(SID, 'url', 'u', 'p', id062); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); + +describe('setSubsonicServerIdentity (version-change cache invalidation)', () => { + beforeEach(reset); + + it('clears cached probes on a version change but keeps the opt-in', () => { + useAuthStore.setState({ + subsonicServerIdentityByServer: { [SID]: id062 }, + audiomusePluginProbeByServer: { [SID]: 'present' }, + audiomuseNavidromeByServer: { [SID]: true }, + } as never); + + useAuthStore.getState().setSubsonicServerIdentity(SID, { type: 'navidrome', serverVersion: '0.63.0', openSubsonic: true }); + + const s = useAuthStore.getState(); + expect(s.audiomusePluginProbeByServer[SID]).toBeUndefined(); + expect(s.audiomuseNavidromeByServer[SID]).toBe(true); + }); + + it('keeps cached probes when the identity is unchanged', () => { + useAuthStore.setState({ + subsonicServerIdentityByServer: { [SID]: id062 }, + audiomusePluginProbeByServer: { [SID]: 'present' }, + } as never); + + useAuthStore.getState().setSubsonicServerIdentity(SID, { ...id062 }); + + expect(useAuthStore.getState().audiomusePluginProbeByServer[SID]).toBe('present'); + }); +}); diff --git a/src/api/subsonic.ts b/src/api/subsonic.ts index 8d673f73..1229559e 100644 --- a/src/api/subsonic.ts +++ b/src/api/subsonic.ts @@ -2,10 +2,18 @@ import axios from 'axios'; import md5 from 'md5'; import { useAuthStore } from '../store/authStore'; import { - isNavidromeAudiomuseSoftwareEligible, type InstantMixProbeResult, type SubsonicServerIdentity, } from '../utils/server/subsonicServerIdentity'; +import { fetchOpenSubsonicExtensionsWithCredentials } from './subsonicOpenSubsonic'; +import { buildCapabilityContext } from '../serverCapabilities/context'; +import { + PROBE_LEGACY_INSTANT_MIX, + PROBE_OPENSUBSONIC_EXTENSIONS, + SERVER_CAPABILITY_CATALOG, + SONIC_SIMILARITY_EXTENSION, +} from '../serverCapabilities/catalog'; +import { neededProbeIds } from '../serverCapabilities/resolve'; import { SUBSONIC_CLIENT, api, @@ -104,16 +112,54 @@ export async function probeInstantMixWithCredentials( } } -/** After a successful ping, probe Instant Mix in the background (Navidrome ≥ 0.60 only). */ +/** + * After a successful ping, run the server-capability probes needed by the catalog + * (`serverCapabilities/`). Which probes run is decided by the strategies eligible + * for this server generation — not by inline version checks here. + * + * Currently: Navidrome ≥ 0.62 → `getOpenSubsonicExtensions` (sonicSimilarity); + * Navidrome 0.60–0.61 → legacy `getSimilarSongs` Instant Mix probe. + * + * Idempotent: a server's advertised capabilities are static within a session, so + * once a definitive result is cached the probe is skipped. This is called on every + * 120 s connection poll, so re-fetching each time would be wasteful and would flip + * the resolved status (and the routed endpoint) through a `probing` window. Pass + * `force` for user-initiated refreshes (add/edit/test server); a server version or + * type change clears the cache (see `setSubsonicServerIdentity`), forcing a re-probe. + */ export function scheduleInstantMixProbeForServer( serverId: string, serverUrl: string, username: string, password: string, identity: SubsonicServerIdentity, + force = false, ): void { - if (!isNavidromeAudiomuseSoftwareEligible(identity)) return; - void probeInstantMixWithCredentials(serverUrl, username, password).then(result => - useAuthStore.getState().setInstantMixProbe(serverId, result), - ); + const ctx = buildCapabilityContext(identity); + const probeIds = neededProbeIds(SERVER_CAPABILITY_CATALOG, ctx); + const store = useAuthStore.getState(); + + if (probeIds.has(PROBE_OPENSUBSONIC_EXTENSIONS)) { + const cached = store.audiomusePluginProbeByServer[serverId]; + // Re-probe only without a definitive cached result (or on force / prior error). + // `probing` means an in-flight fetch — skip to avoid a duplicate request. + if (force || cached === undefined || cached === 'error') { + store.setAudiomusePluginProbe(serverId, 'probing'); + void fetchOpenSubsonicExtensionsWithCredentials(serverUrl, username, password).then(extensions => { + const result = extensions === null + ? 'error' + : extensions.includes(SONIC_SIMILARITY_EXTENSION) ? 'present' : 'absent'; + useAuthStore.getState().setAudiomusePluginProbe(serverId, result); + }); + } + } + + if (probeIds.has(PROBE_LEGACY_INSTANT_MIX)) { + const cached = store.instantMixProbeByServer[serverId]; + if (force || cached === undefined || cached === 'error') { + void probeInstantMixWithCredentials(serverUrl, username, password).then(result => + useAuthStore.getState().setInstantMixProbe(serverId, result), + ); + } + } } diff --git a/src/api/subsonicArtists.router.test.ts b/src/api/subsonicArtists.router.test.ts new file mode 100644 index 00000000..478d9464 --- /dev/null +++ b/src/api/subsonicArtists.router.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('./subsonicClient', () => ({ + api: vi.fn(), + apiForServer: vi.fn(), + libraryFilterParams: () => ({}), + libraryFilterParamsForServer: () => ({}), +})); + +vi.mock('./subsonicLibrary', () => ({ + filterSongsToActiveLibrary: async (songs: unknown[]) => songs, + filterSongsToServerLibrary: async (songs: unknown[]) => songs, + similarSongsRequestCount: (count: number) => count, +})); + +import { api } from './subsonicClient'; +import { fetchSimilarTracksRouted } from './subsonicArtists'; +import { useAuthStore } from '../store/authStore'; + +const SID = 'srv-router'; +const apiMock = vi.mocked(api); + +function seedServer(identity: Record, probes: Record) { + useAuthStore.setState({ + activeServerId: SID, + subsonicServerIdentityByServer: { [SID]: identity as never }, + audiomusePluginProbeByServer: {}, + instantMixProbeByServer: {}, + audiomuseNavidromeByServer: {}, + ...probes, + } as never); +} + +const SONIC_RESPONSE = { sonicMatch: [{ entry: { id: 'sonic-1', title: 'Sonic' } }] }; +const SIMILAR_RESPONSE = { similarSongs: { song: [{ id: 'legacy-1', title: 'Legacy' }] } }; + +describe('fetchSimilarTracksRouted', () => { + beforeEach(() => { + apiMock.mockReset(); + }); + + it('prefers sonicSimilarity on Navidrome 0.62 with plugin', async () => { + seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, { + audiomusePluginProbeByServer: { [SID]: 'present' }, + }); + apiMock.mockImplementation(async (endpoint: string) => + (endpoint === 'getSonicSimilarTracks.view' ? SONIC_RESPONSE : SIMILAR_RESPONSE) as never); + + const result = await fetchSimilarTracksRouted('seed', 10); + expect(result.map(s => s.id)).toEqual(['sonic-1']); + expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything()); + expect(apiMock).not.toHaveBeenCalledWith('getSimilarSongs.view', expect.anything()); + }); + + it('falls back to legacy when sonic returns empty', async () => { + seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, { + audiomusePluginProbeByServer: { [SID]: 'present' }, + }); + apiMock.mockImplementation(async (endpoint: string) => + (endpoint === 'getSonicSimilarTracks.view' ? { sonicMatch: [] } : SIMILAR_RESPONSE) as never); + + const result = await fetchSimilarTracksRouted('seed', 10); + expect(result.map(s => s.id)).toEqual(['legacy-1']); + expect(apiMock).toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything()); + expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything()); + }); + + it('uses legacy only on Navidrome 0.62 without plugin', async () => { + seedServer({ type: 'navidrome', serverVersion: '0.62.0', openSubsonic: true }, { + audiomusePluginProbeByServer: { [SID]: 'absent' }, + }); + apiMock.mockImplementation(async () => SIMILAR_RESPONSE as never); + + const result = await fetchSimilarTracksRouted('seed', 10); + expect(result.map(s => s.id)).toEqual(['legacy-1']); + expect(apiMock).not.toHaveBeenCalledWith('getSonicSimilarTracks.view', expect.anything()); + expect(apiMock).toHaveBeenCalledWith('getSimilarSongs.view', expect.anything()); + }); +}); diff --git a/src/api/subsonicArtists.ts b/src/api/subsonicArtists.ts index 6be79b79..36f43ff3 100644 --- a/src/api/subsonicArtists.ts +++ b/src/api/subsonicArtists.ts @@ -2,6 +2,11 @@ import { useAuthStore } from '../store/authStore'; import { api, apiForServer, libraryFilterParams, libraryFilterParamsForServer } from './subsonicClient'; import { filterSongsToServerLibrary } from './subsonicLibrary'; import { filterSongsToActiveLibrary, similarSongsRequestCount } from './subsonicLibrary'; +import { + FEATURE_AUDIOMUSE_SIMILAR_TRACKS, + OP_SIMILAR_TRACKS, +} from '../serverCapabilities/catalog'; +import { resolveCallRoutesForServer } from '../serverCapabilities/storeView'; import type { SubsonicAlbum, SubsonicArtist, @@ -109,3 +114,45 @@ export async function getSimilarSongs(id: string, count = 50): Promise { + try { + const requestCount = similarSongsRequestCount(count); + const data = await api<{ sonicMatch: Array<{ entry?: SubsonicSong }> | { entry?: SubsonicSong } }>( + 'getSonicSimilarTracks.view', + { id, count: requestCount, ...libraryFilterParams() }, + ); + const raw = data.sonicMatch; + const list = Array.isArray(raw) ? raw : raw ? [raw] : []; + const songs = list.map(m => m.entry).filter((e): e is SubsonicSong => !!e); + if (songs.length === 0) return []; + const filtered = await filterSongsToActiveLibrary(songs); + return filtered.slice(0, count); + } catch { + return []; + } +} + +/** + * Capability-routed similar tracks for the active server. Prefers the sonic + * similarity endpoint when the AudioMuse plugin is detected (Navidrome ≥ 0.62), + * falling back to legacy `getSimilarSongs` on empty/unavailable. + */ +export async function fetchSimilarTracksRouted(songId: string, count = 50): Promise { + const { activeServerId } = useAuthStore.getState(); + if (!activeServerId) return getSimilarSongs(songId, count); + const routes = resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS); + if (routes.length === 0) return getSimilarSongs(songId, count); + for (const route of routes) { + const songs = route.transport === 'opensubsonic' + ? await getSonicSimilarTracks(songId, count) + : await getSimilarSongs(songId, count); + if (songs.length > 0) return songs; + } + return []; +} diff --git a/src/api/subsonicOpenSubsonic.test.ts b/src/api/subsonicOpenSubsonic.test.ts new file mode 100644 index 00000000..c2d75ac8 --- /dev/null +++ b/src/api/subsonicOpenSubsonic.test.ts @@ -0,0 +1,72 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import { + fetchOpenSubsonicExtensionsWithCredentials, + hasOpenSubsonicExtension, + parseOpenSubsonicExtensions, +} from './subsonicOpenSubsonic'; + +vi.mock('axios'); + +function okExtensions(extensions: unknown[]) { + return { + data: { + 'subsonic-response': { + status: 'ok', + openSubsonic: true, + openSubsonicExtensions: extensions, + }, + }, + }; +} + +describe('parseOpenSubsonicExtensions', () => { + it('parses extension names and versions', () => { + const parsed = parseOpenSubsonicExtensions([ + { name: 'sonicSimilarity', versions: [1] }, + { name: 'playbackReport', versions: [1, 2] }, + { bad: true }, + ]); + expect(parsed).toEqual([ + { name: 'sonicSimilarity', versions: [1] }, + { name: 'playbackReport', versions: [1, 2] }, + ]); + }); +}); + +describe('hasOpenSubsonicExtension', () => { + it('detects sonicSimilarity', () => { + const extensions = parseOpenSubsonicExtensions([{ name: 'sonicSimilarity', versions: [1] }]); + expect(hasOpenSubsonicExtension(extensions, 'sonicSimilarity')).toBe(true); + expect(hasOpenSubsonicExtension(extensions, 'other')).toBe(false); + }); +}); + +describe('fetchOpenSubsonicExtensionsWithCredentials', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns the advertised extension names', async () => { + vi.mocked(axios.get).mockResolvedValue( + okExtensions([{ name: 'sonicSimilarity', versions: [1] }, { name: 'playbackReport', versions: [1] }]), + ); + await expect( + fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'), + ).resolves.toEqual(['sonicSimilarity', 'playbackReport']); + }); + + it('returns an empty list when none are advertised', async () => { + vi.mocked(axios.get).mockResolvedValue(okExtensions([])); + await expect( + fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'), + ).resolves.toEqual([]); + }); + + it('returns null on request failure', async () => { + vi.mocked(axios.get).mockRejectedValue(new Error('boom')); + await expect( + fetchOpenSubsonicExtensionsWithCredentials('https://music.test', 'u', 'p'), + ).resolves.toBeNull(); + }); +}); diff --git a/src/api/subsonicOpenSubsonic.ts b/src/api/subsonicOpenSubsonic.ts new file mode 100644 index 00000000..1f6834fe --- /dev/null +++ b/src/api/subsonicOpenSubsonic.ts @@ -0,0 +1,47 @@ +import { apiWithCredentials } from './subsonicClient'; + +export interface OpenSubsonicExtension { + name: string; + versions: number[]; +} + +export function parseOpenSubsonicExtensions(raw: unknown): OpenSubsonicExtension[] { + if (!Array.isArray(raw)) return []; + return raw + .filter((entry): entry is Record => !!entry && typeof entry === 'object' && !Array.isArray(entry)) + .map(entry => ({ + name: typeof entry.name === 'string' ? entry.name : '', + versions: Array.isArray(entry.versions) + ? entry.versions.filter((v): v is number => typeof v === 'number') + : [], + })) + .filter(entry => entry.name.length > 0); +} + +export function hasOpenSubsonicExtension(extensions: readonly OpenSubsonicExtension[], name: string): boolean { + return extensions.some(ext => ext.name === name); +} + +/** + * Fetch the list of OpenSubsonic extension names advertised by the server, or + * `null` when the request fails. Shared probe for any extension-gated feature. + */ +export async function fetchOpenSubsonicExtensionsWithCredentials( + serverUrl: string, + username: string, + password: string, +): Promise { + try { + const data = await apiWithCredentials<{ openSubsonicExtensions?: unknown }>( + serverUrl, + username, + password, + 'getOpenSubsonicExtensions.view', + {}, + 12000, + ); + return parseOpenSubsonicExtensions(data.openSubsonicExtensions).map(ext => ext.name); + } catch { + return null; + } +} diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index af0b0840..66abd3b9 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -24,7 +24,10 @@ import { } from '../../utils/server/serverUrlRemigration'; import { useConfirmModalStore } from '../../store/confirmModalStore'; import { showToast } from '../../utils/ui/toast'; -import { showAudiomuseNavidromeServerSetting } from '../../utils/server/subsonicServerIdentity'; +import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from '../sidebar/perfProbe/PerfProbeStatusBadge'; +import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS } from '../../serverCapabilities/catalog'; +import { resolveFeatureForServer } from '../../serverCapabilities/storeView'; +import type { CapabilityStatus, ResolvedCapability } from '../../serverCapabilities/types'; import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; import { serverIndexKeyForProfile } from '../../utils/server/serverIndexKey'; import { switchActiveServer } from '../../utils/server/switchActiveServer'; @@ -33,6 +36,24 @@ import { ServerGripHandle } from './ServerGripHandle'; const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; +function audiomuseProbeBadge( + status: CapabilityStatus, + t: (key: string) => string, +): { tone: PerfProbeBadgeTone; label: string } { + switch (status) { + case 'present': return { tone: 'ok', label: t('settings.audiomuseStatusActive') }; + case 'absent': return { tone: 'muted', label: t('settings.audiomuseStatusNotDetected') }; + case 'error': return { tone: 'error', label: t('settings.audiomuseStatusProbeFailed') }; + default: return { tone: 'warn', label: t('settings.audiomuseStatusChecking') }; + } +} + +/** Row visibility: hide only when a manual (legacy) strategy proves the feature absent. */ +function showAudiomuseRow(resolved: ResolvedCapability | null): boolean { + if (!resolved || resolved.strategyId === null || resolved.status === 'ineligible') return false; + return !(resolved.activation === 'manual' && resolved.status === 'absent'); +} + type ServerDropTarget = { idx: number; before: boolean } | null; export function ServersTab({ @@ -135,7 +156,7 @@ export function ServersTab({ openSubsonic: probe.ping.openSubsonic, }; auth.setSubsonicServerIdentity(server.id, identity); - scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity); + scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity, true); } setConnStatus(s => ({ ...s, [server.id]: probe.ok ? 'ok' : 'error' })); } catch { @@ -232,7 +253,7 @@ export function ServersTab({ openSubsonic: ping.openSubsonic, }; auth.setSubsonicServerIdentity(id, identity); - scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); + scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true); setConnStatus(s => ({ ...s, [id]: 'ok' })); const added = useAuthStore.getState().servers.find(s => s.id === id); if (added) void bootstrapIndexedServer(added); @@ -324,7 +345,7 @@ export function ServersTab({ openSubsonic: ping.openSubsonic, }; auth.setSubsonicServerIdentity(id, identity); - scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); + scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity, true); } setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' })); } catch { @@ -483,10 +504,13 @@ export function ServersTab({ onVerify={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'verify')} onCancel={() => void librarySync.handleCancel()} /> - {showAudiomuseNavidromeServerSetting( - auth.subsonicServerIdentityByServer[srv.id], - auth.instantMixProbeByServer[srv.id], - ) && ( + {(() => { + const resolved = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS); + if (!showAudiomuseRow(resolved) || !resolved) return null; + const autoManaged = resolved.activation === 'auto'; + const probeBadge = audiomuseProbeBadge(resolved.status, t); + const audiomuseActive = !!auth.audiomuseNavidromeByServer[srv.id]; + return (
{t('settings.audiomuseTitle')} - {!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && ( + {audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && ( )}
-
- { - e.preventDefault(); - void openUrl(AUDIOMUSE_NV_PLUGIN_URL); - }} - style={{ color: 'var(--accent)', textDecoration: 'underline' }} - /> - ), - }} - /> -
+ {!autoManaged && ( +
+ { + e.preventDefault(); + void openUrl(AUDIOMUSE_NV_PLUGIN_URL); + }} + style={{ color: 'var(--accent)', textDecoration: 'underline' }} + /> + ), + }} + /> +
+ )}
- + {autoManaged ? ( + {probeBadge.label} + ) : ( + + )} - )} + ); + })()} ); })} diff --git a/src/components/sidebar/SidebarPerfProbeModal.tsx b/src/components/sidebar/SidebarPerfProbeModal.tsx index 1577380e..806b27eb 100644 --- a/src/components/sidebar/SidebarPerfProbeModal.tsx +++ b/src/components/sidebar/SidebarPerfProbeModal.tsx @@ -1,16 +1,17 @@ import { useState } from 'react'; -import { Activity, ScrollText, SlidersHorizontal, Wrench, X } from 'lucide-react'; +import { Activity, Network, ScrollText, SlidersHorizontal, Wrench, X } from 'lucide-react'; import { createPortal } from 'react-dom'; import SidebarPerfProbeMonitorTab from './perfProbe/SidebarPerfProbeMonitorTab'; import SidebarPerfProbeTogglesTab from './perfProbe/SidebarPerfProbeTogglesTab'; import SidebarPerfProbeTuningTab from './perfProbe/SidebarPerfProbeTuningTab'; import SidebarPerfProbeLogsTab from './perfProbe/SidebarPerfProbeLogsTab'; +import SidebarPerfProbeConnectionsTab from './perfProbe/SidebarPerfProbeConnectionsTab'; import { resetPerfProbeFlags, type PerfProbeFlags } from '../../utils/perf/perfFlags'; import { clearPerfLiveOverlayPins } from '../../utils/perf/perfOverlayPins'; import { resetPerfOverlayAppearance } from '../../utils/perf/perfOverlayAppearance'; import { resetPerfOverlayMode } from '../../utils/perf/perfOverlayMode'; -type TabId = 'monitor' | 'toggles' | 'tuning' | 'logs'; +type TabId = 'monitor' | 'connections' | 'toggles' | 'tuning' | 'logs'; interface Props { open: boolean; @@ -65,7 +66,7 @@ export default function SidebarPerfProbeModal({

PsyLab

- Live metrics with optional on-screen overlays, runtime tuning, and diagnostic disable toggles. + Live metrics, server connections, runtime tuning, and diagnostic disable toggles.

@@ -80,6 +81,16 @@ export default function SidebarPerfProbeModal({ Monitor +