import type { components } from '@pipu/api-client'; import { unwrap } from '@pipu/core'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useId, useState, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession, useUser } from '../../app/session'; import { GeniusPanel } from './GeniusPanel'; import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons'; import { Button, Dialog, EmptyState, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui'; import s from '../page.module.css'; import l from './library.module.css'; import { ScanLine, StatusBadge } from './ScanStatus'; type Source = components['schemas']['Source']; type TestResult = components['schemas']['SourceTestResult']; const SCAN_POLL_MS = 2000; export function SourcesPage() { const user = useUser(); const { t } = useTranslation(['admin', 'common']); const session = useSession(); const errorText = useErrorText(); const [editing, setEditing] = useState(null); const sources = useQuery({ queryKey: ['sources'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items, // Пока идёт скан — опрашиваем прогресс. refetchInterval: (q) => (q.state.data?.some((x) => x.scan?.running) ? SCAN_POLL_MS : false), }); return (

{t('sources.title')}

{t('sources.subtitle')}

{sources.error ? errorText(sources.error) : null} {user.role === 'admin' && } {sources.isSuccess && sources.data.length === 0 && ( setEditing('new')}>{t('sources.add')}}> {t('sources.empty')} )}
{sources.data?.map((src) => ( setEditing(src)} /> ))}
{editing && setEditing(null)} />}
); } function location(src: Source): string { if (src.type === 'nfs') return `${src.nfs_server ?? ''}:${src.nfs_export ?? ''}`; return `LIBRARY_HOST/${src.local_path ?? ''}`; } function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const queryClient = useQueryClient(); const navigate = useNavigate(); const errorText = useErrorText(); const [test, setTest] = useState(null); const [confirmDelete, setConfirmDelete] = useState(false); const id = source.id; const covers = useQuery({ queryKey: ['source-cover-stats', id], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}/cover-stats', { params: { path: { id } } })), refetchInterval: 10000, }); const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] }); const scan = useMutation({ mutationFn: async (full: boolean) => unwrap(await session.client.POST('/api/v1/admin/sources/{id}/scan', { params: { path: { id } }, body: { full } })), onSuccess: refresh, }); const check = useMutation({ mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/sources/{id}/test', { params: { path: { id } } })), onSuccess: setTest, }); const patch = useMutation({ mutationFn: async (enabled: boolean) => unwrap(await session.client.PATCH('/api/v1/admin/sources/{id}', { params: { path: { id } }, body: { enabled } })), onSuccess: refresh, }); const remove = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/admin/sources/{id}', { params: { path: { id } } })), onSuccess: refresh, }); const error = scan.error ?? check.error ?? patch.error ?? remove.error; const running = source.scan?.running ?? false; const disabled = source.status === 'disabled'; const menu: MenuItem[] = [ { label: t('sources.full_scan'), icon: refreshIcon, onSelect: () => scan.mutate(true) }, { label: t('sources.edit'), onSelect: onEdit }, disabled ? { label: t('sources.enable'), onSelect: () => patch.mutate(true) } : { label: t('sources.disable'), onSelect: () => patch.mutate(false) }, { label: t('sources.delete'), danger: true, onSelect: () => setConfirmDelete(true) }, ]; return (
{source.name} {t(`sources.type_${source.type}`)} {source.accepts_uploads && {t('write_template.badge')}}
{location(source)} {!!source.excluded_dirs?.length && {t('sources.excluded_count', { count: source.excluded_dirs.length })}} {source.status === 'error' && source.status_message && ( {source.status_message} )}
} items={menu} />
{covers.data && {t('sources.cover_files', { count: covers.data.files_with_cover, total: covers.data.files_total })} {covers.data.albums_pending > 0 && ` · ${t('sources.cover_pending', { count: covers.data.albums_pending })}`} } {covers.error ? errorText(covers.error) : null} {test && ( {test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })} )} {test && test.write_ok !== null && test.write_ok !== undefined && ( {test.write_ok ? t('write_template.write_ok') : t('write_template.write_fail', { message: test.write_message ?? '' })} )} {error ? errorText(error) : null}
{confirmDelete && ( setConfirmDelete(false)} footer={ <> } >

{t('sources.delete_confirm', { name: source.name })}

)}
); } const INTERVALS = [0, 60, 360, 1440]; export function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const queryClient = useQueryClient(); const errorText = useErrorText(); const fieldError = useFieldError(); const [type, setType] = useState<'local' | 'nfs'>(source?.type ?? 'local'); const [name, setName] = useState(source?.name ?? ''); const [localPath, setLocalPath] = useState(source?.local_path ?? ''); const [server, setServer] = useState(source?.nfs_server ?? ''); const [exportPath, setExportPath] = useState(source?.nfs_export ?? ''); const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3'); const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first'); const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0); const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n')); const excludedDirsId = useId(); const save = useMutation({ mutationFn: async () => { const common = { name: name.trim(), tag_priority: priority, excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean), ...(type === 'local' ? { local_path: localPath } : { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }), }; if (source) return unwrap( await session.client.PATCH('/api/v1/admin/sources/{id}', { params: { path: { id: source.id } }, body: { ...common, scan_interval_minutes: interval }, }), ); return unwrap( await session.client.POST('/api/v1/admin/sources', { body: { ...common, type, scan_interval_minutes: interval || null }, }), ); }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ['sources'] }); for (const key of ['library-status', 'library-errors', 'analysis-errors']) { void queryClient.invalidateQueries({ queryKey: [key] }); } onClose(); }, }); const err = save.error; const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options', 'excluded_dirs']; const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null; const submit = (e: FormEvent) => { e.preventDefault(); save.mutate(); }; return ( } >
{!source && (
{t('sources.type')}
)} {type === 'local' ? ( ) : ( <> )}