From 01f4a2d38a1e7829db8ec3d6ac5c3144dd7fb132 Mon Sep 17 00:00:00 2001 From: jlightner Date: Sat, 4 Apr 2026 06:02:39 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Built=20Media=20Servers=20settings=20se?= =?UTF-8?q?ction=20with=20full=20CRUD,=20connection=20t=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "src/frontend/src/api/hooks/useMediaServers.ts" - "src/frontend/src/components/MediaServerForm.tsx" - "src/frontend/src/pages/Settings.tsx" GSD-Task: S04/T04 --- src/frontend/src/api/hooks/useMediaServers.ts | 111 +++++ .../src/components/MediaServerForm.tsx | 399 ++++++++++++++++++ src/frontend/src/pages/Settings.tsx | 323 +++++++++++++- 3 files changed, 832 insertions(+), 1 deletion(-) create mode 100644 src/frontend/src/api/hooks/useMediaServers.ts create mode 100644 src/frontend/src/components/MediaServerForm.tsx diff --git a/src/frontend/src/api/hooks/useMediaServers.ts b/src/frontend/src/api/hooks/useMediaServers.ts new file mode 100644 index 0000000..8723507 --- /dev/null +++ b/src/frontend/src/api/hooks/useMediaServers.ts @@ -0,0 +1,111 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { apiClient } from '../client'; +import type { MediaServer } from '@shared/types/index'; + +// ── Query Keys ── + +export const mediaServerKeys = { + all: ['media-servers'] as const, + sections: (id: number) => ['media-servers', id, 'sections'] as const, +}; + +// ── Re-export for convenience ── + +export type { MediaServer }; + +// ── Input Types ── + +export interface CreateMediaServerInput { + name: string; + type: 'plex' | 'jellyfin'; + url: string; + token: string; + librarySection?: string | null; + enabled?: boolean; +} + +export interface UpdateMediaServerInput { + name?: string; + type?: 'plex' | 'jellyfin'; + url?: string; + token?: string; + librarySection?: string | null; + enabled?: boolean; +} + +export interface LibrarySection { + key: string; + title: string; + type: string; +} + +export interface ConnectionTestResult { + success: boolean; + message: string; + serverName?: string; +} + +// ── Queries ── + +/** Fetch all media servers. */ +export function useMediaServers() { + return useQuery({ + queryKey: mediaServerKeys.all, + queryFn: () => apiClient.get('/api/v1/media-servers'), + }); +} + +/** Fetch library sections for a saved media server by ID. */ +export function useMediaServerSections(id: number | null) { + return useQuery({ + queryKey: id !== null ? mediaServerKeys.sections(id) : ['media-servers', 'sections', 'none'], + queryFn: () => apiClient.get(`/api/v1/media-servers/${id}/sections`), + enabled: id !== null, + }); +} + +// ── Mutations ── + +/** Create a new media server. Invalidates list on success. */ +export function useCreateMediaServer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: CreateMediaServerInput) => + apiClient.post('/api/v1/media-servers', input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: mediaServerKeys.all }); + }, + }); +} + +/** Update a media server by ID. Invalidates list on success. */ +export function useUpdateMediaServer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...input }: UpdateMediaServerInput & { id: number }) => + apiClient.put(`/api/v1/media-servers/${id}`, input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: mediaServerKeys.all }); + }, + }); +} + +/** Delete a media server by ID. Invalidates list on success. */ +export function useDeleteMediaServer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => + apiClient.del(`/api/v1/media-servers/${id}`), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: mediaServerKeys.all }); + }, + }); +} + +/** Test connection for a saved media server by ID. */ +export function useTestMediaServer() { + return useMutation({ + mutationFn: (id: number) => + apiClient.post(`/api/v1/media-servers/${id}/test`), + }); +} diff --git a/src/frontend/src/components/MediaServerForm.tsx b/src/frontend/src/components/MediaServerForm.tsx new file mode 100644 index 0000000..a9ccb75 --- /dev/null +++ b/src/frontend/src/components/MediaServerForm.tsx @@ -0,0 +1,399 @@ +import { useState, useCallback, useEffect, type FormEvent } from 'react'; +import { Loader, CheckCircle, XCircle, RefreshCw } from 'lucide-react'; +import type { MediaServer } from '@shared/types/index'; +import { + useTestMediaServer, + useMediaServerSections, + type LibrarySection, +} from '../api/hooks/useMediaServers'; + +// ── Types ── + +export interface MediaServerFormValues { + name: string; + type: 'plex' | 'jellyfin'; + url: string; + token: string; + librarySection: string | null; + enabled: boolean; +} + +interface MediaServerFormProps { + /** Pass a server for edit mode. Omit for create mode. */ + server?: MediaServer; + onSubmit: (values: MediaServerFormValues) => void; + onCancel: () => void; + isPending?: boolean; + error?: string | null; +} + +// ── Shared styles ── + +const labelStyle: React.CSSProperties = { + display: 'block', + fontSize: 'var(--font-size-sm)', + fontWeight: 500, + color: 'var(--text-secondary)', + marginBottom: 'var(--space-1)', +}; + +const inputStyle: React.CSSProperties = { + width: '100%', + padding: 'var(--space-2) var(--space-3)', + backgroundColor: 'var(--bg-input)', + border: '1px solid var(--border)', + borderRadius: 'var(--radius-md)', + color: 'var(--text-primary)', + fontSize: 'var(--font-size-base)', +}; + +const selectStyle: React.CSSProperties = { + ...inputStyle, + cursor: 'pointer', +}; + +const fieldGroupStyle: React.CSSProperties = { + marginBottom: 'var(--space-4)', +}; + +const checkboxRowStyle: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 'var(--space-2)', + marginBottom: 'var(--space-2)', +}; + +const checkboxStyle: React.CSSProperties = { + width: 16, + height: 16, + accentColor: 'var(--accent)', + cursor: 'pointer', +}; + +const checkboxLabelStyle: React.CSSProperties = { + fontSize: 'var(--font-size-sm)', + color: 'var(--text-secondary)', + cursor: 'pointer', +}; + +// ── Component ── + +export function MediaServerForm({ + server, + onSubmit, + onCancel, + isPending = false, + error, +}: MediaServerFormProps) { + const isEdit = !!server; + + const [name, setName] = useState(server?.name ?? ''); + const [type, setType] = useState<'plex' | 'jellyfin'>(server?.type ?? 'plex'); + const [url, setUrl] = useState(server?.url ?? ''); + const [token, setToken] = useState(''); // Never pre-fill redacted token + const [librarySection, setLibrarySection] = useState(server?.librarySection ?? null); + const [enabled, setEnabled] = useState(server?.enabled ?? true); + + const [validationError, setValidationError] = useState(null); + + // ── Test Connection ── + const testMutation = useTestMediaServer(); + const [testResult, setTestResult] = useState<'success' | 'error' | null>(null); + const [testMessage, setTestMessage] = useState(null); + + const handleTestConnection = useCallback(() => { + if (!server) return; // Can only test saved servers + setTestResult(null); + setTestMessage(null); + testMutation.mutate(server.id, { + onSuccess: (data) => { + setTestResult(data.success ? 'success' : 'error'); + setTestMessage(data.message); + }, + onError: (err) => { + setTestResult('error'); + setTestMessage(err instanceof Error ? err.message : 'Connection test failed'); + }, + }); + }, [server, testMutation]); + + // ── Sections fetch (only for saved servers) ── + const { + data: sections, + isLoading: sectionsLoading, + refetch: refetchSections, + } = useMediaServerSections(server?.id ?? null); + + // Reset library section when type changes (sections differ between Plex and Jellyfin) + useEffect(() => { + if (!isEdit) { + setLibrarySection(null); + } + }, [type, isEdit]); + + // ── Validation ── + const isValid = + name.trim().length > 0 && + url.trim().length > 0 && + (isEdit || token.trim().length > 0); // Token required for create, optional for edit (keep existing) + + const handleSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + setValidationError(null); + + if (!name.trim()) { + setValidationError('Name is required.'); + return; + } + if (!url.trim()) { + setValidationError('URL is required.'); + return; + } + if (!isEdit && !token.trim()) { + setValidationError('Token is required.'); + return; + } + + const values: MediaServerFormValues = { + name: name.trim(), + type, + url: url.trim().replace(/\/+$/, ''), // Strip trailing slashes + token: token.trim(), + librarySection: librarySection || null, + enabled, + }; + + // In edit mode, only send token if user entered a new one + if (isEdit && !token.trim()) { + // Omit token field — the hook's UpdateMediaServerInput has all fields optional + const { token: _omit, ...rest } = values; + onSubmit(rest as MediaServerFormValues); + return; + } + + onSubmit(values); + }, + [name, type, url, token, librarySection, enabled, isEdit, onSubmit], + ); + + const displayError = validationError ?? error; + + return ( +
+ {displayError && ( +
+ {displayError} +
+ )} + + {/* Name */} +
+ + setName(e.target.value)} + placeholder="e.g. Living Room Plex" + required + style={inputStyle} + autoFocus + /> +
+ + {/* Type */} +
+ + +
+ + {/* URL */} +
+ + setUrl(e.target.value)} + placeholder={type === 'plex' ? 'http://192.168.1.100:32400' : 'http://192.168.1.100:8096'} + required + style={inputStyle} + /> + + {type === 'plex' + ? 'Plex server URL including port (default 32400)' + : 'Jellyfin server URL including port (default 8096)'} + +
+ + {/* Token */} +
+ + setToken(e.target.value)} + placeholder={isEdit ? '(leave empty to keep current)' : type === 'plex' ? 'X-Plex-Token value' : 'API key from Jellyfin dashboard'} + required={!isEdit} + style={inputStyle} + autoComplete="off" + /> + + {type === 'plex' + ? 'Find in Plex Web → Settings → General → X-Plex-Token' + : 'Dashboard → API Keys → Create'} + +
+ + {/* Library Section (dropdown, only for saved servers) */} + {isEdit && ( +
+ +
+ + +
+ + Choose a specific library to scan, or scan all libraries after downloads. + +
+ )} + + {/* Enabled */} +
+ setEnabled(e.target.checked)} + style={checkboxStyle} + /> + +
+ + {/* Test Connection (only for saved servers) */} + {isEdit && ( +
+ + {testMessage && ( + + {testMessage} + + )} +
+ )} + + {/* Action buttons */} +
+ + +
+
+ ); +} diff --git a/src/frontend/src/pages/Settings.tsx b/src/frontend/src/pages/Settings.tsx index 535b0e3..1da1005 100644 --- a/src/frontend/src/pages/Settings.tsx +++ b/src/frontend/src/pages/Settings.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useMemo, useEffect } from 'react'; -import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree } from 'lucide-react'; +import { Plus, Pencil, Trash2, Loader, RefreshCw, Star, Bell, Send, CheckCircle, XCircle, Eye, EyeOff, Copy, RotateCw, Key, Globe, Save, FolderTree, Server } from 'lucide-react'; import { useFormatProfiles, useCreateFormatProfile, @@ -20,11 +20,20 @@ import { type NotificationSetting, } from '../api/hooks/useNotifications'; import { useApiKey, useRegenerateApiKey, useAppSettings, useUpdateAppSettings } from '../api/hooks/useSystem'; +import { + useMediaServers, + useCreateMediaServer, + useUpdateMediaServer, + useDeleteMediaServer, + useTestMediaServer, + type MediaServer, +} from '../api/hooks/useMediaServers'; import { Table, type Column } from '../components/Table'; import { Modal } from '../components/Modal'; import { FormatProfileForm, type FormatProfileFormValues } from '../components/FormatProfileForm'; import { PlatformSettingsForm, type PlatformSettingsFormValues } from '../components/PlatformSettingsForm'; import { NotificationForm, type NotificationFormValues } from '../components/NotificationForm'; +import { MediaServerForm, type MediaServerFormValues } from '../components/MediaServerForm'; import { SkeletonSettings } from '../components/Skeleton'; import type { FormatProfile, PlatformSettings } from '@shared/types/index'; @@ -78,6 +87,18 @@ export function SettingsPage() { const [showRegenerateConfirm, setShowRegenerateConfirm] = useState(false); const [copySuccess, setCopySuccess] = useState(false); + // ── Media Servers state ── + const { data: mediaServers, isLoading: mediaServersLoading } = useMediaServers(); + const createMediaServerMutation = useCreateMediaServer(); + const updateMediaServerMutation = useUpdateMediaServer(); + const deleteMediaServerMutation = useDeleteMediaServer(); + const testMediaServerMutation = useTestMediaServer(); + + const [showCreateMediaServerModal, setShowCreateMediaServerModal] = useState(false); + const [editingMediaServer, setEditingMediaServer] = useState(null); + const [deletingMediaServer, setDeletingMediaServer] = useState(null); + const [mediaServerTestResults, setMediaServerTestResults] = useState>({}); + // ── App Settings state ── const { data: appSettings, isLoading: appSettingsLoading } = useAppSettings(); const updateAppSettingsMutation = useUpdateAppSettings(); @@ -162,6 +183,67 @@ export function SettingsPage() { ); }, [settingsDirty, settingsValid, checkInterval, concurrentDownloads, updateAppSettingsMutation]); + // ── Media Server handlers ── + + const handleCreateMediaServer = useCallback( + (values: MediaServerFormValues) => { + createMediaServerMutation.mutate(values, { + onSuccess: () => setShowCreateMediaServerModal(false), + }); + }, + [createMediaServerMutation], + ); + + const handleUpdateMediaServer = useCallback( + (values: MediaServerFormValues) => { + if (!editingMediaServer) return; + // Only send token if user entered a new one + const payload: Record = { + id: editingMediaServer.id, + name: values.name, + type: values.type, + url: values.url, + librarySection: values.librarySection, + enabled: values.enabled, + }; + if (values.token) { + payload.token = values.token; + } + updateMediaServerMutation.mutate(payload as Parameters[0], { + onSuccess: () => setEditingMediaServer(null), + }); + }, + [editingMediaServer, updateMediaServerMutation], + ); + + const handleDeleteMediaServer = useCallback(() => { + if (!deletingMediaServer) return; + deleteMediaServerMutation.mutate(deletingMediaServer.id, { + onSuccess: () => setDeletingMediaServer(null), + }); + }, [deletingMediaServer, deleteMediaServerMutation]); + + const handleTestMediaServer = useCallback( + (id: number) => { + setMediaServerTestResults((prev) => ({ ...prev, [id]: 'loading' })); + testMediaServerMutation.mutate(id, { + onSuccess: (data) => { + setMediaServerTestResults((prev) => ({ ...prev, [id]: data.success ? 'success' : 'error' })); + setTimeout(() => { + setMediaServerTestResults((prev) => ({ ...prev, [id]: null })); + }, 4000); + }, + onError: () => { + setMediaServerTestResults((prev) => ({ ...prev, [id]: 'error' })); + setTimeout(() => { + setMediaServerTestResults((prev) => ({ ...prev, [id]: null })); + }, 4000); + }, + }); + }, + [testMediaServerMutation], + ); + // ── Format Profile handlers ── const handleCreateProfile = useCallback( @@ -519,6 +601,118 @@ export function SettingsPage() { [], ); + // ── Media Server columns ── + + const mediaServerColumns = useMemo[]>( + () => [ + { + key: 'name', + label: 'Name', + render: (s) => ( + {s.name} + ), + }, + { + key: 'type', + label: 'Type', + width: '100px', + render: (s) => ( + + {s.type === 'plex' ? 'Plex' : 'Jellyfin'} + + ), + }, + { + key: 'url', + label: 'URL', + render: (s) => ( + + {s.url} + + ), + }, + { + key: 'section', + label: 'Library', + width: '120px', + render: (s) => ( + + {s.librarySection ?? 'All'} + + ), + }, + { + key: 'enabled', + label: 'Status', + width: '90px', + render: (s) => ( + + {s.enabled ? 'Active' : 'Disabled'} + + ), + }, + { + key: 'actions', + label: '', + width: '130px', + render: (s) => { + const result = mediaServerTestResults[s.id]; + return ( +
+ {result === 'success' && } + {result === 'error' && } + + + + + + +
+ ); + }, + }, + ], + [mediaServerTestResults, handleTestMediaServer], + ); + // ── Notification columns ── const notificationColumns = useMemo[]>( @@ -1069,6 +1263,58 @@ export function SettingsPage() { + {/* ── Media Servers section ── */} +
+
+
+

