ci / api / api (push) Successful in 33s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m11s
ci / ts / ts (packages + web) (push) Successful in 1m10s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 51s
- storage-agent: POST /internal/files copies a staged file into a source
(temp file, fsync, SHA-256 check, hard link so nothing is overwritten,
" (2)" suffix on conflict, owner PIPU_FILE_UID/GID) and
POST /internal/files/test. NFS sources that accept uploads are mounted
rw in an agent-only /library-rw and bind-mounted read-only for others;
local sources are written through /library-host-rw.
- meta: write templates ({track:02}, {disc:1-}, [optional]) with name
sanitising and length limits, suggestion from the source's regexes
and a reversibility check against 50 catalogue tracks.
- API and admin UI: accepts_uploads / write_template on sources,
write-template suggest and check endpoints, write test result.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
359 lines
14 KiB
TypeScript
359 lines
14 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 { useState, type FormEvent } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession } from '../../app/session';
|
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
|
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
|
|
import { Button, Dialog, 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 { 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>
|
|
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
|
|
<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 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.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} />
|
|
{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];
|
|
|
|
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 save = useMutation({
|
|
mutationFn: async () => {
|
|
const common = {
|
|
name: name.trim(),
|
|
tag_priority: priority,
|
|
...(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'] });
|
|
onClose();
|
|
},
|
|
});
|
|
const err = save.error;
|
|
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options'];
|
|
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}>
|
|
<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>
|
|
);
|
|
}
|