ci / api / api (push) Successful in 55s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / db / db (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
release / prune (старые файлы и прогоны) (push) Successful in 7s
203 lines
8.1 KiB
TypeScript
203 lines
8.1 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useId, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession, useUser } from '../../app/session';
|
|
import { useErrorText } from '../../shared/lib/errors';
|
|
import { refreshIcon } from '../../shared/ui/icons';
|
|
import { Button, FormError } from '../../shared/ui/ui';
|
|
import s from '../page.module.css';
|
|
import l from './library.module.css';
|
|
import { ScanLine, StatusBadge } from './ScanStatus';
|
|
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
|
|
|
|
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
|
|
export function LibraryPage() {
|
|
const { t } = useTranslation(['admin', 'common']);
|
|
const session = useSession();
|
|
const user = useUser();
|
|
const errorText = useErrorText();
|
|
const admin = user.role === 'admin';
|
|
const [showAnalysisErrors, setShowAnalysisErrors] = useState(false);
|
|
|
|
const status = useQuery({
|
|
queryKey: ['library-status'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/status')),
|
|
refetchInterval: (q) => (q.state.data?.sources.some((x) => x.scan?.running) ? 2000 : 15000),
|
|
});
|
|
const errors = useQuery({
|
|
queryKey: ['library-errors'],
|
|
enabled: admin,
|
|
refetchInterval: status.data?.sources.some((source) => source.scan?.running) ? 2000 : 15000,
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
|
|
});
|
|
const reindex = useMutation({
|
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
|
});
|
|
const queryClient = useQueryClient();
|
|
const retry = useMutation({
|
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
|
onSuccess: () => {
|
|
void queryClient.invalidateQueries({ queryKey: ['library-status'] });
|
|
void queryClient.invalidateQueries({ queryKey: ['analysis-errors'] });
|
|
},
|
|
});
|
|
const d = status.data;
|
|
const tiles = d
|
|
? ([
|
|
['artists', d.artists],
|
|
['albums', d.albums],
|
|
['tracks', d.tracks],
|
|
['files', d.files],
|
|
['missing', d.missing_files],
|
|
['errors', d.file_errors],
|
|
] as const)
|
|
: [];
|
|
|
|
return (
|
|
<div className={s.page}>
|
|
<div className={s.head}>
|
|
<div>
|
|
<h1>{t('library.title')}</h1>
|
|
<p className={s.subtitle}>{t('library.subtitle')}</p>
|
|
</div>
|
|
{admin && (
|
|
<Button icon={refreshIcon} busy={reindex.isPending} onClick={() => reindex.mutate()}>
|
|
{reindex.isSuccess ? t('library.reindex_started') : t('library.reindex')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<FormError>{status.error || reindex.error ? errorText(status.error ?? reindex.error) : null}</FormError>
|
|
<div className={l.tiles}>
|
|
{tiles.map(([k, v]) => (
|
|
<div key={k} className={l.tile}>
|
|
<span className={l.tileValue}>{v.toLocaleString()}</span>
|
|
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{d && (
|
|
<section className={s.section}>
|
|
<h2>{t('library.analysis')}</h2>
|
|
<ScanLineBar
|
|
done={d.analysis.done}
|
|
total={d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed}
|
|
label={t('library.analysis_progress', {
|
|
done: d.analysis.done,
|
|
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
|
})}
|
|
/>
|
|
{d.analysis.failed > 0 && (
|
|
<div className={l.retryRow}>
|
|
<span className={l.scanError}>
|
|
{t('library.analysis_failed', { count: d.analysis.failed })}
|
|
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
|
</span>
|
|
{admin && (
|
|
<div className={l.analysisActions}>
|
|
<Button onClick={() => setShowAnalysisErrors(true)}>{t('library.analysis_show_errors')}</Button>
|
|
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
|
{t('library.analysis_retry')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
|
{showAnalysisErrors && <AnalysisErrorsDialog onClose={() => setShowAnalysisErrors(false)} />}
|
|
</section>
|
|
)}
|
|
{d && (
|
|
<section className={s.section}>
|
|
<h2>{t('library.wave')}</h2>
|
|
<div className={l.tiles}>
|
|
{(
|
|
[
|
|
['wave_served', d.wave.served.toLocaleString()],
|
|
['wave_sessions', d.wave.sessions.toLocaleString()],
|
|
['wave_listen', pct(d.wave.listen_through_rate)],
|
|
['wave_early_skip', pct(d.wave.early_skip_rate)],
|
|
['wave_likes', d.wave.like_rate === null ? '—' : d.wave.like_rate.toFixed(1)],
|
|
] as const
|
|
).map(([k, v]) => (
|
|
<div key={k} className={l.tile}>
|
|
<span className={l.tileValue}>{v}</span>
|
|
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
{d.wave.by_model.length > 1 && (
|
|
<div className={s.tableWrap}>
|
|
<table className={s.table}>
|
|
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
|
|
<thead>
|
|
<tr>
|
|
<th scope="col">{t('library.wave_model')}</th>
|
|
<th scope="col">{t('library.wave_served')}</th>
|
|
<th scope="col">{t('library.wave_listen')}</th>
|
|
<th scope="col">{t('library.wave_early_skip')}</th>
|
|
<th scope="col">{t('library.wave_likes')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{d.wave.by_model.map((m) => (
|
|
<tr key={m.model_ver}>
|
|
<td>{m.model_ver || '—'}</td>
|
|
<td>{m.served.toLocaleString()}</td>
|
|
<td>{pct(m.listen_through_rate)}</td>
|
|
<td>{pct(m.early_skip_rate)}</td>
|
|
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)}
|
|
{d && d.sources.length > 0 && (
|
|
<section className={s.section}>
|
|
<h2>{t('library.sources')}</h2>
|
|
{d.sources.map((src) => (
|
|
<div key={src.id} className={l.cardFoot}>
|
|
<div className={l.cardTitle}>
|
|
{src.name} <StatusBadge status={src.status} />
|
|
</div>
|
|
<ScanLine scan={src.scan} lastScanAt={src.last_scan_at} />
|
|
</div>
|
|
))}
|
|
</section>
|
|
)}
|
|
{admin && errors.data && errors.data.items.length > 0 && (
|
|
<section className={s.section}>
|
|
<h2>{t('library.error_files')}</h2>
|
|
{errors.data.items.map((e) => (
|
|
<div key={e.file_id} className={l.cardFoot}>
|
|
<span className={l.location}>{e.rel_path}</span>
|
|
<span className={l.scanError}>{e.error}</span>
|
|
</div>
|
|
))}
|
|
</section>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%`);
|
|
|
|
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
|
|
const value = total > 0 ? Math.round((done / total) * 100) : 0;
|
|
const id = useId();
|
|
return (
|
|
<div className={l.scan}>
|
|
<div className={l.scanText} id={id}>
|
|
{label}
|
|
</div>
|
|
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
|
|
<span style={{ width: `${value}%` }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|