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
+40 -4
View File
@@ -17,10 +17,9 @@ type SetState = (
* ping reveals the server isn't AudioMuse-eligible (wrong type or
* too old), wipe the related caps for that id so the UI doesn't
* keep a stale toggle.
* - `setInstantMixProbe` — probe result for getSimilarSongs. If
* `empty`, wipe the related AudioMuse caps so the row hides.
* - `setAudiomuseNavidromeIssue` — set/clear the "current session
* saw a failure" flag.
* - `setInstantMixProbe` — legacy probe (pre-0.62). If `empty`, wipe AudioMuse caps.
* - `setAudiomusePluginProbe` — Navidrome ≥ 0.62 `sonicSimilarity` extension probe.
* - `setAudiomuseNavidromeIssue` — set/clear the "current session saw a failure" flag.
*/
export function createPerServerCapabilityActions(set: SetState): Pick<
AuthState,
@@ -28,6 +27,7 @@ export function createPerServerCapabilityActions(set: SetState): Pick<
| 'setAudiomuseNavidromeEnabled'
| 'setSubsonicServerIdentity'
| 'setInstantMixProbe'
| 'setAudiomusePluginProbe'
| 'setAudiomuseNavidromeIssue'
> {
return {
@@ -50,16 +50,31 @@ export function createPerServerCapabilityActions(set: SetState): Pick<
setSubsonicServerIdentity: (serverId, identity) =>
set(s => {
const prev = s.subsonicServerIdentityByServer[serverId];
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;
const { [serverId]: _pp, ...pluginProbeRest } = s.audiomusePluginProbeByServer;
return {
subsonicServerIdentityByServer,
audiomuseNavidromeByServer: audiomuseRest,
audiomuseNavidromeIssueByServer: issueRest,
instantMixProbeByServer: probeRest,
audiomusePluginProbeByServer: pluginProbeRest,
};
}
// Server generation changed (version/type) → drop cached capability probes
// so the next probe re-runs against the new generation. The user opt-in
// (`audiomuseNavidromeByServer`) is preserved.
if (prev && (prev.serverVersion !== identity.serverVersion || prev.type !== identity.type)) {
const { [serverId]: _p, ...probeRest } = s.instantMixProbeByServer;
const { [serverId]: _pp, ...pluginProbeRest } = s.audiomusePluginProbeByServer;
return {
subsonicServerIdentityByServer,
instantMixProbeByServer: probeRest,
audiomusePluginProbeByServer: pluginProbeRest,
};
}
return { subsonicServerIdentityByServer };
@@ -80,6 +95,27 @@ export function createPerServerCapabilityActions(set: SetState): Pick<
return { instantMixProbeByServer };
}),
setAudiomusePluginProbe: (serverId, result) =>
set(s => {
const audiomusePluginProbeByServer = { ...s.audiomusePluginProbeByServer, [serverId]: result };
if (result === 'present') {
return {
audiomusePluginProbeByServer,
audiomuseNavidromeByServer: { ...s.audiomuseNavidromeByServer, [serverId]: true },
};
}
if (result === 'absent' || result === 'error') {
const { [serverId]: _a, ...audiomuseRest } = s.audiomuseNavidromeByServer;
const { [serverId]: _i, ...issueRest } = s.audiomuseNavidromeIssueByServer;
return {
audiomusePluginProbeByServer,
audiomuseNavidromeByServer: audiomuseRest,
audiomuseNavidromeIssueByServer: issueRest,
};
}
return { audiomusePluginProbeByServer };
}),
setAudiomuseNavidromeIssue: (serverId, hasIssue) =>
set(s =>
hasIssue
+2
View File
@@ -56,6 +56,7 @@ export function createServerProfileActions(set: SetState): Pick<
const { [id]: _idn, ...identityRest } = s.subsonicServerIdentityByServer;
const { [id]: _iss, ...issueRest } = s.audiomuseNavidromeIssueByServer;
const { [id]: _pr, ...probeRest } = s.instantMixProbeByServer;
const { [id]: _ppl, ...pluginProbeRest } = s.audiomusePluginProbeByServer;
return {
servers: newServers,
activeServerId: switchedAway ? (newServers[0]?.id ?? null) : s.activeServerId,
@@ -65,6 +66,7 @@ export function createServerProfileActions(set: SetState): Pick<
subsonicServerIdentityByServer: identityRest,
audiomuseNavidromeIssueByServer: issueRest,
instantMixProbeByServer: probeRest,
audiomusePluginProbeByServer: pluginProbeRest,
};
});
},
+12
View File
@@ -255,6 +255,18 @@ describe('per-server bookkeeping setters', () => {
'srv-1': true,
});
});
it('setAudiomusePluginProbe auto-enables AudioMuse when sonicSimilarity is present', () => {
useAuthStore.getState().setAudiomusePluginProbe('srv-1', 'present');
expect(useAuthStore.getState().audiomusePluginProbeByServer).toEqual({ 'srv-1': 'present' });
expect(useAuthStore.getState().audiomuseNavidromeByServer).toEqual({ 'srv-1': true });
});
it('setAudiomusePluginProbe clears AudioMuse when the extension is absent', () => {
useAuthStore.getState().setAudiomuseNavidromeEnabled('srv-1', true);
useAuthStore.getState().setAudiomusePluginProbe('srv-1', 'absent');
expect(useAuthStore.getState().audiomuseNavidromeByServer).toEqual({});
});
});
describe('genre blacklist + audio output device', () => {
+1
View File
@@ -121,6 +121,7 @@ export const useAuthStore = create<AuthState>()(
subsonicServerIdentityByServer: {},
audiomuseNavidromeIssueByServer: {},
instantMixProbeByServer: {},
audiomusePluginProbeByServer: {},
isLoggedIn: false,
isConnecting: false,
connectionError: null,
+10 -2
View File
@@ -1,5 +1,6 @@
import type { EntityRatingSupportLevel } from '../api/subsonicTypes';
import type {
AudiomusePluginProbeResult,
InstantMixProbeResult,
SubsonicServerIdentity,
} from '../utils/server/subsonicServerIdentity';
@@ -250,7 +251,8 @@ export interface AuthState {
setEntityRatingSupport: (serverId: string, level: EntityRatingSupportLevel) => void;
/**
* Per server: Navidrome has the AudioMuse-AI plugin — use `getSimilarSongs` (Instant Mix) and
* Per server: AudioMuse-AI features active — manual opt-in on pre-0.62 Navidrome; auto-set on
* 0.62+ when `sonicSimilarity` probe is `present`. Uses `getSimilarSongs` (Instant Mix) and
* `getArtistInfo2` similar artists instead of Last.fm for discovery on this server.
*/
audiomuseNavidromeByServer: Record<string, boolean>;
@@ -265,11 +267,17 @@ export interface AuthState {
setAudiomuseNavidromeIssue: (serverId: string, hasIssue: boolean) => void;
/**
* `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row; re-run by testing connection.
* `getSimilarSongs` probe per server (after ping). `empty` hides the AudioMuse row on pre-0.62 Navidrome.
*/
instantMixProbeByServer: Record<string, InstantMixProbeResult>;
setInstantMixProbe: (serverId: string, result: InstantMixProbeResult) => void;
/**
* Navidrome ≥ 0.62: `sonicSimilarity` extension probe (`present` = AudioMuse-style plugin active).
*/
audiomusePluginProbeByServer: Record<string, AudiomusePluginProbeResult>;
setAudiomusePluginProbe: (serverId: string, result: AudiomusePluginProbeResult) => void;
// Status
isLoggedIn: boolean;
isConnecting: boolean;