Files
pipu-music/apps/web/src/pages/admin/SourcesPage.tsx
T
mrleo1nidandCursor 73ad61e7a5
ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
feat(genius): load song metadata, lyrics, and artwork
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:23:54 +03:00

362 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, 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, 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 && <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>
);
}