feat: storage writes via storage-agent, accepts_uploads and write_template (M5.5)
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
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>
This commit is contained in:
1 parent
9b50038bb4
commit
f66be2ba0a
27 files changed
+2422
-213
No files matched your search
@@ -11,6 +11,7 @@ import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui
|
||||
import { Button, cx, Field, FormError, Icon } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import l from './library.module.css';
|
||||
import { WriteTemplateSection } from './WriteTemplateSection';
|
||||
|
||||
type Pattern = components['schemas']['PathPattern'];
|
||||
type Preview = components['schemas']['PatternPreview'];
|
||||
@@ -155,6 +156,7 @@ export function PatternsPage({ id }: { id: string }) {
|
||||
<FormError>{err && !perr ? errorText(err) : null}</FormError>
|
||||
</section>
|
||||
{preview.data && <PreviewTable preview={preview.data} />}
|
||||
{source.data && <WriteTemplateSection source={source.data} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
{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 && (
|
||||
@@ -140,6 +141,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
|
||||
{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 && (
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||
import { Button, cx, Field, FormError } from '../../shared/ui/ui';
|
||||
import s from '../page.module.css';
|
||||
import l from './library.module.css';
|
||||
|
||||
type Source = components['schemas']['Source'];
|
||||
type Check = components['schemas']['WriteTemplateCheck'];
|
||||
|
||||
/**
|
||||
* Приём загрузок и шаблон пути новых файлов (docs/uploads.md#7): автоподбор по существующей структуре
|
||||
* и проверка обратимости регэкспами хранилища. Сохранить можно и с расхождениями — админ видит предупреждение.
|
||||
*/
|
||||
export function WriteTemplateSection({ source }: { source: Source }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const queryClient = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const id = source.id;
|
||||
const [accepts, setAccepts] = useState(source.accepts_uploads);
|
||||
const [template, setTemplate] = useState(source.write_template ?? '');
|
||||
const [check, setCheck] = useState<Check | null>(null);
|
||||
useEffect(() => {
|
||||
setAccepts(source.accepts_uploads);
|
||||
setTemplate(source.write_template ?? '');
|
||||
}, [source.accepts_uploads, source.write_template]);
|
||||
|
||||
const runCheck = useMutation({
|
||||
mutationFn: async (tpl: string) =>
|
||||
unwrap(
|
||||
await session.client.POST('/api/v1/admin/sources/{id}/write-template/check', {
|
||||
params: { path: { id } },
|
||||
body: { template: tpl },
|
||||
}),
|
||||
),
|
||||
onSuccess: setCheck,
|
||||
});
|
||||
const suggest = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/write-template/suggest', { params: { path: { id } } }))
|
||||
.template,
|
||||
onSuccess: (tpl) => {
|
||||
setTemplate(tpl);
|
||||
runCheck.mutate(tpl);
|
||||
},
|
||||
});
|
||||
const save = useMutation({
|
||||
mutationFn: async () =>
|
||||
unwrap(
|
||||
await session.client.PATCH('/api/v1/admin/sources/{id}', {
|
||||
params: { path: { id } },
|
||||
body: { accepts_uploads: accepts, write_template: template },
|
||||
}),
|
||||
),
|
||||
onSuccess: async (src) => {
|
||||
queryClient.setQueryData(['source', id], src);
|
||||
await queryClient.invalidateQueries({ queryKey: ['sources'] });
|
||||
},
|
||||
});
|
||||
|
||||
const err = save.error ?? suggest.error ?? runCheck.error;
|
||||
const tplError = fieldError(err, 'write_template') ?? fieldError(err, 'template');
|
||||
const dirty = accepts !== source.accepts_uploads || template !== (source.write_template ?? '');
|
||||
|
||||
return (
|
||||
<section className={s.section}>
|
||||
<h2>{t('write_template.title')}</h2>
|
||||
<p className={s.note}>{t('write_template.intro')}</p>
|
||||
<label className={l.checkRow}>
|
||||
<input type="checkbox" checked={accepts} onChange={(e) => setAccepts(e.target.checked)} />
|
||||
<span>{t('write_template.accepts')}</span>
|
||||
</label>
|
||||
{accepts && source.type === 'nfs' && <p className={s.note}>{t('write_template.nfs_rw_hint')}</p>}
|
||||
<Field
|
||||
label={t('write_template.template')}
|
||||
value={template}
|
||||
onChange={(v) => {
|
||||
setTemplate(v);
|
||||
setCheck(null);
|
||||
save.reset();
|
||||
}}
|
||||
hint={t('write_template.hint')}
|
||||
error={tplError}
|
||||
mono
|
||||
/>
|
||||
<div className={s.toolbar}>
|
||||
<Button busy={suggest.isPending} onClick={() => suggest.mutate()}>
|
||||
{t('write_template.suggest')}
|
||||
</Button>
|
||||
<Button busy={runCheck.isPending} disabled={!template.trim()} onClick={() => runCheck.mutate(template)}>
|
||||
{t('write_template.check')}
|
||||
</Button>
|
||||
<span style={{ flex: 1 }} />
|
||||
<Button variant="primary" busy={save.isPending} disabled={!dirty || (accepts && !template.trim())} onClick={() => save.mutate()}>
|
||||
{t('write_template.save')}
|
||||
</Button>
|
||||
</div>
|
||||
{accepts && !template.trim() && <p className={s.note}>{t('write_template.required')}</p>}
|
||||
{save.isSuccess && !dirty && <span className={s.ok}>{t('write_template.saved')}</span>}
|
||||
<FormError>{err && !tplError ? errorText(err) : null}</FormError>
|
||||
{check && <CheckResult check={check} />}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckResult({ check }: { check: Check }) {
|
||||
const { t } = useTranslation('admin');
|
||||
if (check.total === 0) return <p className={s.note}>{t('write_template.no_tracks')}</p>;
|
||||
return (
|
||||
<>
|
||||
<p className={cx(s.note, check.ok ? s.ok : l.scanError)} role="status">
|
||||
{t(check.ok ? 'write_template.check_ok' : 'write_template.check_warn', {
|
||||
round_trip: check.round_trip,
|
||||
same_path: check.same_path,
|
||||
total: check.total,
|
||||
})}
|
||||
</p>
|
||||
{check.mismatches.length > 0 && (
|
||||
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
|
||||
<table className={s.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t('write_template.col_real')}</th>
|
||||
<th>{t('write_template.col_rendered')}</th>
|
||||
<th>{t('write_template.col_problem')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{check.mismatches.map((m) => (
|
||||
<tr key={m.rel_path}>
|
||||
<td className={l.match}>{m.rel_path}</td>
|
||||
<td className={l.match}>{m.rendered ?? '—'}</td>
|
||||
<td>
|
||||
{m.error ??
|
||||
(m.fields.length
|
||||
? m.fields.map((f) => (f === 'pattern' ? t('write_template.no_pattern') : f)).join(', ')
|
||||
: t('write_template.other_path'))}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -299,3 +299,10 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.checkRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
Reference in new issue
Block a user