Files
pipu-music/apps/web/src/pages/admin/SourcesPage.tsx
T
mrleo1nid f1528de948
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
feat(admin): transfer user accounts and show local cover coverage
2026-10-23 02:17:06 +03:00

395 lines
16 KiB
TypeScript

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<Source | 'new' | null>(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 (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('sources.title')}</h1>
<p className={s.subtitle}>{t('sources.subtitle')}</p>
</div>
<Button variant="primary" icon={addIcon} onClick={() => setEditing('new')}>
{t('sources.add')}
</Button>
</div>
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
{user.role === 'admin' && <GeniusPanel />}
{sources.isSuccess && sources.data.length === 0 && (
<EmptyState icon={folderIcon} action={<Button variant="primary" onClick={() => setEditing('new')}>{t('sources.add')}</Button>}>
{t('sources.empty')}
</EmptyState>
)}
<div className={l.list}>
{sources.data?.map((src) => (
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
))}
</div>
{editing && <SourceDialog source={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />}
</div>
);
}
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<TestResult | null>(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 (
<article className={l.card} aria-label={source.name}>
<div className={l.cardHead}>
<div className={l.cardTitle}>
{source.name}
<span className={l.typeBadge}>{t(`sources.type_${source.type}`)}</span>
<StatusBadge status={source.status} />
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
</div>
<span className={l.location}>{location(source)}</span>
{!!source.excluded_dirs?.length && <span className={s.note}>{t('sources.excluded_count', { count: source.excluded_dirs.length })}</span>}
{source.status === 'error' && source.status_message && (
<span className={l.message}>{source.status_message}</span>
)}
</div>
<div className={l.cardActions}>
<Button
variant="primary"
busy={scan.isPending}
disabled={running || source.status !== 'mounted'}
onClick={() => scan.mutate(false)}
>
{t('sources.scan')}
</Button>
<Button busy={check.isPending} onClick={() => check.mutate()}>
{t('sources.test')}
</Button>
<Button onClick={() => void navigate({ to: '/admin/sources/$id/patterns', params: { id } })}>
{t('sources.patterns')}
</Button>
<Menu label={t('users.actions', { username: source.name })} trigger={<Icon src={moreIcon} />} items={menu} />
</div>
<div className={l.cardFoot}>
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
{covers.data && <span className={s.note} role="status">
{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 })}`}
</span>}
<FormError>{covers.error ? errorText(covers.error) : null}</FormError>
{test && (
<span className={`${l.test} ${test.ok ? l.testOk : l.testFail}`} role="status">
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
</span>
)}
{test && test.write_ok !== null && test.write_ok !== undefined && (
<span className={`${l.test} ${test.write_ok ? l.testOk : l.testFail}`} role="status">
{test.write_ok ? t('write_template.write_ok') : t('write_template.write_fail', { message: test.write_message ?? '' })}
</span>
)}
<FormError>{error ? errorText(error) : null}</FormError>
</div>
{confirmDelete && (
<Dialog
title={t('sources.delete')}
onClose={() => setConfirmDelete(false)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
{t('common:form.cancel')}
</Button>
<Button
variant="danger"
onClick={() => {
setConfirmDelete(false);
remove.mutate();
}}
>
{t('users.confirm')}
</Button>
</>
}
>
<p className={s.note}>{t('sources.delete_confirm', { name: source.name })}</p>
</Dialog>
)}
</article>
);
}
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 (
<Dialog
title={t(source ? 'sources.form_title_edit' : 'sources.form_title_new')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button type="submit" form="source-form" variant="primary" busy={save.isPending} disabled={!name.trim()}>
{t('sources.save')}
</Button>
</>
}
>
<form id="source-form" onSubmit={submit} noValidate className={s.sessions}>
<Field label={t('sources.name')} value={name} onChange={setName} maxLength={100} error={fieldError(err, 'name')} />
{!source && (
<div className={l.field}>
<span className={l.label}>{t('sources.type')}</span>
<Segmented
label={t('sources.type')}
value={type}
onChange={setType}
options={[
{ value: 'local', label: t('sources.type_local') },
{ value: 'nfs', label: t('sources.type_nfs') },
]}
/>
</div>
)}
{type === 'local' ? (
<FolderPicker value={localPath} onChange={setLocalPath} error={fieldError(err, 'local_path')} />
) : (
<>
<Field
label={t('sources.nfs_server')}
value={server}
onChange={setServer}
hint={t('sources.nfs_server_hint')}
error={fieldError(err, 'nfs_server')}
mono
/>
<Field
label={t('sources.nfs_export')}
value={exportPath}
onChange={setExportPath}
hint={t('sources.nfs_export_hint')}
error={fieldError(err, 'nfs_export')}
mono
/>
<Field
label={t('sources.nfs_options')}
value={options}
onChange={setOptions}
hint={t('sources.nfs_options_hint')}
error={fieldError(err, 'nfs_options')}
mono
/>
</>
)}
<div className={l.field}>
<label htmlFor={excludedDirsId} className={l.label}>{t('sources.excluded_dirs')}</label>
<textarea id={excludedDirsId} className={l.excludedDirs} rows={5}
value={excludedDirs} onChange={(event) => setExcludedDirs(event.target.value)}
disabled={source?.scan?.running ?? false}
placeholder="Downloads_PumpEars"
aria-invalid={!!fieldError(err, 'excluded_dirs')}
aria-describedby={`${excludedDirsId}-hint`} />
<span id={`${excludedDirsId}-hint`} className={s.note}>
{fieldError(err, 'excluded_dirs') ?? t(source?.scan?.running ? 'sources.excluded_running' : 'sources.excluded_hint')}
</span>
</div>
<div className={l.field}>
<span className={l.label}>{t('sources.tag_priority')}</span>
<Segmented
label={t('sources.tag_priority')}
value={priority}
onChange={setPriority}
options={[
{ value: 'tags_first', label: t('sources.tags_first') },
{ value: 'path_first', label: t('sources.path_first') },
]}
/>
</div>
<label className={l.field}>
<span className={l.label}>{t('sources.interval')}</span>
<select className={s.select} value={interval} onChange={(e) => setIntervalMin(Number(e.target.value))}>
{[...new Set([...INTERVALS, interval])].map((m) => (
<option key={m} value={m}>
{m === 0 ? t('sources.interval_off') : t('sources.interval_hours', { count: Math.round(m / 60) })}
</option>
))}
</select>
</label>
<FormError>{general}</FormError>
</form>
</Dialog>
);
}
/** Выбор подпапки LIBRARY_HOST: хлебные крошки и список вложенных папок. */
function FolderPicker({ value, onChange, error }: { value: string; onChange: (v: string) => void; error?: string }) {
const { t } = useTranslation('admin');
const session = useSession();
const dirs = useQuery({
queryKey: ['host-dirs', value],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/admin/library/host-dirs', { params: { query: { path: value } } })),
});
const parts = value ? value.split('/') : [];
return (
<div className={l.field}>
<span className={l.label}>{t('sources.folder')}</span>
<div className={l.picker}>
<div className={l.crumbs}>
<button type="button" className={l.dir} onClick={() => onChange('')}>
LIBRARY_HOST
</button>
{parts.map((p, i) => (
<span key={i} className={l.crumbs}>
<Icon src={chevronRightIcon} size={14} />
<button type="button" className={l.dir} onClick={() => onChange(parts.slice(0, i + 1).join('/'))}>
{p}
</button>
</span>
))}
</div>
{dirs.data?.dirs.map((d) => (
<button key={d} type="button" className={l.dir} onClick={() => onChange(value ? `${value}/${d}` : d)}>
<Icon src={folderIcon} size={18} className={l.dirIcon} />
{d}
</button>
))}
</div>
<span className={s.note}>{error ?? (value ? value : t('sources.folder_root'))}</span>
</div>
);
}