From 17b6dd2041e0019739fc66e1351bf92a9c210ab5 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:05:11 +0300 Subject: [PATCH] fix(web): clarify Genius processing progress and queue actions --- apps/web/src/pages/admin/GeniusPanel.test.tsx | 67 +++++++++++++++++++ apps/web/src/pages/admin/GeniusPanel.tsx | 15 ++++- apps/web/src/pages/genius.module.css | 2 + packages/i18n/locales/en/common.json | 9 ++- packages/i18n/locales/ru/common.json | 9 ++- 5 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/pages/admin/GeniusPanel.test.tsx diff --git a/apps/web/src/pages/admin/GeniusPanel.test.tsx b/apps/web/src/pages/admin/GeniusPanel.test.tsx new file mode 100644 index 0000000..a16a314 --- /dev/null +++ b/apps/web/src/pages/admin/GeniusPanel.test.tsx @@ -0,0 +1,67 @@ +import { AuthSession } from '@pipu/core'; +import { createI18n } from '@pipu/i18n'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { cleanup, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { I18nextProvider } from 'react-i18next'; +import { afterEach, expect, it } from 'vitest'; +import { SessionProvider } from '../../app/session'; +import { GeniusPanel } from './GeniusPanel'; + +afterEach(cleanup); + +const initial = { + configured: true, paused: false, running: true, + total: 100, pending: 80, matched: 17, lyrics: 15, annotations: 5, images: 17, + unmatched: 2, errors: 1, last_error: '', cooldown_until: '2000-01-01T00:00:00Z', +}; + +async function fixture(overrides: Partial = {}) { + let data = { ...initial, ...overrides }; + const actions: string[] = []; + const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (url, init) => { + const request = new Request(url, init); + if (request.method === 'POST') { + const { action } = await request.json() as { action: string }; + actions.push(action); + data = { ...data, paused: action === 'pause' }; + } + return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } }); + } }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } }); + client.setQueryData(['genius-stats'], data); + const i18n = await createI18n('ru'); + render( + + ); + return { actions, user: userEvent.setup() }; +} + +it.each<[Partial, string]>([ + [{}, 'Получение данных из Genius…'], + [{ running: false }, 'Очередь ожидает обработки'], + [{ paused: true }, 'Обработка приостановлена'], + [{ cooldown_until: '2099-01-01T00:00:00Z' }, 'Ожидание после ограничения Genius'], + [{ running: false, pending: 0 }, 'Ожидание новых треков'], +])('shows queue state and progress for %j', async (overrides, status) => { + await fixture(overrides); + expect(screen.getByRole('status')).toHaveTextContent(status); + const progress = screen.getByRole('progressbar', { name: 'Прогресс обработки Genius' }); + expect(progress).toHaveAttribute('max', '100'); + expect(progress).toHaveAttribute('value', String(100 - (overrides.pending ?? 80))); +}); + +it('pauses and resumes the existing queue without requesting a retry', async () => { + const { user, actions } = await fixture(); + await user.click(screen.getByRole('button', { name: 'Приостановить обработку' })); + await user.click(await screen.findByRole('button', { name: 'Продолжить обработку' })); + expect(actions).toEqual(['pause', 'start']); + expect(screen.getByRole('progressbar')).toHaveAttribute('value', '20'); +}); + +it('explains the retry scope and sends the retry action', async () => { + const { user, actions } = await fixture(); + expect(screen.getByText(/Повтор добавит в очередь только/)).toBeVisible(); + await user.click(screen.getByRole('button', { name: 'Добавить проблемные треки в очередь' })); + expect(actions).toEqual(['retry']); +}); diff --git a/apps/web/src/pages/admin/GeniusPanel.tsx b/apps/web/src/pages/admin/GeniusPanel.tsx index 16b2dd1..49bef43 100644 --- a/apps/web/src/pages/admin/GeniusPanel.tsx +++ b/apps/web/src/pages/admin/GeniusPanel.tsx @@ -8,7 +8,7 @@ import s from '../page.module.css'; import g from '../genius.module.css'; export function GeniusPanel() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const session = useSession(); const queries = useQueryClient(); const errorText = useErrorText(); @@ -24,16 +24,23 @@ export function GeniusPanel() { }); const data = stats.data; const cooling = data && Date.parse(data.cooldown_until) > Date.now(); - const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting'); + const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : data.pending > 0 ? t('genius.queued') : t('genius.waiting'); + const processed = data ? Math.max(0, data.total - data.pending) : 0; + const formatNumber = (value: number) => value.toLocaleString(i18n.language); return

