mirror of
https://github.com/Psychotoxical/psysonic.git
synced 2026-07-21 14:55:43 +00:00
feat(settings): show server software and version on server cards (#1045)
* feat(settings): show server software and version on server cards Render the OpenSubsonic-reported software and version (e.g. "Navidrome 0.62.0") under each server name on Settings -> Servers. The value comes from the existing ping identity (subsonicServerIdentityByServer), so no extra request is made; the line is omitted when the server reports no type (e.g. plain Subsonic without OpenSubsonic). Extract the formatting into a shared formatServerSoftware() helper and reuse it in the PsyLab perf-probe server section in place of its local copy. * docs: add CHANGELOG entry for PR #1045
This commit is contained in:
@@ -91,6 +91,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Servers — software and version on each server card
|
||||||
|
|
||||||
|
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1045](https://github.com/Psychotoxical/psysonic/pull/1045)**
|
||||||
|
|
||||||
|
* Each server card under **Settings → Servers** now shows the server software and version (e.g. `Navidrome 0.62.0`) under the server name. The value comes from the existing connection ping, so no extra request is made; it is hidden for servers that don't report it (plain Subsonic without OpenSubsonic).
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Changed
|
## Changed
|
||||||
|
|
||||||
### Dependencies — npm and Rust refresh
|
### Dependencies — npm and Rust refresh
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
import { open as openUrl } from '@tauri-apps/plugin-shell';
|
||||||
import { AlertTriangle, CheckCircle2, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Globe, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react';
|
||||||
import { useAuthStore } from '../../store/authStore';
|
import { useAuthStore } from '../../store/authStore';
|
||||||
|
import { formatServerSoftware } from '../../utils/server/subsonicServerIdentity';
|
||||||
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
import { useLibraryIndexStore } from '../../store/libraryIndexStore';
|
||||||
import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library';
|
import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library';
|
||||||
import { bootstrapIndexedServer } from '../../utils/library/librarySession';
|
import { bootstrapIndexedServer } from '../../utils/library/librarySession';
|
||||||
@@ -394,6 +395,7 @@ export function ServersTab({
|
|||||||
const status = connStatus[srv.id];
|
const status = connStatus[srv.id];
|
||||||
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
|
const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before;
|
||||||
const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before;
|
const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before;
|
||||||
|
const serverSoftware = formatServerSoftware(auth.subsonicServerIdentityByServer[srv.id]);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={srv.id}
|
key={srv.id}
|
||||||
@@ -426,16 +428,24 @@ export function ServersTab({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
{serverSoftware && (
|
||||||
{srv.url.startsWith('https://') && (
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginBottom: '2px', overflow: 'hidden' }}>
|
||||||
<Lock size={11} style={{ color: 'var(--positive)', flexShrink: 0 }} />
|
<Server size={10} style={{ flexShrink: 0 }} />
|
||||||
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{serverSoftware}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, overflow: 'hidden' }}>
|
||||||
|
{srv.url.startsWith('https://') ? (
|
||||||
|
<Lock size={10} style={{ color: 'var(--positive)', flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<Globe size={10} style={{ flexShrink: 0 }} />
|
||||||
)}
|
)}
|
||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||||
{srv.url.replace(/^https?:\/\//, '')}
|
{srv.url.replace(/^https?:\/\//, '')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
<div style={{ fontSize: 11, color: 'var(--text-muted)', display: 'flex', alignItems: 'center', gap: 4, marginTop: 1 }}>
|
||||||
<User size={11} />
|
<User size={10} />
|
||||||
{srv.username}
|
{srv.username}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useAuthStore } from '../../../store/authStore';
|
import { useAuthStore } from '../../../store/authStore';
|
||||||
import type { NavidromeAdminRole } from '../../../hooks/useNavidromeAdminRole';
|
import type { NavidromeAdminRole } from '../../../hooks/useNavidromeAdminRole';
|
||||||
import { isNavidromeServer } from '../../../utils/server/subsonicServerIdentity';
|
import { isNavidromeServer, formatServerSoftware } from '../../../utils/server/subsonicServerIdentity';
|
||||||
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS } from '../../../serverCapabilities/catalog';
|
import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS, OP_SIMILAR_TRACKS } from '../../../serverCapabilities/catalog';
|
||||||
import {
|
import {
|
||||||
isFeatureActiveForServer,
|
isFeatureActiveForServer,
|
||||||
@@ -12,12 +12,6 @@ import { PerfProbeMetricSection } from './PerfProbeMetricCard';
|
|||||||
import PerfProbeDetailList, { type PerfProbeDetailRow } from './PerfProbeDetailList';
|
import PerfProbeDetailList, { type PerfProbeDetailRow } from './PerfProbeDetailList';
|
||||||
import PerfProbeStatusBadge, { type PerfProbeBadgeTone } from './PerfProbeStatusBadge';
|
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 } {
|
function detectionBadge(status: CapabilityStatus): { tone: PerfProbeBadgeTone; label: string } {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'present': return { tone: 'ok', label: 'Detected' };
|
case 'present': return { tone: 'ok', label: 'Detected' };
|
||||||
@@ -97,7 +91,7 @@ export default function SidebarPerfProbeServerSection({ adminRole = 'na' }: Prop
|
|||||||
const rows: PerfProbeDetailRow[] = [
|
const rows: PerfProbeDetailRow[] = [
|
||||||
{ label: 'Name', value: server.name || server.url },
|
{ label: 'Name', value: server.name || server.url },
|
||||||
{ label: 'Profile URL', value: <code className="perf-server-dl__code">{server.url}</code> },
|
{ label: 'Profile URL', value: <code className="perf-server-dl__code">{server.url}</code> },
|
||||||
{ label: 'Subsonic server', value: formatServerType(identity) },
|
{ label: 'Subsonic server', value: formatServerSoftware(identity) ?? 'Unknown' },
|
||||||
{
|
{
|
||||||
label: 'OpenSubsonic',
|
label: 'OpenSubsonic',
|
||||||
value: identity?.openSubsonic
|
value: identity?.openSubsonic
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
|
formatServerSoftware,
|
||||||
isNavidromeAudiomuseSoftwareEligible,
|
isNavidromeAudiomuseSoftwareEligible,
|
||||||
isNavidromeServer,
|
isNavidromeServer,
|
||||||
parseLeadingSemver,
|
parseLeadingSemver,
|
||||||
@@ -26,6 +27,27 @@ describe('isNavidromeServer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('formatServerSoftware', () => {
|
||||||
|
it('capitalises the type and appends the version', () => {
|
||||||
|
expect(formatServerSoftware({ type: 'navidrome', serverVersion: '0.62.0' })).toBe('Navidrome 0.62.0');
|
||||||
|
expect(formatServerSoftware({ type: 'gonic', serverVersion: '0.16.4' })).toBe('Gonic 0.16.4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps only the leading version token, dropping a build hash', () => {
|
||||||
|
expect(formatServerSoftware({ type: 'navidrome', serverVersion: '0.62.0 (1b46b977)' })).toBe('Navidrome 0.62.0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the type alone when no version is reported', () => {
|
||||||
|
expect(formatServerSoftware({ type: 'subsonic' })).toBe('Subsonic');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the server reports no type', () => {
|
||||||
|
expect(formatServerSoftware(undefined)).toBeNull();
|
||||||
|
expect(formatServerSoftware({})).toBeNull();
|
||||||
|
expect(formatServerSoftware({ type: ' ' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('isNavidromeAudiomuseSoftwareEligible', () => {
|
describe('isNavidromeAudiomuseSoftwareEligible', () => {
|
||||||
it('is permissive until a typed ping arrives', () => {
|
it('is permissive until a typed ping arrives', () => {
|
||||||
expect(isNavidromeAudiomuseSoftwareEligible(undefined)).toBe(true);
|
expect(isNavidromeAudiomuseSoftwareEligible(undefined)).toBe(true);
|
||||||
|
|||||||
@@ -40,6 +40,23 @@ export function isNavidromeServer(identity: SubsonicServerIdentity | undefined):
|
|||||||
return identity.type.trim().toLowerCase() === 'navidrome';
|
return identity.type.trim().toLowerCase() === 'navidrome';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-facing server software label from a ping identity — e.g. `Navidrome 0.62.0`.
|
||||||
|
* Capitalises the leading word of `type` (OpenSubsonic reports it lower-case) and
|
||||||
|
* appends the leading version token. Navidrome reports `serverVersion` with a build
|
||||||
|
* hash (`0.62.0 (1b46b977)`); only the version up to the first space/paren is kept.
|
||||||
|
* Returns `null` when the server reported no `type` (e.g. plain Subsonic without
|
||||||
|
* OpenSubsonic), so callers can omit the line.
|
||||||
|
*/
|
||||||
|
export function formatServerSoftware(identity: SubsonicServerIdentity | undefined): string | null {
|
||||||
|
const type = identity?.type?.trim();
|
||||||
|
if (!type) return null;
|
||||||
|
const label = type.charAt(0).toUpperCase() + type.slice(1);
|
||||||
|
const rawVersion = identity?.serverVersion?.trim();
|
||||||
|
const version = rawVersion ? rawVersion.split(/[\s(]/)[0] : undefined;
|
||||||
|
return version ? `${label} ${version}` : label;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navidrome version from ping supports the plugin system (≥ 0.60). Unknown `type` stays permissive
|
* Navidrome version from ping supports the plugin system (≥ 0.60). Unknown `type` stays permissive
|
||||||
* until the first successful ping with metadata.
|
* until the first successful ping with metadata.
|
||||||
|
|||||||
Reference in New Issue
Block a user