+ + Media Servers +

+

+ Connect Plex or Jellyfin servers to automatically scan libraries after downloads complete. +

+
+ +
+ +
+ {mediaServersLoading ? ( +
+ + Loading media servers... +
+ ) : ( + s.id} + emptyMessage="No media servers configured. Add a Plex or Jellyfin server to enable automatic library scans." + /> + )} + + + {/* ── Notifications section ── */}
+ {/* ── Media Server: Create modal ── */} + setShowCreateMediaServerModal(false)} width={520}> + setShowCreateMediaServerModal(false)} + isPending={createMediaServerMutation.isPending} + error={createMediaServerMutation.error instanceof Error ? createMediaServerMutation.error.message : null} + /> + + + {/* ── Media Server: Edit modal ── */} + setEditingMediaServer(null)} + width={520} + > + {editingMediaServer && ( + setEditingMediaServer(null)} + isPending={updateMediaServerMutation.isPending} + error={updateMediaServerMutation.error instanceof Error ? updateMediaServerMutation.error.message : null} + /> + )} + + + {/* ── Media Server: Delete confirmation ── */} + setDeletingMediaServer(null)} + width={400} + > +

+ Are you sure you want to delete {deletingMediaServer?.name}? + This action cannot be undone. +

+ {deleteMediaServerMutation.error && ( +
+ {deleteMediaServerMutation.error instanceof Error ? deleteMediaServerMutation.error.message : 'Delete failed'} +
+ )} +
+ + +
+
+ {/* ── Notification: Create modal ── */} setShowCreateNotifModal(false)} width={520}>