import React, { useCallback, 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, Info, Lock, LogOut, Pencil, Plus, Power, Server, Sparkles, Wifi, WifiOff } from 'lucide-react'; import { useAuthStore } from '@/store/authStore'; import { formatServerSoftware, isNavidromeAudiomuseSoftwareEligible, type InstantMixProbeResult, type SubsonicServerIdentity } from '@/utils/server/subsonicServerIdentity'; import { buildCapabilityContext } from '@/serverCapabilities/context'; import { useLibraryIndexStore } from '@/store/libraryIndexStore'; import { libraryDeleteServerData, librarySyncClearSession } from '@/lib/api/library'; import { bootstrapIndexedServer } from '@/lib/library/librarySession'; import { useLibraryIndexSync } from '@/hooks/useLibraryIndexSync'; import ServerLibraryIndexControls from '@/features/settings/components/ServerLibraryIndexControls'; import type { ServerProfile } from '@/store/authStoreTypes'; import { pingWithCredentialsForProfile, scheduleInstantMixProbeForServer } from '@/lib/api/subsonic'; import { clearServerHttpContext, syncServerHttpContextForProfile, } from '@/utils/server/syncServerHttpContext'; import { type ServerMagicPayload } from '@/utils/server/serverMagicString'; import { ensureConnectUrlResolved, invalidateReachableEndpointCache } from '@/utils/server/serverEndpoint'; import { verifySameServerEndpoints, type VerifySameServerResult, } from '@/utils/server/serverFingerprint'; import { indexKeyRemapForUrlChange, runIndexKeyRemigration, } from '@/utils/server/serverUrlRemigration'; import { useConfirmModalStore } from '@/store/confirmModalStore'; import { showToast } from '@/utils/ui/toast'; import { FEATURE_AUDIOMUSE_SIMILAR_TRACKS } from '@/serverCapabilities/catalog'; import { isFeatureActiveForServer, resolveFeatureForServer } from '@/serverCapabilities/storeView'; import type { ResolvedCapability } from '@/serverCapabilities/types'; import { serverIdentityLabel, serverListDisplayLabel, serverSettingsEntryTitle } from '@/utils/server/serverDisplayName'; import { serverIndexKeyForProfile } from '@/utils/server/serverIndexKey'; import { switchActiveServer } from '@/utils/server/switchActiveServer'; import { AddServerForm } from '@/features/settings/components/AddServerForm'; import { ServerCapabilityHeaderBadge } from '@/features/settings/components/ServerCapabilityHeaderBadge'; import { useListReorderDnd } from '@/hooks/useListReorderDnd'; import { applyListReorderById, type ListReorderDropTarget } from '@/utils/componentHelpers/listReorder'; import { ReorderGripHandle } from '@/features/settings/components/ReorderGripHandle'; import { tooltipAttrs } from '@/ui/tooltipAttrs'; const AUDIOMUSE_NV_PLUGIN_URL = 'https://github.com/NeptuneHub/AudioMuse-AI-NV-plugin'; /** Row visibility: same as main — hide only when manual 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'); } /** Legacy Navidrome (< 0.62): manual toggle row below the card (not the auto header badge). */ function showLegacyAudiomuseToggleRow( identity: SubsonicServerIdentity | undefined, instantMixProbe: InstantMixProbeResult | undefined, resolved: ResolvedCapability | null, ): boolean { const ctx = buildCapabilityContext(identity); if (ctx.isNavidrome && ctx.semverGte([0, 62, 0])) return false; if (showAudiomuseRow(resolved)) return true; return isNavidromeAudiomuseSoftwareEligible(identity) && instantMixProbe !== 'empty'; } export function ServersTab({ initialInvite, }: { initialInvite: ServerMagicPayload | null; }) { const { t } = useTranslation(); const navigate = useNavigate(); const auth = useAuthStore(); const librarySync = useLibraryIndexSync(); const [connStatus, setConnStatus] = useState>({}); const [showAddForm, setShowAddForm] = useState(initialInvite != null); const [editingServerId, setEditingServerId] = useState(null); const [pastedServerInvite, setPastedServerInvite] = useState(initialInvite); const serversRef = useRef(auth.servers); // React Compiler refs rule: ref kept in sync with the latest value for use in effects/handlers/cleanup; not render data. // eslint-disable-next-line react-hooks/refs serversRef.current = auth.servers; const addServerInviteAnchorRef = useRef(null); useLayoutEffect(() => { if (!showAddForm || !pastedServerInvite) return; addServerInviteAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, [showAddForm, pastedServerInvite]); // Pick up later invites that arrive via the parent route handler while // ServersTab is already mounted (initial mount is handled via useState). useEffect(() => { if (initialInvite) { // React Compiler set-state-in-effect rule: local state synced with store/prop inputs when the effect’s dependencies change. // eslint-disable-next-line react-hooks/set-state-in-effect setPastedServerInvite(initialInvite); setShowAddForm(true); } }, [initialInvite]); const applyServerReorder = useCallback((draggedId: string, target: ListReorderDropTarget) => { const next = applyListReorderById(serversRef.current, draggedId, target); if (next) auth.setServers(next); }, [auth]); const { isDragging, setContainer, onMouseMove, dropEdge } = useListReorderDnd({ type: 'server_reorder', apply: applyServerReorder, }); const testConnection = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); try { // Dual-address: probe through the connect layer so the test reflects // whichever endpoint the app would actually use right now (LAN at home, // public elsewhere). probe.baseUrl also feeds the AudioMuse probe so // that one hits the same endpoint. const probe = await ensureConnectUrlResolved(server); if (probe.ok) { const identity = { type: probe.ping.type, serverVersion: probe.ping.serverVersion, openSubsonic: probe.ping.openSubsonic, }; auth.setSubsonicServerIdentity(server.id, identity); scheduleInstantMixProbeForServer(server.id, probe.baseUrl, server.username, server.password, identity, true); } setConnStatus(s => ({ ...s, [server.id]: probe.ok ? 'ok' : 'error' })); } catch { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } }; const switchToServer = async (server: ServerProfile) => { setConnStatus(s => ({ ...s, [server.id]: 'testing' })); const ok = await switchActiveServer(server); if (ok) { setConnStatus(s => ({ ...s, [server.id]: 'ok' })); // Auf der Servers-Seite bleiben, damit der User seinen Switch hier // sofort visuell bestaetigt sieht (gruener Check, aktiv-Badge). } else { setConnStatus(s => ({ ...s, [server.id]: 'error' })); } }; const deleteServer = async (server: ServerProfile) => { if (!confirm(t('settings.confirmDeleteServer', { name: serverListDisplayLabel(server, auth.servers) }))) { return; } // §5.6: when a local library index exists for this server, let the // user keep the cached rows (offline use) or delete them. OK = // delete the cache, Cancel = keep it. const hadIndex = useLibraryIndexStore.getState().isIndexEnabled(server.id); const purgeLibrary = hadIndex && confirm(t('settings.confirmDeleteServerLibrary')); auth.removeServer(server.id); try { await clearServerHttpContext(server); await librarySyncClearSession(server.id); if (purgeLibrary) { await libraryDeleteServerData(server.id); } } catch { /* best-effort — server already removed from the store */ } }; const closeAddServerForm = () => { setShowAddForm(false); setPastedServerInvite(null); }; /** * Surface a dual-address verify failure as a toast (mismatch / * insufficient / unreachable). Returns true when the result is `ok` and * the caller should proceed; false when the user must fix something * before save. */ const announceVerifyResult = (result: VerifySameServerResult): boolean => { if (result.ok) return true; if (result.reason === 'unreachable') { showToast( t('settings.dualAddressUnreachable', { host: result.unreachableHost ?? '' }), 6000, 'error', ); } else if (result.reason === 'mismatch') { showToast(t('settings.dualAddressMismatch'), 6000, 'error'); } else { showToast(t('settings.dualAddressInsufficient'), 6000, 'error'); } return false; }; const handleAddServer = async (data: Omit) => { setShowAddForm(false); setPastedServerInvite(null); const tempId = '_new'; setConnStatus(s => ({ ...s, [tempId]: 'testing' })); try { // Dual-address: confirm both addresses point at the same server // before persisting anything. Single-address adds skip verify and go // straight to the legacy ping (which is also the connect-test). if (data.alternateUrl) { const verify = await verifySameServerEndpoints( { url: data.url, alternateUrl: data.alternateUrl, customHeaders: data.customHeaders, customHeadersApplyTo: data.customHeadersApplyTo, }, data.username, data.password, ); if (!announceVerifyResult(verify)) { setConnStatus(s => ({ ...s, [tempId]: 'error' })); return; } } const ping = await pingWithCredentialsForProfile(data, data.url); if (ping.ok) { const id = auth.addServer(data); const identity = { type: ping.type, serverVersion: ping.serverVersion, openSubsonic: ping.openSubsonic, }; auth.setSubsonicServerIdentity(id, 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 syncServerHttpContextForProfile(added); void bootstrapIndexedServer(added); } } else { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } } catch { setConnStatus(s => ({ ...s, [tempId]: 'error' })); } }; // Edit normally 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. // // **Dual-address exception:** when the edit introduces or changes the // second address (or changes the primary url while a second address is // already saved), verify both addresses are the same server *before* // persisting. A mismatch here would silently bind library / cover / queue // data to two unrelated boxes — the spec blocks save in v1. const handleEditServer = async (id: string, data: Omit) => { const previous = auth.servers.find(s => s.id === id); // URL-change remigration — runs BEFORE everything else when the edit // changes the derived index key. User confirms first; on failure the // edit is aborted with a stage-specific toast. Spec §8. const remap = previous ? indexKeyRemapForUrlChange(previous, data) : null; if (remap) { const confirmed = await useConfirmModalStore.getState().request({ title: t('settings.urlRemigrationTitle'), message: t('settings.urlRemigrationMessage', { oldKey: remap.oldKey, newKey: remap.newKey, }), confirmLabel: t('settings.urlRemigrationConfirm'), cancelLabel: t('common.cancel'), danger: true, }); if (!confirmed) return; setConnStatus(s => ({ ...s, [id]: 'testing' })); const result = await runIndexKeyRemigration(remap); if (!result.ok) { const failureKey = result.failure.stage === 'inspect' ? 'settings.urlRemigrationFailureInspect' : result.failure.stage === 'run' ? 'settings.urlRemigrationFailureRun' : 'settings.urlRemigrationFailureCoverRename'; showToast(t(failureKey), 8000, 'error'); setConnStatus(s => ({ ...s, [id]: 'error' })); return; } } const dualAddressChanged = data.alternateUrl != null && data.alternateUrl !== '' && (data.alternateUrl !== previous?.alternateUrl || data.url !== previous?.url || data.username !== previous?.username || data.password !== previous?.password); if (dualAddressChanged) { setConnStatus(s => ({ ...s, [id]: 'testing' })); const verify = await verifySameServerEndpoints( { url: data.url, alternateUrl: data.alternateUrl, customHeaders: data.customHeaders, customHeadersApplyTo: data.customHeadersApplyTo, }, data.username, data.password, ); if (!announceVerifyResult(verify)) { setConnStatus(s => ({ ...s, [id]: 'error' })); return; } } setEditingServerId(null); auth.updateServer(id, data); const updated = useAuthStore.getState().servers.find(s => s.id === id); if (updated) void syncServerHttpContextForProfile(updated); // Profile edited → any cached sticky connect URL for this id may now be invalidateReachableEndpointCache(id); setConnStatus(s => ({ ...s, [id]: 'testing' })); try { const ping = await pingWithCredentialsForProfile(data, data.url); 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, true); } setConnStatus(s => ({ ...s, [id]: ping.ok ? 'ok' : 'error' })); } catch { setConnStatus(s => ({ ...s, [id]: 'error' })); } }; const handleLogout = () => { auth.logout(); navigate('/login'); }; return ( <>

{t('settings.servers')}

{t('settings.serverCompatible')}
{auth.servers.length === 0 && !showAddForm ? (
{t('settings.noServers')}
) : (
{auth.servers.map((srv) => { if (editingServerId === srv.id) { return ( handleEditServer(srv.id, data)} onCancel={() => setEditingServerId(null)} onDelete={async () => { await deleteServer(srv); setEditingServerId(null); }} /> ); } const isActive = srv.id === auth.activeServerId; const status = connStatus[srv.id]; const dropEdgeKind = isDragging ? dropEdge(srv.id) : null; const isBefore = dropEdgeKind === 'before'; const isAfter = dropEdgeKind === 'after'; const serverSoftware = formatServerSoftware(auth.subsonicServerIdentityByServer[srv.id]); const serverIdentity = auth.subsonicServerIdentityByServer[srv.id]; const resolvedAudiomuse = resolveFeatureForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS); const versionTooltip = serverSoftware ?? t('settings.serverVersionUnknown'); const audiomuseActive = isFeatureActiveForServer(srv.id, FEATURE_AUDIOMUSE_SIMILAR_TRACKS); const showLegacyAudiomuseToggle = showLegacyAudiomuseToggleRow( serverIdentity, auth.instantMixProbeByServer[srv.id], resolvedAudiomuse, ); return (
{serverSettingsEntryTitle(srv)} {resolvedAudiomuse?.activation === 'auto' && audiomuseActive && auth.audiomuseNavidromeIssueByServer[srv.id] && ( )}
{srv.url.startsWith('https://') && ( )} {serverIdentityLabel(srv)}
{status === 'ok' && } {status === 'error' && } {status === 'testing' &&
} {isActive ? ( {t('settings.serverActive')} ) : ( )}
void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'full')} onDeltaSync={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'delta')} onVerify={() => void librarySync.runServerAction(serverIndexKeyForProfile(srv), 'verify')} onCancel={() => void librarySync.handleCancel()} /> {(() => { if (!showLegacyAudiomuseToggle) return null; const audiomuseManualActive = !!auth.audiomuseNavidromeByServer[srv.id]; return (
{t('settings.audiomuseTitle')} {audiomuseManualActive && auth.audiomuseNavidromeIssueByServer[srv.id] && ( )}
{ e.preventDefault(); void openUrl(AUDIOMUSE_NV_PLUGIN_URL); }} style={{ color: 'var(--accent)', textDecoration: 'underline' }} /> ), }} />
); })()}
); })}
)}
{showAddForm ? ( ) : ( )}
); }