diff --git a/api/openapi.yaml b/api/openapi.yaml index 6fe3b84..059c8af 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -2687,6 +2687,8 @@ components: type: object required: [id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, scan_interval_minutes, accepts_uploads, write_template, status, status_message, last_scan_at, created_at, scan] properties: + excluded_dirs: + $ref: "#/components/schemas/ExcludedDirs" id: type: string format: uuid @@ -2747,6 +2749,8 @@ components: type: object required: [name, type] properties: + excluded_dirs: + $ref: "#/components/schemas/ExcludedDirs" name: type: string maxLength: 100 @@ -2774,6 +2778,8 @@ components: SourcePatch: type: object properties: + excluded_dirs: + $ref: "#/components/schemas/ExcludedDirs" name: type: string maxLength: 100 @@ -2802,6 +2808,14 @@ components: description: | Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid` с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`. + ExcludedDirs: + type: array + maxItems: 100 + description: Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan. + items: + type: string + minLength: 1 + maxLength: 1000 SourceTestResult: type: object required: [ok, message, audio_files] diff --git a/apps/web/src/pages/admin/SourceDialog.test.tsx b/apps/web/src/pages/admin/SourceDialog.test.tsx new file mode 100644 index 0000000..34a87ed --- /dev/null +++ b/apps/web/src/pages/admin/SourceDialog.test.tsx @@ -0,0 +1,63 @@ +import type { components } from '@pipu/api-client'; +import { AuthSession } from '@pipu/core'; +import { createI18n } from '@pipu/i18n'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { afterEach, expect, it, vi } from 'vitest'; +import { SessionProvider } from '../../app/session'; +import { mobileFixtures } from '../../test/mobileFixtures'; +import { SourceDialog } from './SourcesPage'; + +afterEach(cleanup); +type Source = components['schemas']['Source']; +const base = (mobileFixtures['/api/v1/admin/sources'] as { items: Source[] }).items[0]!; + +async function fixture(source: Source | null = { ...base, excluded_dirs: ['Downloads_PumpEars'] }, reject = false) { + const bodies: Record[] = []; + const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => { + const req = new Request(input, init); + if (req.method === 'PATCH' || req.method === 'POST') { + const body = await req.json() as Record; + bodies.push(body); + return new Response(JSON.stringify(reject + ? { code: 'validation.excluded_dirs_invalid', errors: [{ field: 'excluded_dirs', code: 'validation.excluded_dirs_invalid' }] } + : { ...base, ...body }), { status: reject ? 422 : 200, headers: { 'Content-Type': 'application/json' } }); + } + return new Response(JSON.stringify({ path: '', dirs: [] }), { headers: { 'Content-Type': 'application/json' } }); + } }); + const i18n = await createI18n('ru'); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const close = vi.fn(); + render( + + ); + return { bodies, close, user: userEvent.setup() }; +} + +it('loads and saves one excluded path per line', async () => { + const { bodies, close, user } = await fixture(); + const input = screen.getByLabelText('Не сканировать папки'); + expect(input).toHaveValue('Downloads_PumpEars'); + await user.type(input, '\nArtist/Samples\n'); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(bodies[0]?.excluded_dirs).toEqual(['Downloads_PumpEars', 'Artist/Samples']); +}); + +it('clears the list explicitly so excluded folders can be scanned again', async () => { + const { bodies, close, user } = await fixture(); + await user.clear(screen.getByLabelText('Не сканировать папки')); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(bodies[0]?.excluded_dirs).toEqual([]); +}); + +it('keeps invalid paths in the editor and displays the server validation', async () => { + const { user, close } = await fixture({ ...base, excluded_dirs: ['../outside'] }, true); + await user.click(screen.getByRole('button', { name: 'Сохранить' })); + await waitFor(() => expect(screen.getByLabelText('Не сканировать папки')).toHaveAttribute('aria-invalid', 'true')); + expect(screen.getByLabelText('Не сканировать папки')).toHaveValue('../outside'); + expect(close).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/pages/admin/SourcesPage.tsx b/apps/web/src/pages/admin/SourcesPage.tsx index 259c8e2..f71f520 100644 --- a/apps/web/src/pages/admin/SourcesPage.tsx +++ b/apps/web/src/pages/admin/SourcesPage.tsx @@ -2,7 +2,7 @@ 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 { useState, type FormEvent } from 'react'; +import { useId, useState, type FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { useSession, useUser } from '../../app/session'; @@ -120,6 +120,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) {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} )} @@ -185,7 +186,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) const INTERVALS = [0, 60, 360, 1440]; -function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) { +export function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) { const { t } = useTranslation(['admin', 'common']); const session = useSession(); const queryClient = useQueryClient(); @@ -199,12 +200,15 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: () 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() }), @@ -224,11 +228,14 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: () }, 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']; + 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) => { @@ -297,6 +304,18 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: () /> )} +
+ +