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
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>
47 lines
2.8 KiB
TypeScript
47 lines
2.8 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useSession } from '../../app/session';
|
|
import { useErrorText } from '../../shared/lib/errors';
|
|
import { Button, FormError } from '../../shared/ui/ui';
|
|
import s from '../page.module.css';
|
|
import g from '../genius.module.css';
|
|
|
|
export function GeniusPanel() {
|
|
const { t } = useTranslation();
|
|
const session = useSession();
|
|
const queries = useQueryClient();
|
|
const errorText = useErrorText();
|
|
const stats = useQuery({
|
|
queryKey: ['genius-stats'],
|
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/genius')),
|
|
refetchInterval: 5000,
|
|
});
|
|
const control = useMutation({
|
|
mutationFn: async (action: 'start' | 'pause' | 'retry') =>
|
|
unwrap(await session.client.POST('/api/v1/admin/genius', { body: { action } })),
|
|
onSuccess: (data) => { queries.setQueryData(['genius-stats'], data); },
|
|
});
|
|
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');
|
|
return <section className={s.section} aria-label="Genius">
|
|
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
|
|
<p className={s.subtitle}>{t('genius.description')}</p>
|
|
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
|
|
{data && <>
|
|
{!data.configured && <p>{t('genius.configure')}</p>}
|
|
<dl className={g.stats}>
|
|
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
|
|
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
|
|
</dl>
|
|
{data.last_error && <p role="alert">{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')}</p>}
|
|
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
|
|
<div className={g.actions}>
|
|
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
|
|
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
|
|
</div>
|
|
</>}
|
|
</section>;
|
|
}
|