mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 23:05:46 +00:00
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:
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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({
|
||||
<header className="sidebar-perf-modal__header">
|
||||
<h3 id="psylab-title" className="modal-title">PsyLab</h3>
|
||||
<p className="sidebar-perf-modal__hint">
|
||||
Live metrics with optional on-screen overlays, runtime tuning, and diagnostic disable toggles.
|
||||
Live metrics, server connections, runtime tuning, and diagnostic disable toggles.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
@@ -80,6 +81,16 @@ export default function SidebarPerfProbeModal({
|
||||
<Activity size={15} />
|
||||
Monitor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'connections'}
|
||||
className={`sidebar-perf-modal__tab${tab === 'connections' ? ' sidebar-perf-modal__tab--active' : ''}`}
|
||||
onClick={() => setTab('connections')}
|
||||
>
|
||||
<Network size={15} />
|
||||
Connections
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
@@ -114,6 +125,7 @@ export default function SidebarPerfProbeModal({
|
||||
|
||||
<div className={`sidebar-perf-modal__body${tab === 'logs' ? ' sidebar-perf-modal__body--logs' : ''}`}>
|
||||
{tab === 'monitor' && <SidebarPerfProbeMonitorTab />}
|
||||
{tab === 'connections' && <SidebarPerfProbeConnectionsTab />}
|
||||
{tab === 'tuning' && <SidebarPerfProbeTuningTab />}
|
||||
{tab === 'toggles' && (
|
||||
<SidebarPerfProbeTogglesTab
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface PerfProbeDetailRow {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
rows: PerfProbeDetailRow[];
|
||||
}
|
||||
|
||||
export default function PerfProbeDetailList({ rows }: Props) {
|
||||
return (
|
||||
<dl className="perf-server-dl">
|
||||
{rows.map(row => (
|
||||
<div key={row.label} className="perf-server-dl__row">
|
||||
<dt>{row.label}</dt>
|
||||
<dd>{row.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ interface SectionProps {
|
||||
hint?: string;
|
||||
children: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
layout?: 'grid' | 'stack';
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -79,6 +80,7 @@ export function PerfProbeMetricSection({
|
||||
hint,
|
||||
children,
|
||||
defaultOpen = true,
|
||||
layout = 'grid',
|
||||
onOpenChange,
|
||||
}: SectionProps) {
|
||||
return (
|
||||
@@ -92,7 +94,9 @@ export function PerfProbeMetricSection({
|
||||
<span>{title}</span>
|
||||
{hint && <span className="perf-metric-section__hint">{hint}</span>}
|
||||
</summary>
|
||||
<div className="perf-metric-section__grid">{children}</div>
|
||||
<div className={layout === 'stack' ? 'perf-metric-section__body' : 'perf-metric-section__grid'}>
|
||||
{children}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type PerfProbeBadgeTone = 'ok' | 'warn' | 'error' | 'neutral' | 'muted';
|
||||
|
||||
interface Props {
|
||||
tone: PerfProbeBadgeTone;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function PerfProbeStatusBadge({ tone, children }: Props) {
|
||||
return (
|
||||
<span className={`perf-status-badge perf-status-badge--${tone}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import { usePlayerStore } from '../../../store/playerStore';
|
||||
import { useConnectionStatus } from '../../../hooks/useConnectionStatus';
|
||||
import { useNavidromeAdminRole } from '../../../hooks/useNavidromeAdminRole';
|
||||
import { serverListDisplayLabel } from '../../../utils/server/serverDisplayName';
|
||||
import { findServerByIdOrIndexKey } from '../../../utils/server/serverLookup';
|
||||
import { PerfProbeMetricSection } from './PerfProbeMetricCard';
|
||||
import PerfProbeDetailList from './PerfProbeDetailList';
|
||||
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from './PerfProbeStatusBadge';
|
||||
import SidebarPerfProbeServerSection from './SidebarPerfProbeServerSection';
|
||||
|
||||
function connectionStatusBadge(status: string): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (status) {
|
||||
case 'connected': return { tone: 'ok', label: 'Connected' };
|
||||
case 'disconnected': return { tone: 'error', label: 'Disconnected' };
|
||||
case 'checking': return { tone: 'warn', label: 'Checking…' };
|
||||
default: return { tone: 'muted', label: status };
|
||||
}
|
||||
}
|
||||
|
||||
function sessionBadge(loggedIn: boolean): { tone: PerfProbeBadgeTone; label: string } {
|
||||
return loggedIn
|
||||
? { tone: 'ok', label: 'Logged in' }
|
||||
: { tone: 'muted', label: 'Not logged in' };
|
||||
}
|
||||
|
||||
function adminRoleBadge(role: ReturnType<typeof useNavidromeAdminRole>): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (role) {
|
||||
case 'admin': return { tone: 'ok', label: 'Admin' };
|
||||
case 'user': return { tone: 'neutral', label: 'Standard user' };
|
||||
case 'checking':
|
||||
case 'idle': return { tone: 'warn', label: 'Checking…' };
|
||||
case 'error': return { tone: 'error', label: 'Could not verify' };
|
||||
case 'na':
|
||||
default: return { tone: 'muted', label: 'N/A (not Navidrome)' };
|
||||
}
|
||||
}
|
||||
|
||||
function endpointBadge(isLan: boolean): { tone: PerfProbeBadgeTone; label: string } {
|
||||
return isLan
|
||||
? { tone: 'ok', label: 'LAN' }
|
||||
: { tone: 'neutral', label: 'Public / remote' };
|
||||
}
|
||||
|
||||
export default function SidebarPerfProbeConnectionsTab() {
|
||||
const { status, isLan, serverName } = useConnectionStatus();
|
||||
const isLoggedIn = useAuthStore(s => s.isLoggedIn);
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const servers = useAuthStore(s => s.servers);
|
||||
const connectUrl = useAuthStore(s => s.getBaseUrl());
|
||||
const queueServerId = usePlayerStore(s => s.queueServerId);
|
||||
const adminRole = useNavidromeAdminRole();
|
||||
|
||||
const queueServer = queueServerId ? findServerByIdOrIndexKey(queueServerId) : undefined;
|
||||
const queueDiffersFromActive = Boolean(
|
||||
queueServerId && activeServerId && queueServerId !== activeServerId,
|
||||
);
|
||||
|
||||
const connBadge = connectionStatusBadge(status);
|
||||
const sessBadge = sessionBadge(isLoggedIn);
|
||||
const roleBadge = adminRoleBadge(adminRole);
|
||||
|
||||
return (
|
||||
<div className="perf-monitor">
|
||||
<div className="perf-conn-summary" aria-label="Connection overview">
|
||||
<div className="perf-conn-summary__item">
|
||||
<span className="perf-conn-summary__label">Link</span>
|
||||
<PerfProbeStatusBadge tone={connBadge.tone}>{connBadge.label}</PerfProbeStatusBadge>
|
||||
</div>
|
||||
<div className="perf-conn-summary__item">
|
||||
<span className="perf-conn-summary__label">Session</span>
|
||||
<PerfProbeStatusBadge tone={sessBadge.tone}>{sessBadge.label}</PerfProbeStatusBadge>
|
||||
</div>
|
||||
{isLoggedIn && (
|
||||
<div className="perf-conn-summary__item">
|
||||
<span className="perf-conn-summary__label">Role</span>
|
||||
<PerfProbeStatusBadge tone={roleBadge.tone}>{roleBadge.label}</PerfProbeStatusBadge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PerfProbeMetricSection title="Connection" defaultOpen layout="stack">
|
||||
<PerfProbeDetailList
|
||||
rows={[
|
||||
{
|
||||
label: 'Status',
|
||||
value: <PerfProbeStatusBadge tone={connBadge.tone}>{connBadge.label}</PerfProbeStatusBadge>,
|
||||
},
|
||||
{
|
||||
label: 'Session',
|
||||
value: <PerfProbeStatusBadge tone={sessBadge.tone}>{sessBadge.label}</PerfProbeStatusBadge>,
|
||||
},
|
||||
...(isLoggedIn
|
||||
? [{
|
||||
label: 'Navidrome role',
|
||||
value: <PerfProbeStatusBadge tone={roleBadge.tone}>{roleBadge.label}</PerfProbeStatusBadge>,
|
||||
}]
|
||||
: []),
|
||||
...(serverName ? [{ label: 'Browse label', value: serverName }] : []),
|
||||
...(connectUrl ? [{ label: 'Connect URL', value: <code className="perf-server-dl__code">{connectUrl}</code> }] : []),
|
||||
...(status === 'connected'
|
||||
? [{
|
||||
label: 'Endpoint',
|
||||
value: (
|
||||
<PerfProbeStatusBadge tone={endpointBadge(isLan).tone}>
|
||||
{endpointBadge(isLan).label}
|
||||
</PerfProbeStatusBadge>
|
||||
),
|
||||
}]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</PerfProbeMetricSection>
|
||||
|
||||
<SidebarPerfProbeServerSection adminRole={adminRole} />
|
||||
|
||||
{queueDiffersFromActive && queueServer && (
|
||||
<PerfProbeMetricSection title="Queue playback server" defaultOpen={false} layout="stack">
|
||||
<PerfProbeDetailList
|
||||
rows={[
|
||||
{ label: 'Name', value: serverListDisplayLabel(queueServer, servers) },
|
||||
{ label: 'Scope key', value: <code className="perf-server-dl__code">{queueServerId}</code> },
|
||||
]}
|
||||
/>
|
||||
</PerfProbeMetricSection>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
import type { NavidromeAdminRole } from '../../../hooks/useNavidromeAdminRole';
|
||||
import { isNavidromeServer } from '../../../utils/server/subsonicServerIdentity';
|
||||
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS } from '../../../serverCapabilities/catalog';
|
||||
import {
|
||||
isFeatureActiveForServer,
|
||||
resolveCallRoutesForServer,
|
||||
resolveFeatureForServer,
|
||||
} from '../../../serverCapabilities/storeView';
|
||||
import type { CapabilityStatus, FeatureTrust, ResolvedCapability } from '../../../serverCapabilities/types';
|
||||
import { PerfProbeMetricSection } from './PerfProbeMetricCard';
|
||||
import PerfProbeDetailList, { type PerfProbeDetailRow } from './PerfProbeDetailList';
|
||||
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from './PerfProbeStatusBadge';
|
||||
|
||||
function formatServerType(identity: { type?: string; serverVersion?: string; openSubsonic?: boolean } | undefined): string {
|
||||
if (!identity?.type?.trim()) return 'Unknown';
|
||||
const version = identity.serverVersion?.trim();
|
||||
return version ? `${identity.type} ${version}` : identity.type;
|
||||
}
|
||||
|
||||
function detectionBadge(status: CapabilityStatus): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (status) {
|
||||
case 'present': return { tone: 'ok', label: 'Detected' };
|
||||
case 'absent': return { tone: 'muted', label: 'Not detected' };
|
||||
case 'probing': return { tone: 'warn', label: 'Checking…' };
|
||||
case 'error': return { tone: 'error', label: 'Probe failed' };
|
||||
case 'unknown': return { tone: 'muted', label: 'Not probed yet' };
|
||||
default: return { tone: 'muted', label: 'N/A' };
|
||||
}
|
||||
}
|
||||
|
||||
function strategyLabel(strategyId: string | null): string {
|
||||
switch (strategyId) {
|
||||
case 'opensubsonic.sonicSimilarity': return 'sonicSimilarity (OpenSubsonic)';
|
||||
case 'subsonic.getSimilarSongs': return 'getSimilarSongs (legacy)';
|
||||
default: return '—';
|
||||
}
|
||||
}
|
||||
|
||||
function trustBadge(trust: FeatureTrust | null): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (trust) {
|
||||
case 'high': return { tone: 'ok', label: 'high' };
|
||||
case 'low': return { tone: 'warn', label: 'heuristic' };
|
||||
default: return { tone: 'muted', label: '—' };
|
||||
}
|
||||
}
|
||||
|
||||
function adminRoleBadge(role: NavidromeAdminRole): { tone: PerfProbeBadgeTone; label: string } {
|
||||
switch (role) {
|
||||
case 'admin': return { tone: 'ok', label: 'Admin' };
|
||||
case 'user': return { tone: 'neutral', label: 'Standard user' };
|
||||
case 'checking':
|
||||
case 'idle': return { tone: 'warn', label: 'Checking…' };
|
||||
case 'error': return { tone: 'error', label: 'Could not verify' };
|
||||
case 'na':
|
||||
default: return { tone: 'muted', label: 'N/A' };
|
||||
}
|
||||
}
|
||||
|
||||
interface Props {
|
||||
adminRole?: NavidromeAdminRole;
|
||||
}
|
||||
|
||||
export default function SidebarPerfProbeServerSection({ adminRole = 'na' }: Props) {
|
||||
const activeServerId = useAuthStore(s => s.activeServerId);
|
||||
const server = useAuthStore(s => s.servers.find(srv => srv.id === s.activeServerId));
|
||||
const identity = useAuthStore(s =>
|
||||
activeServerId ? s.subsonicServerIdentityByServer[activeServerId] : undefined,
|
||||
);
|
||||
// Subscribe to the probe maps so the resolver-derived rows re-render on probe updates.
|
||||
useAuthStore(s => (activeServerId ? s.audiomusePluginProbeByServer[activeServerId] : undefined));
|
||||
useAuthStore(s => (activeServerId ? s.instantMixProbeByServer[activeServerId] : undefined));
|
||||
useAuthStore(s => (activeServerId ? s.audiomuseNavidromeByServer[activeServerId] : undefined));
|
||||
|
||||
if (!server) {
|
||||
return (
|
||||
<PerfProbeMetricSection title="Active server" defaultOpen layout="stack">
|
||||
<div className="perf-monitor-empty perf-monitor-empty--inline">
|
||||
No server configured.
|
||||
</div>
|
||||
</PerfProbeMetricSection>
|
||||
);
|
||||
}
|
||||
|
||||
const navidrome = isNavidromeServer(identity);
|
||||
const role = adminRoleBadge(adminRole);
|
||||
const resolved: ResolvedCapability | null = activeServerId
|
||||
? resolveFeatureForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS)
|
||||
: null;
|
||||
const audiomuseActive = activeServerId
|
||||
? isFeatureActiveForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS)
|
||||
: false;
|
||||
const routes = activeServerId
|
||||
? resolveCallRoutesForServer(activeServerId, FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS)
|
||||
: [];
|
||||
|
||||
const rows: PerfProbeDetailRow[] = [
|
||||
{ label: 'Name', value: server.name || server.url },
|
||||
{ label: 'Profile URL', value: <code className="perf-server-dl__code">{server.url}</code> },
|
||||
{ label: 'Subsonic server', value: formatServerType(identity) },
|
||||
{
|
||||
label: 'OpenSubsonic',
|
||||
value: identity?.openSubsonic
|
||||
? <PerfProbeStatusBadge tone="ok">yes</PerfProbeStatusBadge>
|
||||
: identity
|
||||
? <PerfProbeStatusBadge tone="muted">no</PerfProbeStatusBadge>
|
||||
: '—',
|
||||
},
|
||||
];
|
||||
|
||||
if (navidrome) {
|
||||
rows.push({
|
||||
label: 'Navidrome role',
|
||||
value: <PerfProbeStatusBadge tone={role.tone}>{role.label}</PerfProbeStatusBadge>,
|
||||
});
|
||||
}
|
||||
|
||||
if (resolved && resolved.strategyId !== null && resolved.status !== 'ineligible') {
|
||||
const detect = detectionBadge(resolved.status);
|
||||
const trust = trustBadge(resolved.trust);
|
||||
rows.push(
|
||||
{
|
||||
label: 'AudioMuse Instant Mix',
|
||||
value: <PerfProbeStatusBadge tone={detect.tone}>{detect.label}</PerfProbeStatusBadge>,
|
||||
},
|
||||
{ label: 'Provider', value: <code className="perf-server-dl__code">{strategyLabel(resolved.strategyId)}</code> },
|
||||
{
|
||||
label: 'Detection trust',
|
||||
value: <PerfProbeStatusBadge tone={trust.tone}>{trust.label}</PerfProbeStatusBadge>,
|
||||
},
|
||||
{
|
||||
label: 'Mode',
|
||||
value: audiomuseActive
|
||||
? (
|
||||
<PerfProbeStatusBadge tone="ok">
|
||||
{resolved.activation === 'auto' ? 'active (auto)' : 'enabled in Settings'}
|
||||
</PerfProbeStatusBadge>
|
||||
)
|
||||
: <PerfProbeStatusBadge tone="muted">off</PerfProbeStatusBadge>,
|
||||
},
|
||||
);
|
||||
if (routes.length > 0) {
|
||||
rows.push({
|
||||
label: 'Call route',
|
||||
value: <code className="perf-server-dl__code">{routes.map(r => r.endpoint).join(' → ')}</code>,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PerfProbeMetricSection title="Active server" defaultOpen layout="stack">
|
||||
<PerfProbeDetailList rows={rows} />
|
||||
</PerfProbeMetricSection>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user