Genius

{state}

{t('genius.description')}

{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null} {data && <> {!data.configured &&

{t('genius.configure')}

} + {data.total > 0 &&
+ {t('genius.progress', { processed: formatNumber(processed), total: formatNumber(data.total) })} + +

{t('genius.progress_hint')}

+
}
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) => -
{t(`genius.${key}`)}
{data[key].toLocaleString()}
)} +
{t(`genius.${key}`)}
{formatNumber(data[key])}
)}
{data.last_error &&

{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}

} {cooling &&

{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}

} @@ -41,6 +48,8 @@ export function GeniusPanel() { +

{t('genius.controls_hint')}

+

{t('genius.retry_hint')}

}
; } diff --git a/apps/web/src/pages/genius.module.css b/apps/web/src/pages/genius.module.css index 706e906..c1a8517 100644 --- a/apps/web/src/pages/genius.module.css +++ b/apps/web/src/pages/genius.module.css @@ -2,6 +2,8 @@ .stats dt { color: var(--text2); font-size: 13px; } .stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; } .actions { display: flex; flex-wrap: wrap; gap: 12px; } +.progress { display: grid; gap: 8px; font-variant-numeric: tabular-nums; } +.progress progress { display: block; width: 100%; height: 8px; accent-color: var(--accent); } .lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; } .annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; } .annotation summary { cursor: pointer; line-height: 1.6; } diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index b1dfec5..51f4069 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -1,11 +1,16 @@ { "genius": { "description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.", - "loading": "Loading…", "disabled": "Not configured", "paused": "Paused", "cooldown": "Waiting after Genius rate limit", "running": "Processing", "waiting": "Waiting for new tracks", + "loading": "Loading…", "disabled": "Not configured", "paused": "Processing paused", "cooldown": "Waiting after Genius rate limit", "running": "Fetching data from Genius…", "queued": "Queue awaiting processing", "waiting": "Waiting for new tracks", + "progress": "Processed {{processed}} of {{total}} tracks", + "progress_label": "Genius processing progress", + "progress_hint": "Processed tracks include errors and unmatched tracks. Data refreshes every 5 seconds.", + "controls_hint": "The queue is processed automatically. Pausing saves progress; resuming continues with the remaining tracks.", + "retry_hint": "Retry queues only tracks with errors, incomplete data or no match, and resumes processing. Successfully processed tracks stay unchanged.", "configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.", "total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data", "last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than", - "start": "Resume", "pause": "Pause", "retry": "Retry errors and unmatched", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius" + "start": "Resume processing", "pause": "Pause processing", "retry": "Queue tracks needing attention", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius" }, "app": { "name": "pipu", diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index f5704da..db7019c 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -1,11 +1,16 @@ { "genius": { "description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.", - "loading": "Загрузка…", "disabled": "Не подключён", "paused": "Приостановлено", "cooldown": "Ожидание после ограничения Genius", "running": "Обработка", "waiting": "Ожидание новых треков", + "loading": "Загрузка…", "disabled": "Не подключён", "paused": "Обработка приостановлена", "cooldown": "Ожидание после ограничения Genius", "running": "Получение данных из Genius…", "queued": "Очередь ожидает обработки", "waiting": "Ожидание новых треков", + "progress": "Обработано {{processed}} из {{total}} треков", + "progress_label": "Прогресс обработки Genius", + "progress_hint": "В обработанные входят треки с ошибками и без совпадения. Данные обновляются каждые 5 секунд.", + "controls_hint": "Очередь обрабатывается автоматически. Пауза сохраняет прогресс; продолжение возобновляет обработку оставшихся треков.", + "retry_hint": "Повтор добавит в очередь только треки с ошибками, неполными данными и без совпадения, а также снимет паузу. Успешно обработанные треки останутся без изменений.", "configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.", "total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные", "last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше", - "start": "Продолжить", "pause": "Приостановить", "retry": "Повторить ошибки и ненайденные", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius" + "start": "Продолжить обработку", "pause": "Приостановить обработку", "retry": "Добавить проблемные треки в очередь", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius" }, "app": { "name": "pipu",