diff --git a/CHANGELOG.md b/CHANGELOG.md index eb21416e..ae08597c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ### Dependencies — npm and Rust refresh diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index 66abd3b9..bf2a4e72 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -2,8 +2,9 @@ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; 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 { formatServerSoftware } from '../../utils/server/subsonicServerIdentity'; import { useLibraryIndexStore } from '../../store/libraryIndexStore'; import { libraryDeleteServerData, librarySyncClearSession } from '../../api/library'; import { bootstrapIndexedServer } from '../../utils/library/librarySession'; @@ -394,6 +395,7 @@ export function ServersTab({ const status = connStatus[srv.id]; const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before; const isAfter = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && !serverDropTarget.before; + const serverSoftware = formatServerSoftware(auth.subsonicServerIdentityByServer[srv.id]); return (
)}
-
- {srv.url.startsWith('https://') && ( - + {serverSoftware && ( +
+ + {serverSoftware} +
+ )} +
+ {srv.url.startsWith('https://') ? ( + + ) : ( + )} {srv.url.replace(/^https?:\/\//, '')}
-
- +
+ {srv.username}
diff --git a/src/components/sidebar/perfProbe/SidebarPerfProbeServerSection.tsx b/src/components/sidebar/perfProbe/SidebarPerfProbeServerSection.tsx index 054f304e..3f4a3979 100644 --- a/src/components/sidebar/perfProbe/SidebarPerfProbeServerSection.tsx +++ b/src/components/sidebar/perfProbe/SidebarPerfProbeServerSection.tsx @@ -1,6 +1,6 @@ import { useAuthStore } from '../../../store/authStore'; 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 { isFeatureActiveForServer, @@ -12,12 +12,6 @@ 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' }; @@ -97,7 +91,7 @@ export default function SidebarPerfProbeServerSection({ adminRole = 'na' }: Prop const rows: PerfProbeDetailRow[] = [ { label: 'Name', value: server.name || server.url }, { label: 'Profile URL', value: {server.url} }, - { label: 'Subsonic server', value: formatServerType(identity) }, + { label: 'Subsonic server', value: formatServerSoftware(identity) ?? 'Unknown' }, { label: 'OpenSubsonic', value: identity?.openSubsonic diff --git a/src/utils/server/subsonicServerIdentity.test.ts b/src/utils/server/subsonicServerIdentity.test.ts index 8106cae5..be124c84 100644 --- a/src/utils/server/subsonicServerIdentity.test.ts +++ b/src/utils/server/subsonicServerIdentity.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + formatServerSoftware, isNavidromeAudiomuseSoftwareEligible, isNavidromeServer, 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', () => { it('is permissive until a typed ping arrives', () => { expect(isNavidromeAudiomuseSoftwareEligible(undefined)).toBe(true); diff --git a/src/utils/server/subsonicServerIdentity.ts b/src/utils/server/subsonicServerIdentity.ts index 2c63d169..e09934f1 100644 --- a/src/utils/server/subsonicServerIdentity.ts +++ b/src/utils/server/subsonicServerIdentity.ts @@ -40,6 +40,23 @@ export function isNavidromeServer(identity: SubsonicServerIdentity | undefined): 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 * until the first successful ping with metadata.