feat(genius): load song metadata, lyrics, and artwork
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>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-22 08:23:54 +03:00
1 parent fb20258489
commit 73ad61e7a5
34 files changed
+2214 -303

No files matched your search

+6 -1
View File
@@ -272,7 +272,7 @@ class Downloads {
if (track.album.cover_url) {
const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
}
const info = await FileSystem.getInfoAsync(this.file(id));
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
@@ -286,4 +286,9 @@ class Downloads {
}
}
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
export function sizedCover(url: string, size: number): string {
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
}
export const downloads = new Downloads();
+13 -10
View File
@@ -17,7 +17,7 @@ import TrackPlayer, {
} from 'react-native-track-player';
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave';
import { eventId } from './eventId';
@@ -224,15 +224,18 @@ class Controller {
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => ({
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
duration: t.duration_ms / 1000,
}));
return tracks.map((t) => {
const cover = absolute(this.server, t.album.cover_url);
return {
id: t.id,
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
title: t.title,
artist: t.artists.map((a) => a.name).join(', '),
album: t.album.title,
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
duration: t.duration_ms / 1000,
};
});
}
async playList(tracks: Track[], index: number, context: PlayContext) {
+2 -2
View File
@@ -20,7 +20,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads';
import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller';
import { radius, useColors, type Colors } from '../theme';
@@ -161,7 +161,7 @@ export function Cover({
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
const bg = album.cover_colors?.[0] ?? colors.surface3;
return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
+1
View File
@@ -28,6 +28,7 @@ function fallbackColors(id: string): string[] {
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string {
if (!url.includes('/api/v1/covers/')) return url;
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`;
+1 -1
View File
@@ -39,7 +39,7 @@ export function ArtistPage({ id }: { id: string }) {
return (
<div className={s.page}>
<header className={c.hero}>
<Cover album={{ id }} size={168} round />
<Cover album={{ id, cover_url: a?.image_url }} size={168} round />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
+36
View File
@@ -0,0 +1,36 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { FormError } from '../shared/ui/ui';
import s from './page.module.css';
import g from './genius.module.css';
export function TrackGenius({ id }: { id: string }) {
const session = useSession();
const { t } = useTranslation();
const errorText = useErrorText();
const query = useQuery({
queryKey: ['track-genius', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}/genius', { params: { path: { id } } })),
refetchInterval: (q) => ['pending', 'processing'].includes(q.state.data?.status ?? '') ? 10000 : false,
});
const data = query.data;
if (data?.status === 'disabled') return null;
return <section className={s.section}>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p>{t('genius.loading')}</p>}
{data?.description && <><h2>{t('genius.about')}</h2><p className={g.lyrics}>{data.description}</p></>}
<div className={s.head}><h2>{t('genius.lyrics')}</h2>{data?.source_url && <a href={data.source_url} target="_blank" rel="noopener noreferrer">Genius ↗</a>}</div>
{data && (data.lyrics ? <div className={g.lyrics}>{data.lyrics}</div> : <p className={s.subtitle}>{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}</p>)}
{!!data?.annotations.length && <>
<h2>{t('genius.explanations')}</h2>
{data.annotations.map((a, i) => <details className={g.annotation} key={`${a.url}-${i}`}>
<summary>{a.fragment}{a.verified && <span className={g.badge}>{t('genius.verified')}</span>}</summary>
<p className={g.lyrics}>{a.text}</p>
<p className={s.subtitle}>{a.authors}{a.url && <> · <a href={a.url} target="_blank" rel="noopener noreferrer">Genius ↗</a></>}</p>
</details>)}
</>}
</section>;
}
+2
View File
@@ -9,6 +9,7 @@ import { formatDuration } from '../shared/lib/format';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
import { TrackGenius } from './TrackGenius';
export function TrackPage({ id }: { id: string }) {
const { t } = useTranslation();
@@ -33,6 +34,7 @@ export function TrackPage({ id }: { id: string }) {
</div>
</header>
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
{tr && <TrackGenius id={id} />}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
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>;
}
+4 -1
View File
@@ -5,7 +5,8 @@ import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
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';
@@ -19,6 +20,7 @@ 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();
@@ -43,6 +45,7 @@ export function SourcesPage() {
</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) => (
+8
View File
@@ -0,0 +1,8 @@
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin: 0; }
.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; }
.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; }
.badge { display: inline-block; margin-left: 12px; font-size: 12px; color: var(--text2); }
+3
View File
@@ -302,6 +302,9 @@ export function fakeApi(initial: FakeUser[]) {
users.set(target.id, target);
return json(admin(target));
}
if (req.method === 'GET' && /^\/api\/v1\/tracks\/[^/]+\/genius$/.test(path)) {
return json({ status: 'disabled', lyrics: '', source_url: '', song_image_url: '', artist_image_url: '', description: '', annotations: [] });
}
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
if (trackPath && req.method === 'GET') {