From bca45d5a80570dfaa8c43b6b4b28831fa13e1fed Mon Sep 17 00:00:00 2001 From: Frank Stellmacher <171614930+Psychotoxical@users.noreply.github.com> Date: Mon, 18 May 2026 16:32:40 +0200 Subject: [PATCH] feat(servers): scan actions + edit existing server profiles (#780) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(server-scan): plumbing for triggering library scans Adds `startScan` / `getScanStatus` against the Subsonic API (`fullScan=true` is Navidrome's extension), a small per-server scan store, and a global polling hook (2 s cadence) that emits a toast when each scan finishes. Scans can run on any configured server, including inactive ones, by reusing `apiForServer`. UI surfaces follow in the next commit. * feat(server-scan): expose Quick / Full Scan in switcher + settings cards Adds a `ServerScanActions` component with two variants (compact for the server-switcher dropdown, card for the Settings server cards) backed by the scan store from the previous commit. Full Scan requires a second click within 3 s to confirm, matching the playlist-delete pattern. Status slot shows a spinner with running track count while scanning, a green check when finished, and a red icon on error. The switcher row is converted from a single button to a flex container so per-server scan controls don't hijack the server-switch click. i18n added across all 9 locales. * fix(server-scan): reorder switcher row to check / name / scan actions Moves the check / spinner slot from the right edge to the left so the spinner pop-in on server switch doesn't sit next to the scan icons. Removes the layout shift that briefly hovered the Quick scan button when the row re-rendered. * feat(servers): edit existing server profiles in Settings → Servers * Pencil-button on each server card opens an inline edit form that replaces the card (prefilled name / URL / username / password). * `AddServerForm` reused with an `editingServer` prop — title flips to "Edit Server", submit label to "Save", magic-string field hidden (the edit scope is manual fields; magic-string remains an add-time invite shortcut). * Edit saves unconditionally — ping runs post-save as a status indicator (analog to the existing Test button) instead of gating the save. Lets users update a profile when the server is currently unreachable. * Translations across all 9 locales (`editServer`, `editServerTitle`). * fix(servers): submit Add/Edit Server form on Enter Wrapped the form body in a real
, made the submit button type="submit", marked Cancel as type="button" so Enter no longer cancels. Add-Mode now also responds to Enter — same flow, consistent across both modes. * fix(servers): collapse card action buttons to icon-only on narrow screens * Quick-Scan / Full-Scan / Test buttons in each server card hide their text label below 1100px viewport via the .server-card-btn-label class and a single media query in connection-indicator.css. * Labels remain accessible via data-tooltip and aria-label so screen readers + hover both keep working in the collapsed state. * No content reflow above the breakpoint — pure additive CSS. * fix(servers): include Use button in icon-only narrow-screen collapse The Use ("Verwenden") button on inactive server cards lacked the .server-card-btn-label wrapper, so its text stayed visible at narrow viewports and pushed Edit/Delete off-screen. Added a Power icon and wrapped the label so it collapses alongside the other action buttons. * docs(changelog,credits): #780 server scan + edit --- CHANGELOG.md | 9 + src/api/subsonicScan.ts | 23 +++ src/app/MainApp.tsx | 3 + src/components/ConnectionIndicator.tsx | 38 ++-- src/components/settings/AddServerForm.tsx | 54 +++--- src/components/settings/ServerScanActions.tsx | 164 ++++++++++++++++++ src/components/settings/ServersTab.tsx | 62 ++++++- src/config/settingsCredits.ts | 1 + src/hooks/useScanPolling.ts | 64 +++++++ src/locales/de/settings.ts | 14 ++ src/locales/en/settings.ts | 14 ++ src/locales/es/settings.ts | 14 ++ src/locales/fr/settings.ts | 14 ++ src/locales/nb/settings.ts | 14 ++ src/locales/nl/settings.ts | 14 ++ src/locales/ro/settings.ts | 14 ++ src/locales/ru/settings.ts | 14 ++ src/locales/zh/settings.ts | 14 ++ src/store/scanStore.ts | 33 ++++ .../components/connection-indicator.css | 52 ++++++ 20 files changed, 592 insertions(+), 37 deletions(-) create mode 100644 src/api/subsonicScan.ts create mode 100644 src/components/settings/ServerScanActions.tsx create mode 100644 src/hooks/useScanPolling.ts create mode 100644 src/store/scanStore.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 21c552d7..2b39c83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 +### Servers — scan triggers + edit existing profiles + +**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#780](https://github.com/Psychotoxical/psysonic/pull/780)** + +* Quick/Full Scan buttons on each server card and in the switcher dropdown (Full needs a two-step confirm). +* Pencil button opens an inline edit form prefilled with the existing profile. Card actions collapse to icon-only on narrow viewports so Edit/Delete stay reachable. + + + ## [1.46.0] - 2026-05-18 > **🙏 Special thanks to [@zz5zz](https://github.com/zz5zz)** for his tireless quirk-spotting and bug reports on the [Psysonic Discord](https://discord.gg/AMnDRErm4u) — several of the polish fixes in this release landed directly off the back of his messages. diff --git a/src/api/subsonicScan.ts b/src/api/subsonicScan.ts new file mode 100644 index 00000000..eedbf57e --- /dev/null +++ b/src/api/subsonicScan.ts @@ -0,0 +1,23 @@ +import { apiForServer } from './subsonicClient'; + +export interface ScanStatus { + scanning: boolean; + count: number; + folderCount?: number; + lastScan?: string; +} + +export async function startScan(serverId: string, fullScan: boolean): Promise { + await apiForServer(serverId, 'startScan.view', fullScan ? { fullScan: 'true' } : {}, 10000); +} + +export async function getScanStatus(serverId: string): Promise { + const data = await apiForServer<{ scanStatus?: ScanStatus }>(serverId, 'getScanStatus.view', {}, 8000); + const s = data.scanStatus; + return { + scanning: !!s?.scanning, + count: typeof s?.count === 'number' ? s.count : 0, + folderCount: typeof s?.folderCount === 'number' ? s.folderCount : undefined, + lastScan: typeof s?.lastScan === 'string' ? s.lastScan : undefined, + }; +} diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx index 57987d68..48729df8 100644 --- a/src/app/MainApp.tsx +++ b/src/app/MainApp.tsx @@ -14,6 +14,7 @@ import { useGlobalShortcutsStore } from '../store/globalShortcutsStore'; import { initHotCachePrefetch } from '../hotCachePrefetch'; import { initMiniPlayerBridgeOnMain } from '../utils/miniPlayerBridge'; import { runAdvancedModeMigration } from '../utils/migrations/advancedModeMigration'; +import { useScanPolling } from '../hooks/useScanPolling'; import { IS_WINDOWS } from '../utils/platform'; import TauriEventBridge from './TauriEventBridge'; import AppShell from './AppShell'; @@ -34,6 +35,8 @@ export default function MainApp() { // Advanced Mode toggle. Idempotent — flagged in localStorage. useEffect(() => { runAdvancedModeMigration(); }, []); + useScanPolling(); + // Push playback state to mini window + handle control events. useEffect(() => { return initMiniPlayerBridgeOnMain(); diff --git a/src/components/ConnectionIndicator.tsx b/src/components/ConnectionIndicator.tsx index f1b81730..cad14fec 100644 --- a/src/components/ConnectionIndicator.tsx +++ b/src/components/ConnectionIndicator.tsx @@ -9,6 +9,7 @@ import { useAuthStore } from '../store/authStore'; import { switchActiveServer } from '../utils/server/switchActiveServer'; import { showToast } from '../utils/ui/toast'; import { serverListDisplayLabel } from '../utils/server/serverDisplayName'; +import ServerScanActions from './settings/ServerScanActions'; interface Props { status: ConnectionStatus; @@ -163,23 +164,34 @@ export default function ConnectionIndicator({ status, isLan, serverName }: Props const busy = switchingId !== null; const labelText = serverListDisplayLabel(srv, servers); return ( - + + {switchingId === srv.id ? ( +
+ ) : active ? ( + + ) : null} + + + +
); })}
) => void; onCancel: () => void; initialInvite?: ServerMagicPayload | null; + editingServer?: ServerProfile | null; }) { const { t } = useTranslation(); - const [form, setForm] = useState({ name: '', url: '', username: '', password: '' }); + const isEdit = editingServer != null; + const [form, setForm] = useState( + editingServer + ? { name: editingServer.name, url: editingServer.url, username: editingServer.username, password: editingServer.password } + : { name: '', url: '', username: '', password: '' }, + ); const [magicString, setMagicString] = useState(''); const [showPass, setShowPass] = useState(false); const [blockPasswordReveal, setBlockPasswordReveal] = useState(false); @@ -85,8 +92,14 @@ export function AddServerForm({ }; return ( -
-

{t('settings.addServerTitle')}

+ { e.preventDefault(); submit(); }} + > +

+ {isEdit ? t('settings.editServerTitle') : t('settings.addServerTitle')} +

@@ -142,26 +155,25 @@ export function AddServerForm({ )}
-
- - -
+ {!isEdit && ( +
+ + +
+ )}
- - +
-
+ ); } diff --git a/src/components/settings/ServerScanActions.tsx b/src/components/settings/ServerScanActions.tsx new file mode 100644 index 00000000..115b127d --- /dev/null +++ b/src/components/settings/ServerScanActions.tsx @@ -0,0 +1,164 @@ +import React, { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Check, DatabaseZap, Loader2, RefreshCw, WifiOff } from 'lucide-react'; +import { startScan } from '../../api/subsonicScan'; +import { useScanStore } from '../../store/scanStore'; +import { showToast } from '../../utils/ui/toast'; + +const CONFIRM_TIMEOUT_MS = 3000; + +interface Props { + serverId: string; + /** `compact` for the server switcher dropdown (icon-only), `card` for the settings cards. */ + variant: 'compact' | 'card'; +} + +export default function ServerScanActions({ serverId, variant }: Props) { + const { t } = useTranslation(); + const entry = useScanStore(s => s.byServer[serverId]); + const setEntry = useScanStore(s => s.set); + const confirmTimerRef = useRef | null>(null); + + const phase = entry?.phase ?? 'idle'; + const count = entry?.count ?? 0; + const confirmingFull = entry?.confirmingFull ?? false; + const busy = phase === 'starting' || phase === 'running'; + + useEffect(() => () => { + if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); + }, []); + + const armConfirmTimeout = () => { + if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); + confirmTimerRef.current = setTimeout(() => { + setEntry(serverId, { confirmingFull: false }); + }, CONFIRM_TIMEOUT_MS); + }; + + const launch = async (full: boolean) => { + setEntry(serverId, { phase: 'starting', type: full ? 'full' : 'quick', count: 0, confirmingFull: false }); + try { + await startScan(serverId, full); + setEntry(serverId, { phase: 'running' }); + } catch (err) { + setEntry(serverId, { phase: 'error' }); + showToast(typeof err === 'string' ? err : err instanceof Error ? err.message : 'Scan failed', 4000, 'error'); + } + }; + + const onQuickClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (busy) return; + void launch(false); + }; + + const onFullClick = (e: React.MouseEvent) => { + e.stopPropagation(); + if (busy) return; + if (!confirmingFull) { + setEntry(serverId, { confirmingFull: true }); + armConfirmTimeout(); + return; + } + if (confirmTimerRef.current) clearTimeout(confirmTimerRef.current); + void launch(true); + }; + + // ── Status slot content ────────────────────────────────────────────────── + const status = (() => { + if (phase === 'running' || phase === 'starting') { + const label = count > 0 ? formatCount(count) : ''; + return ( + + + {label && {label}} + + ); + } + if (phase === 'just-finished') { + return ( + + + + ); + } + if (phase === 'error') { + return ( + + + + ); + } + return null; + })(); + + // ── Render ─────────────────────────────────────────────────────────────── + if (variant === 'compact') { + return ( + e.stopPropagation()}> + + + {status} + + ); + } + + // Card variant — matches the existing `btn btn-surface` chrome in ServersTab actions. + return ( + <> + + + {status && {status}} + + ); +} + +function formatCount(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`; + return String(n); +} diff --git a/src/components/settings/ServersTab.tsx b/src/components/settings/ServersTab.tsx index 5b2cd9ba..cac940c1 100644 --- a/src/components/settings/ServersTab.tsx +++ b/src/components/settings/ServersTab.tsx @@ -2,7 +2,7 @@ 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, Plus, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Trash2, User, Wifi, WifiOff } from 'lucide-react'; import { useAuthStore } from '../../store/authStore'; import type { ServerProfile } from '../../store/authStoreTypes'; import { pingWithCredentials, scheduleInstantMixProbeForServer } from '../../api/subsonic'; @@ -13,6 +13,7 @@ import { serverListDisplayLabel } from '../../utils/server/serverDisplayName'; import { switchActiveServer } from '../../utils/server/switchActiveServer'; import { AddServerForm } from './AddServerForm'; import { ServerGripHandle } from './ServerGripHandle'; +import ServerScanActions from './ServerScanActions'; const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; @@ -30,6 +31,7 @@ export function ServersTab({ const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(initialInvite != null); + const [editingServerId, setEditingServerId] = useState(null); const [pastedServerInvite, setPastedServerInvite] = useState(initialInvite); const [serverContainerEl, setServerContainerEl] = useState(null); const [serverDropTarget, setServerDropTarget] = useState(null); @@ -168,6 +170,31 @@ export function ServersTab({ } }; + // Edit saves unconditionally — ping result becomes a post-save status + // indicator (analog zum existing Test-Button) rather than blocking the + // save. Lets users update a profile even when the server is currently + // unreachable. + const handleEditServer = async (id: string, data: Omit) => { + setEditingServerId(null); + auth.updateServer(id, data); + setConnStatus(s => ({ ...s, [id]: 'testing' })); + try { + const ping = await pingWithCredentials(data.url, data.username, data.password); + if (ping.ok) { + const identity = { + type: ping.type, + serverVersion: ping.serverVersion, + openSubsonic: ping.openSubsonic, + }; + auth.setSubsonicServerIdentity(id, identity); + scheduleInstantMixProbeForServer(id, data.url, data.username, data.password, identity); + } + setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' })); + } catch { + setConnStatus(s => ({ ...s, [id]: 'error' })); + } + }; + const handleLogout = () => { auth.logout(); navigate('/login'); @@ -195,6 +222,16 @@ export function ServersTab({ style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }} > {auth.servers.map((srv, srvIdx) => { + if (editingServerId === srv.id) { + return ( + handleEditServer(srv.id, data)} + onCancel={() => setEditingServerId(null)} + /> + ); + } const isActive = srv.id === auth.activeServerId; const status = connStatus[srv.id]; const isBefore = psyDragState.isDragging && serverDropTarget?.idx === srvIdx && serverDropTarget.before; @@ -240,14 +277,17 @@ export function ServersTab({ {status === 'ok' && } {status === 'error' && } {status === 'testing' &&
} + {!isActive && ( )} +