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
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:
1 parent
fb20258489
commit
73ad61e7a5
34 files changed
+2214
-303
No files matched your search
@@ -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();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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); }
|
||||
@@ -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') {
|
||||
|
||||
Reference in new issue
Block a user