feat(server): capability framework, AudioMuse sonic routing & PsyLab Connections (#1033)

* feat(server): probe AudioMuse via OpenSubsonic and add PsyLab Connections tab

Navidrome ≥0.62: detect sonicSimilarity extension for reliable plugin signal;
older servers keep the legacy getSimilarSongs probe. PsyLab gets a Connections
tab with session, endpoint, and active-server capability details.

* feat(psylab): polish Connections tab, admin role probe, and tab bar layout

Status badges and Navidrome admin/user validation in Connections; prevent
PsyLab tab row from vertically collapsing under the Logs flex layout.

* docs: add CHANGELOG and credits for PR #1032

* feat(settings): auto-enable AudioMuse on Navidrome 0.62+ with status indicator

Replace the per-server manual toggle with a probe-driven badge when
sonicSimilarity is available; pre-0.62 Navidrome keeps the legacy toggle.

* feat(server): capability framework with AudioMuse sonic routing

Add a declarative server-capability catalog (src/serverCapabilities/) that
picks a feature strategy per server generation, runs only the needed probes,
and routes API calls. AudioMuse Instant Mix now prefers the OpenSubsonic
sonicSimilarity endpoint (getSonicSimilarTracks) on Navidrome 0.62+ and
falls back to legacy getSimilarSongs.

- catalog/context/resolve: eligibility, detection, activation, call routing
- storeView: read facade over the existing per-server probe maps
- getSonicSimilarTracks API client + fetchSimilarTracksRouted router
- route Instant Mix and Lucky Mix through the resolver
- ServersTab + PsyLab Connections read the resolver (auto status vs toggle)
- tests: resolve, storeView, router

* docs: update CHANGELOG and credits for PR #1033

Renamed branch supersedes PR #1032: point changelog/credits at #1033 and
document the server-capability framework, auto-managed AudioMuse indicator,
and sonic Instant Mix routing.

* refactor(server): address PR #1033 review — idempotent probe, drop dead code

- Make scheduleInstantMixProbeForServer idempotent: skip when a definitive
  result is cached; re-probe only on force (add/edit/test server), a prior
  error, or a server version/type change (invalidated in setSubsonicServerIdentity).
  Removes the steady-state 120 s re-probe, the present→probing→present flicker,
  and the momentary legacy-fallback routing window.
- Remove now-dead identity helpers (showAudiomuseNavidromeServerSetting,
  isAudiomusePluginAutoManaged, isNavidromeSonicSimilarityEligible,
  resolveAudiomusePluginProbeUiStatus + type) and the superseded
  probeAudiomusePluginWithCredentials; the catalog is the single source of truth.
- Drop the never-emitted 'unsupported' AudiomusePluginProbeResult variant.
- Fill audiomuseStatus* keys in all 8 non-en locales.
- Tests: probe idempotency + version-change invalidation; retarget
  OpenSubsonic test to fetchOpenSubsonicExtensionsWithCredentials.
This commit is contained in:
cucadmuh
2026-06-09 00:16:29 +03:00
committed by GitHub
parent 0878cbf308
commit 6118b3940f
43 changed files with 1867 additions and 74 deletions
+66 -35
View File
@@ -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 (
<div
className="settings-toggle-row"
data-settings-search={t('settings.audiomuseTitle')}
@@ -497,7 +521,7 @@ export function ServersTab({
<div>
<div style={{ fontWeight: 500, display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
{t('settings.audiomuseTitle')}
{!!auth.audiomuseNavidromeByServer[srv.id] && auth.audiomuseNavidromeIssueByServer[srv.id] && (
{audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && (
<AlertTriangle
size={16}
style={{ color: 'var(--warning, #f59e0b)', flexShrink: 0 }}
@@ -506,35 +530,42 @@ export function ServersTab({
/>
)}
</div>
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
<Trans
i18nKey="settings.audiomuseDesc"
components={{
pluginLink: (
<a
href={AUDIOMUSE_NV_PLUGIN_URL}
onClick={e => {
e.preventDefault();
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
}}
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
/>
),
}}
/>
</div>
{!autoManaged && (
<div style={{ fontSize: 12, color: 'var(--text-muted)', lineHeight: 1.45 }}>
<Trans
i18nKey="settings.audiomuseDesc"
components={{
pluginLink: (
<a
href={AUDIOMUSE_NV_PLUGIN_URL}
onClick={e => {
e.preventDefault();
void openUrl(AUDIOMUSE_NV_PLUGIN_URL);
}}
style={{ color: 'var(--accent)', textDecoration: 'underline' }}
/>
),
}}
/>
</div>
)}
</div>
</div>
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
<input
type="checkbox"
checked={!!auth.audiomuseNavidromeByServer[srv.id]}
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
/>
<span className="toggle-track" />
</label>
{autoManaged ? (
<PerfProbeStatusBadge tone={probeBadge.tone}>{probeBadge.label}</PerfProbeStatusBadge>
) : (
<label className="toggle-switch" aria-label={t('settings.audiomuseTitle')}>
<input
type="checkbox"
checked={audiomuseActive}
onChange={e => auth.setAudiomuseNavidromeEnabled(srv.id, e.target.checked)}
/>
<span className="toggle-track" />
</label>
)}
</div>
)}
);
})()}
</div>
);
})}