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>
218 lines
8.5 KiB
TypeScript
218 lines
8.5 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { ApiError, unwrap } from '@pipu/core';
|
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
|
import { Link } from '@tanstack/react-router';
|
|
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession } from '../../app/session';
|
|
import { useErrorText } from '../../shared/lib/errors';
|
|
import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui/icons';
|
|
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'];
|
|
|
|
/** Примеры из docs/library-and-storage.md — частые раскладки папок. */
|
|
const EXAMPLES = [
|
|
String.raw`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
|
|
String.raw`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
|
|
String.raw`^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$`,
|
|
];
|
|
|
|
/** Ошибка компиляции из 422: номер шаблона и причина. */
|
|
function patternError(err: unknown): { index: number; reason: string } | null {
|
|
if (err instanceof ApiError && err.code === 'validation.pattern_invalid') {
|
|
return { index: Number(err.params.index), reason: String(err.params.reason) };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function PatternsPage({ id }: { id: string }) {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const errorText = useErrorText();
|
|
const source = useQuery({
|
|
queryKey: ['source', id],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
|
|
});
|
|
const saved = useQuery({
|
|
queryKey: ['patterns', id],
|
|
queryFn: async () =>
|
|
unwrap(await session.client.GET('/api/v1/admin/sources/{id}/patterns', { params: { path: { id } } })).patterns,
|
|
});
|
|
const [rows, setRows] = useState<Pattern[]>([]);
|
|
useEffect(() => {
|
|
if (saved.data) setRows(saved.data);
|
|
}, [saved.data]);
|
|
|
|
const preview = useMutation({
|
|
mutationFn: async () =>
|
|
unwrap(
|
|
await session.client.POST('/api/v1/admin/sources/{id}/patterns/preview', {
|
|
params: { path: { id } },
|
|
body: { patterns: rows, sample: 50 },
|
|
}),
|
|
),
|
|
});
|
|
const save = useMutation({
|
|
mutationFn: async () =>
|
|
unwrap(
|
|
await session.client.PUT('/api/v1/admin/sources/{id}/patterns', {
|
|
params: { path: { id } },
|
|
body: { patterns: rows },
|
|
}),
|
|
),
|
|
onSuccess: (r) => setRows(r.patterns),
|
|
});
|
|
|
|
const err = save.error ?? preview.error;
|
|
const perr = patternError(err);
|
|
const update = (i: number, p: Partial<Pattern>) => {
|
|
save.reset();
|
|
setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...p } : r)));
|
|
};
|
|
const move = (i: number, d: -1 | 1) =>
|
|
setRows((rs) => {
|
|
const next = [...rs];
|
|
[next[i], next[i + d]] = [next[i + d]!, next[i]!];
|
|
return next;
|
|
});
|
|
|
|
return (
|
|
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}>
|
|
<div className={s.head}>
|
|
<div>
|
|
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
|
|
<p className={s.subtitle}>
|
|
<Link to="/admin/sources">{t('sources.title')}</Link>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<section className={s.section}>
|
|
<p className={s.note}>{t('patterns.intro')}</p>
|
|
<p className={l.groups}>{t('patterns.groups')}</p>
|
|
{rows.map((r, i) => (
|
|
<div key={i} className={l.patternRow}>
|
|
<span className={l.patternNum}>{i + 1}</span>
|
|
<Field
|
|
label={t('patterns.regex', { n: i + 1 })}
|
|
value={r.regex}
|
|
onChange={(v) => update(i, { regex: v })}
|
|
maxLength={1000}
|
|
spellCheck={false}
|
|
autoCapitalize="none"
|
|
mono
|
|
error={perr?.index === i ? t('patterns.error_at', { n: i + 1, reason: perr.reason }) : undefined}
|
|
/>
|
|
<div className={l.patternTools} style={{ marginTop: 22 }}>
|
|
<label className={l.check}>
|
|
<input type="checkbox" checked={r.is_enabled} onChange={(e) => update(i, { is_enabled: e.target.checked })} />
|
|
{t('patterns.enabled')}
|
|
</label>
|
|
<Button variant="ghost" aria-label={t('patterns.up')} disabled={i === 0} onClick={() => move(i, -1)}>
|
|
<Icon src={arrowUpIcon} size={18} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
aria-label={t('patterns.down')}
|
|
disabled={i === rows.length - 1}
|
|
onClick={() => move(i, 1)}
|
|
>
|
|
<Icon src={arrowDownIcon} size={18} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
aria-label={t('patterns.remove')}
|
|
onClick={() => setRows((rs) => rs.filter((_, k) => k !== i))}
|
|
>
|
|
<Icon src={deleteIcon} size={18} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
<div className={s.toolbar}>
|
|
<Button icon={addIcon} onClick={() => setRows((rs) => [...rs, { regex: '', is_enabled: true }])}>
|
|
{t('patterns.add')}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
onClick={() => setRows((rs) => [...rs, ...EXAMPLES.map((regex) => ({ regex, is_enabled: true }))])}
|
|
>
|
|
{t('patterns.examples')}
|
|
</Button>
|
|
<span style={{ flex: 1 }} />
|
|
<Button busy={preview.isPending} onClick={() => preview.mutate()}>
|
|
{t('patterns.preview')}
|
|
</Button>
|
|
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
|
|
{t('patterns.save')}
|
|
</Button>
|
|
</div>
|
|
{save.isSuccess && <span className={s.ok}>{t('patterns.saved')}</span>}
|
|
<FormError>{err && !perr ? errorText(err) : null}</FormError>
|
|
</section>
|
|
{preview.data && <PreviewTable preview={preview.data} />}
|
|
{source.data && <WriteTemplateSection source={source.data} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PreviewTable({ preview }: { preview: Preview }) {
|
|
const { t } = useTranslation('admin');
|
|
const fields = ['artist', 'album', 'title'] as const;
|
|
return (
|
|
<>
|
|
<p className={s.note}>{t('patterns.unmatched', { count: preview.unmatched, total: preview.items.length })}</p>
|
|
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
|
|
<table className={s.table}>
|
|
<thead>
|
|
<tr>
|
|
<th>{t('patterns.col_path')}</th>
|
|
<th>{t('patterns.col_pattern')}</th>
|
|
<th>{t('patterns.col_result')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{preview.items.map((it) => (
|
|
<tr key={it.rel_path}>
|
|
<td className={l.match}>{it.rel_path}</td>
|
|
<td className={cx(l.match, it.pattern_index === null && l.noMatch)}>
|
|
{it.skipped
|
|
? t('patterns.skipped')
|
|
: it.pattern_index === null
|
|
? t('patterns.no_match')
|
|
: `#${it.pattern_index + 1}`}
|
|
</td>
|
|
<td>
|
|
<div className={l.result}>
|
|
{fields.map((f) => {
|
|
const fromPath = f in it.path_fields;
|
|
const fromTags = (f === 'artist' ? 'artist' : f) in it.tags;
|
|
return (
|
|
<span key={f}>
|
|
<b>{it.result[f]}</b>{' '}
|
|
{!fromPath && fromTags && <span className={l.fromTags}>{t('patterns.from_tags')}</span>}
|
|
</span>
|
|
);
|
|
})}
|
|
<span className={l.fromTags}>
|
|
{[it.result.year, it.result.track && `#${it.result.track}`, it.result.genres.join(', ')]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
</span>
|
|
{it.error && <span className={l.scanError}>{it.error}</span>}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|