From 73ad61e7a5f25215d21831ef68efec76158df3f1 Mon Sep 17 00:00:00 2001 From: mrleo1nid <43583385+mrleo1nid@users.noreply.github.com> Date: Thu, 22 Oct 2026 08:23:54 +0300 Subject: [PATCH] feat(genius): load song metadata, lyrics, and artwork 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 --- api/openapi.yaml | 86 ++ apps/mobile/src/offline/downloads.ts | 7 +- apps/mobile/src/player/controller.ts | 23 +- apps/mobile/src/ui/kit.tsx | 4 +- apps/web/src/entities/catalog.tsx | 1 + apps/web/src/pages/ArtistPage.tsx | 2 +- apps/web/src/pages/TrackGenius.tsx | 36 + apps/web/src/pages/TrackPage.tsx | 2 + apps/web/src/pages/admin/GeniusPanel.tsx | 46 + apps/web/src/pages/admin/SourcesPage.tsx | 5 +- apps/web/src/pages/genius.module.css | 8 + apps/web/src/test/fakeApi.ts | 3 + deploy/.env.example | 4 + docs/README.md | 1 + docs/genius.md | 83 ++ packages/api-client/src/generated/schema.ts | 136 +++ packages/i18n/locales/en/common.json | 8 + packages/i18n/locales/ru/common.json | 8 + services/api/cmd/api/main.go | 5 + services/api/cmd/genius/main.go | 39 + services/api/go.mod | 1 + services/api/internal/api/gen/api.gen.go | 850 ++++++++++++------ services/api/internal/genius/client.go | 176 ++++ services/api/internal/genius/client_test.go | 92 ++ services/api/internal/genius/handler.go | 98 ++ services/api/internal/genius/lyrics.go | 174 ++++ services/api/internal/genius/metadata.go | 181 ++++ services/api/internal/genius/metadata_test.go | 97 ++ services/api/internal/genius/service.go | 272 ++++++ services/api/internal/library/catalog.go | 28 +- services/api/internal/library/handler.go | 5 +- .../api/internal/platform/config/config.go | 4 +- services/api/internal/server/router.go | 6 +- services/api/migrations/00014_genius.sql | 26 + 34 files changed, 2214 insertions(+), 303 deletions(-) create mode 100644 apps/web/src/pages/TrackGenius.tsx create mode 100644 apps/web/src/pages/admin/GeniusPanel.tsx create mode 100644 apps/web/src/pages/genius.module.css create mode 100644 docs/genius.md create mode 100644 services/api/cmd/genius/main.go create mode 100644 services/api/internal/genius/client.go create mode 100644 services/api/internal/genius/client_test.go create mode 100644 services/api/internal/genius/handler.go create mode 100644 services/api/internal/genius/lyrics.go create mode 100644 services/api/internal/genius/metadata.go create mode 100644 services/api/internal/genius/metadata_test.go create mode 100644 services/api/internal/genius/service.go create mode 100644 services/api/migrations/00014_genius.sql diff --git a/api/openapi.yaml b/api/openapi.yaml index 4fdb1b4..1cedb78 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -22,6 +22,51 @@ tags: security: - bearerAuth: [] paths: + /api/v1/admin/genius: + get: + operationId: getGeniusStats + tags: [admin] + responses: + "200": + description: Persistent enrichment statistics (admin only) + content: + application/json: + schema: + $ref: "#/components/schemas/GeniusStats" + post: + operationId: controlGenius + tags: [admin] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [action] + properties: + action: + type: string + enum: [start, pause, retry] + responses: + "200": + description: Updated statistics + content: + application/json: + schema: + $ref: "#/components/schemas/GeniusStats" + /api/v1/tracks/{id}/genius: + parameters: + - $ref: "#/components/parameters/EntityId" + get: + operationId: getTrackGenius + tags: [catalog] + responses: + "200": + description: Cached lyrics and annotations; never makes an upstream request + content: + application/json: + schema: + $ref: "#/components/schemas/GeniusContent" /healthz: get: operationId: getHealthz @@ -3016,10 +3061,51 @@ components: description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости allOf: - $ref: "#/components/schemas/TrackFeatures" + GeniusStats: + type: object + required: [configured, paused, running, total, pending, matched, lyrics, annotations, images, unmatched, errors, last_error, cooldown_until] + properties: + configured: { type: boolean } + paused: { type: boolean } + running: { type: boolean } + total: { type: integer } + pending: { type: integer } + matched: { type: integer } + lyrics: { type: integer } + annotations: { type: integer } + images: { type: integer } + unmatched: { type: integer } + errors: { type: integer } + last_error: { type: string } + cooldown_until: { type: string, format: date-time } + GeniusContent: + type: object + required: [status, lyrics, source_url, song_image_url, artist_image_url, description, annotations] + properties: + status: { type: string } + lyrics: { type: string } + source_url: { type: string } + song_image_url: { type: string } + artist_image_url: { type: string } + description: { type: string } + annotations: + type: array + items: + $ref: "#/components/schemas/GeniusAnnotation" + GeniusAnnotation: + type: object + required: [fragment, text, url, verified, authors] + properties: + fragment: { type: string } + text: { type: string } + url: { type: string } + verified: { type: boolean } + authors: { type: string } Artist: type: object required: [id, name, album_count, track_count] properties: + image_url: { type: string } id: type: string format: uuid diff --git a/apps/mobile/src/offline/downloads.ts b/apps/mobile/src/offline/downloads.ts index 1456acb..218b6ae 100644 --- a/apps/mobile/src/offline/downloads.ts +++ b/apps/mobile/src/offline/downloads.ts @@ -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(); diff --git a/apps/mobile/src/player/controller.ts b/apps/mobile/src/player/controller.ts index 510beb8..a46bc8a 100644 --- a/apps/mobile/src/player/controller.ts +++ b/apps/mobile/src/player/controller.ts @@ -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 { 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) { diff --git a/apps/mobile/src/ui/kit.tsx b/apps/mobile/src/ui/kit.tsx index 2a6b035..c584c8e 100644 --- a/apps/mobile/src/ui/kit.tsx +++ b/apps/mobile/src/ui/kit.tsx @@ -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 ( diff --git a/apps/web/src/entities/catalog.tsx b/apps/web/src/entities/catalog.tsx index 5a021d4..b608902 100644 --- a/apps/web/src/entities/catalog.tsx +++ b/apps/web/src/entities/catalog.tsx @@ -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}`; diff --git a/apps/web/src/pages/ArtistPage.tsx b/apps/web/src/pages/ArtistPage.tsx index 8d00171..752b7fa 100644 --- a/apps/web/src/pages/ArtistPage.tsx +++ b/apps/web/src/pages/ArtistPage.tsx @@ -39,7 +39,7 @@ export function ArtistPage({ id }: { id: string }) { return (
- +
{t('catalog.artist')}

{a?.name ?? '…'}

diff --git a/apps/web/src/pages/TrackGenius.tsx b/apps/web/src/pages/TrackGenius.tsx new file mode 100644 index 0000000..c87bc9f --- /dev/null +++ b/apps/web/src/pages/TrackGenius.tsx @@ -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
+ {query.error ? errorText(query.error) : null} + {query.isPending &&

{t('genius.loading')}

} + {data?.description && <>

{t('genius.about')}

{data.description}

} +

{t('genius.lyrics')}

{data?.source_url && Genius ↗}
+ {data && (data.lyrics ?
{data.lyrics}
:

{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}

)} + {!!data?.annotations.length && <> +

{t('genius.explanations')}

+ {data.annotations.map((a, i) =>
+ {a.fragment}{a.verified && {t('genius.verified')}} +

{a.text}

+

{a.authors}{a.url && <> · Genius ↗}

+
)} + } +
; +} diff --git a/apps/web/src/pages/TrackPage.tsx b/apps/web/src/pages/TrackPage.tsx index 7376336..f54dc93 100644 --- a/apps/web/src/pages/TrackPage.tsx +++ b/apps/web/src/pages/TrackPage.tsx @@ -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 }) {
{tr && } + {tr && }
); } diff --git a/apps/web/src/pages/admin/GeniusPanel.tsx b/apps/web/src/pages/admin/GeniusPanel.tsx new file mode 100644 index 0000000..16b2dd1 --- /dev/null +++ b/apps/web/src/pages/admin/GeniusPanel.tsx @@ -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
+

Genius

{state}
+

{t('genius.description')}

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

{t('genius.configure')}

} +
+ {(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) => +
{t(`genius.${key}`)}
{data[key].toLocaleString()}
)} +
+ {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()}

} +
+ + +
+ } +
; +} diff --git a/apps/web/src/pages/admin/SourcesPage.tsx b/apps/web/src/pages/admin/SourcesPage.tsx index 316b1b1..d66c42a 100644 --- a/apps/web/src/pages/admin/SourcesPage.tsx +++ b/apps/web/src/pages/admin/SourcesPage.tsx @@ -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() { {sources.error ? errorText(sources.error) : null} + {user.role === 'admin' && } {sources.isSuccess && sources.data.length === 0 &&
{t('sources.empty')}
}
{sources.data?.map((src) => ( diff --git a/apps/web/src/pages/genius.module.css b/apps/web/src/pages/genius.module.css new file mode 100644 index 0000000..706e906 --- /dev/null +++ b/apps/web/src/pages/genius.module.css @@ -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); } diff --git a/apps/web/src/test/fakeApi.ts b/apps/web/src/test/fakeApi.ts index 1a41487..daacf2b 100644 --- a/apps/web/src/test/fakeApi.ts +++ b/apps/web/src/test/fakeApi.ts @@ -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') { diff --git a/deploy/.env.example b/deploy/.env.example index 989ad14..c8c08fe 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -44,6 +44,10 @@ LOG_LEVEL=info SUPPORTED_LOCALES=ru,en MIN_ANDROID_VERSION_CODE=0 +# Genius: empty token disables enrichment. Queue and controls: Admin → Sources. +GENIUS_ACCESS_TOKEN= +GENIUS_REQUEST_INTERVAL=2s + # --- Самообновление Android (M5) --- # Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music). # Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000 diff --git a/docs/README.md b/docs/README.md index 6d81c1f..436b871 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android | [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск | | [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище | | [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн | +| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда | | [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android | | [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» | | [web.md](web.md) | Веб-клиент | diff --git a/docs/genius.md b/docs/genius.md new file mode 100644 index 0000000..8aad19e --- /dev/null +++ b/docs/genius.md @@ -0,0 +1,83 @@ +# Genius: поиск метаданных и получение текста + +Реализован Go-провайдер `services/api/internal/genius` и отдельная команда +`services/api/cmd/genius`. Поиск через API возвращает кандидатов, ссылки на изображения +и исполнителя. Текст выбранной песни извлекается из HTML страницы: из контейнеров +`data-lyrics-container` либо из `songPage.lyricsData.body.html` во встроенном состоянии. +JavaScript не исполняется. Используется HTML-парсер `golang.org/x/net/html`. + +Это проверяемый первый этап: команда работает отдельно от сервера. Автоматическое +дополнение каталога, хранение текстов в БД, аннотации и отображение в плеере пока +не подключены. Поэтому переменная ниже пока нужна именно процессу команды, +а не Docker Compose или веб-клиенту. + +## Почему Go + +Основной API Pipu написан на Go. Небольшой провайдер можно подключить к нему без +отдельного Python-сервиса. LyricsGenius удобен для Python, но собственный адаптер +позволяет явно контролировать выбор версии песни, сетевые ограничения и ошибки. +HTML Genius не является стабильным API: при изменении разметки парсер потребует обновления. + +## Получение токена + +1. Войти или зарегистрироваться на https://genius.com. +2. Открыть https://genius.com/api-clients и создать новый API Client. +3. Указать название, например `Pipu Music`, и URL своего приложения. Если форма + требует Redirect URI, указать URL под своим контролем; для чтения с Client Access + Token OAuth-переходы и обработчик callback в Pipu не нужны. +4. В созданном клиенте нажать **Generate Access Token**. +5. Сохранить **Client Access Token** в переменной `GENIUS_ACCESS_TOKEN` процесса. + Client ID и Client Secret вместо него не подходят. + +Токен нужен для поиска через `api.genius.com`. Получение HTML по уже известному +URL не требует токена и не отправляет заголовок Authorization. Не помещать токен +в git, клиентские переменные `VITE_*`, логи или переписку. + +Источник: https://lyricsgenius.readthedocs.io/en/master/setup.html. + +## Проверка в PowerShell 7 + +Из корня репозитория: + +```powershell +Set-Location services/api +$env:GENIUS_ACCESS_TOKEN = Read-Host 'Genius Client Access Token' -MaskInput +go run ./cmd/genius -search 'Исполнитель Название песни' +``` + +Выбрать нужного исполнителя и версию песни из результатов. Команда намеренно +не считает первый результат правильным. Затем подставить возвращённый `url`: + +```powershell +go run ./cmd/genius -url 'https://genius.com/Artist-song-lyrics' +Remove-Item Env:GENIUS_ACCESS_TOKEN +``` + +URL во втором примере — шаблон. Результат — JSON с `text`, `source_url` и временем +получения `fetched_at`. Это обычный текст без временной синхронизации. + +## Ограничения и дальнейшее подключение + +- HTTP 401, 403, 404, 429 и другие ошибки возвращаются явно; `HTTPError` сохраняет + `Retry-After`. Автоматических повторов и обхода блокировок нет. +- Таймаут запроса — 15 секунд, предел ответа — 8 MiB. Разрешён только HTTPS + на Genius; перенаправления на другой хост запрещены. +- Отсутствие распознаваемого текста возвращает `ErrLyricsUnavailable`; HTML + блокировки или навигация не становятся текстом песни. +- Перед интеграцией в каталог нужны кэш результатов, лимит частоты запросов, + сохранение выбранного Genius ID и обработка временных ошибок без потери старого текста. +- Токен API не предоставляет лицензию на тексты. Документация LyricsGenius + предупреждает, что парсинг противоречит условиям Genius; отображление и хранение + в публичном сервисе требуют отдельной проверки разрешений: + https://lyricsgenius.readthedocs.io/en/latest/how_it_works.html. + +## Тесты + +```powershell +go test ./internal/genius ./cmd/genius +``` + +Тесты используют синтетические тексты и подмену HTTP: переносы, HTML-сущности, +служебные элементы, встроенное состояние, ошибки HTTP, проверка URL и +отсутствие токена при запросе страницы. Реальный доступ к Genius проверяется +отдельно из сети сервера с собственным токеном. diff --git a/packages/api-client/src/generated/schema.ts b/packages/api-client/src/generated/schema.ts index be4604a..73a4521 100644 --- a/packages/api-client/src/generated/schema.ts +++ b/packages/api-client/src/generated/schema.ts @@ -4,6 +4,40 @@ */ export interface paths { + "/api/v1/admin/genius": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations["getGeniusStats"]; + put?: never; + post: operations["controlGenius"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/tracks/{id}/genius": { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + get: operations["getTrackGenius"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/healthz": { parameters: { query?: never; @@ -2068,7 +2102,40 @@ export interface components { /** @description Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости */ features: components["schemas"]["TrackFeatures"] | null; }; + GeniusStats: { + configured: boolean; + paused: boolean; + running: boolean; + total: number; + pending: number; + matched: number; + lyrics: number; + annotations: number; + images: number; + unmatched: number; + errors: number; + last_error: string; + /** Format: date-time */ + cooldown_until: string; + }; + GeniusContent: { + status: string; + lyrics: string; + source_url: string; + song_image_url: string; + artist_image_url: string; + description: string; + annotations: components["schemas"]["GeniusAnnotation"][]; + }; + GeniusAnnotation: { + fragment: string; + text: string; + url: string; + verified: boolean; + authors: string; + }; Artist: { + image_url?: string; /** Format: uuid */ id: string; name: string; @@ -2380,6 +2447,75 @@ export interface components { } export type $defs = Record; export interface operations { + getGeniusStats: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Persistent enrichment statistics (admin only) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeniusStats"]; + }; + }; + }; + }; + controlGenius: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @enum {string} */ + action: "start" | "pause" | "retry"; + }; + }; + }; + responses: { + /** @description Updated statistics */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeniusStats"]; + }; + }; + }; + }; + getTrackGenius: { + parameters: { + query?: never; + header?: never; + path: { + id: components["parameters"]["EntityId"]; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cached lyrics and annotations; never makes an upstream request */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GeniusContent"]; + }; + }; + }; + }; getHealthz: { parameters: { query?: never; diff --git a/packages/i18n/locales/en/common.json b/packages/i18n/locales/en/common.json index 94977bf..14c96ac 100644 --- a/packages/i18n/locales/en/common.json +++ b/packages/i18n/locales/en/common.json @@ -1,4 +1,12 @@ { + "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", + "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" + }, "app": { "name": "pipu", "tagline": "Your music from your own server" diff --git a/packages/i18n/locales/ru/common.json b/packages/i18n/locales/ru/common.json index c979a3f..57637b9 100644 --- a/packages/i18n/locales/ru/common.json +++ b/packages/i18n/locales/ru/common.json @@ -1,4 +1,12 @@ { + "genius": { + "description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.", + "loading": "Загрузка…", "disabled": "Не подключён", "paused": "Приостановлено", "cooldown": "Ожидание после ограничения Genius", "running": "Обработка", "waiting": "Ожидание новых треков", + "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" + }, "app": { "name": "pipu", "tagline": "Ваша музыка с вашего сервера" diff --git a/services/api/cmd/api/main.go b/services/api/cmd/api/main.go index 63faaad..5d0e928 100644 --- a/services/api/cmd/api/main.go +++ b/services/api/cmd/api/main.go @@ -21,6 +21,7 @@ import ( "pipu/api/internal/appupdate" "pipu/api/internal/auth" "pipu/api/internal/buildinfo" + "pipu/api/internal/genius" "pipu/api/internal/library" "pipu/api/internal/platform/config" "pipu/api/internal/platform/db" @@ -105,6 +106,8 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis. }) libStore := library.NewStore(pool) + geniusSvc := genius.NewService(pool, cfg.GeniusAccessToken, cfg.GeniusRequestInterval, log) + go geniusSvc.Run(ctx) board := library.NewProgressBoard() scanner := library.NewScanner(libStore, prober, board, log) agent := library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken) @@ -132,6 +135,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis. covers.Run(ctx) indexer.Trigger() fingerprints.Trigger(ctx) + geniusSvc.Wake() } go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска fingerprints.Trigger(ctx) @@ -168,6 +172,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis. Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log) wh.Warm = pb.Prewarm return server.NewRouter(server.Deps{ + Genius: genius.NewHandler(geniusSvc), Log: log, System: sys, Auth: auth.NewHandler(authSvc, cfg.CookieSecure), diff --git a/services/api/cmd/genius/main.go b/services/api/cmd/genius/main.go new file mode 100644 index 0000000..03fad57 --- /dev/null +++ b/services/api/cmd/genius/main.go @@ -0,0 +1,39 @@ +// Command genius searches metadata or retrieves lyrics from an explicitly selected Genius URL. +package main + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "os" + + "pipu/api/internal/genius" +) + +func main() { + query := flag.String("search", "", "search candidates by artist and title (requires GENIUS_ACCESS_TOKEN)") + page := flag.String("url", "", "retrieve lyrics from a selected https://genius.com/...-lyrics URL") + flag.Parse() + if (*query == "") == (*page == "") || flag.NArg() != 0 { + fmt.Fprintln(os.Stderr, "provide exactly one of -search or -url") + os.Exit(2) + } + client := genius.New(os.Getenv("GENIUS_ACCESS_TOKEN")) + var result any + var err error + if *query != "" { + result, err = client.Search(context.Background(), *query) + } else { + result, err = client.FetchLyrics(context.Background(), *page) + } + if err == nil { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + err = encoder.Encode(result) + } + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/services/api/go.mod b/services/api/go.mod index c8581ca..ee667f3 100644 --- a/services/api/go.mod +++ b/services/api/go.mod @@ -17,6 +17,7 @@ require ( github.com/redis/go-redis/v9 v9.10.0 github.com/stretchr/testify v1.10.0 golang.org/x/crypto v0.38.0 + golang.org/x/net v0.40.0 golang.org/x/sys v0.33.0 golang.org/x/text v0.25.0 ) diff --git a/services/api/internal/api/gen/api.gen.go b/services/api/internal/api/gen/api.gen.go index 5612981..9b24135 100644 --- a/services/api/internal/api/gen/api.gen.go +++ b/services/api/internal/api/gen/api.gen.go @@ -225,6 +225,13 @@ const ( WaveStartSeedTypeTrack WaveStartSeedType = "track" ) +// Defines values for ControlGeniusJSONBodyAction. +const ( + Pause ControlGeniusJSONBodyAction = "pause" + Retry ControlGeniusJSONBodyAction = "retry" + Start ControlGeniusJSONBodyAction = "start" +) + // Defines values for GetCoverParamsSize. const ( N1200 GetCoverParamsSize = 1200 @@ -351,6 +358,7 @@ type AppClients struct { type Artist struct { AlbumCount int `json:"album_count"` Id openapi_types.UUID `json:"id"` + ImageUrl *string `json:"image_url,omitempty"` Name string `json:"name"` TrackCount int `json:"track_count"` } @@ -454,6 +462,43 @@ type FieldError struct { Field string `json:"field"` } +// GeniusAnnotation defines model for GeniusAnnotation. +type GeniusAnnotation struct { + Authors string `json:"authors"` + Fragment string `json:"fragment"` + Text string `json:"text"` + Url string `json:"url"` + Verified bool `json:"verified"` +} + +// GeniusContent defines model for GeniusContent. +type GeniusContent struct { + Annotations []GeniusAnnotation `json:"annotations"` + ArtistImageUrl string `json:"artist_image_url"` + Description string `json:"description"` + Lyrics string `json:"lyrics"` + SongImageUrl string `json:"song_image_url"` + SourceUrl string `json:"source_url"` + Status string `json:"status"` +} + +// GeniusStats defines model for GeniusStats. +type GeniusStats struct { + Annotations int `json:"annotations"` + Configured bool `json:"configured"` + CooldownUntil time.Time `json:"cooldown_until"` + Errors int `json:"errors"` + Images int `json:"images"` + LastError string `json:"last_error"` + Lyrics int `json:"lyrics"` + Matched int `json:"matched"` + Paused bool `json:"paused"` + Pending int `json:"pending"` + Running bool `json:"running"` + Total int `json:"total"` + Unmatched int `json:"unmatched"` +} + // HealthStatus defines model for HealthStatus. type HealthStatus struct { Status HealthStatusStatus `json:"status"` @@ -1399,6 +1444,14 @@ type ListAuditParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } +// ControlGeniusJSONBody defines parameters for ControlGenius. +type ControlGeniusJSONBody struct { + Action ControlGeniusJSONBodyAction `json:"action"` +} + +// ControlGeniusJSONBodyAction defines parameters for ControlGenius. +type ControlGeniusJSONBodyAction string + // ListLibraryErrorsParams defines parameters for ListLibraryErrors. type ListLibraryErrorsParams struct { Limit *Limit `form:"limit,omitempty" json:"limit,omitempty"` @@ -1522,6 +1575,9 @@ type ListMyUploadsParams struct { Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` } +// ControlGeniusJSONRequestBody defines body for ControlGenius for application/json ContentType. +type ControlGeniusJSONRequestBody ControlGeniusJSONBody + // UpdateUploadSettingsJSONRequestBody defines body for UpdateUploadSettings for application/json ContentType. type UpdateUploadSettingsJSONRequestBody = UploadSettingsPatch @@ -1614,6 +1670,12 @@ type ServerInterface interface { // Журнал действий (admin), новые сверху // (GET /api/v1/admin/audit) ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams) + + // (GET /api/v1/admin/genius) + GetGeniusStats(w http.ResponseWriter, r *http.Request) + + // (POST /api/v1/admin/genius) + ControlGenius(w http.ResponseWriter, r *http.Request) // Вернуть в очередь треки, которые не удалось проанализировать (admin) // (POST /api/v1/admin/library/analysis/retry) RetryAnalysis(w http.ResponseWriter, r *http.Request) @@ -1830,6 +1892,9 @@ type ServerInterface interface { // Трек // (GET /api/v1/tracks/{id}) GetTrack(w http.ResponseWriter, r *http.Request, id EntityId) + + // (GET /api/v1/tracks/{id}/genius) + GetTrackGenius(w http.ResponseWriter, r *http.Request, id EntityId) // Создать пачку и зарезервировать место (moderator, admin) // (POST /api/v1/uploads) CreateUploadBatch(w http.ResponseWriter, r *http.Request) @@ -1884,6 +1949,16 @@ func (_ Unimplemented) ListAudit(w http.ResponseWriter, r *http.Request, params w.WriteHeader(http.StatusNotImplemented) } +// (GET /api/v1/admin/genius) +func (_ Unimplemented) GetGeniusStats(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// (POST /api/v1/admin/genius) +func (_ Unimplemented) ControlGenius(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Вернуть в очередь треки, которые не удалось проанализировать (admin) // (POST /api/v1/admin/library/analysis/retry) func (_ Unimplemented) RetryAnalysis(w http.ResponseWriter, r *http.Request) { @@ -2311,6 +2386,11 @@ func (_ Unimplemented) GetTrack(w http.ResponseWriter, r *http.Request, id Entit w.WriteHeader(http.StatusNotImplemented) } +// (GET /api/v1/tracks/{id}/genius) +func (_ Unimplemented) GetTrackGenius(w http.ResponseWriter, r *http.Request, id EntityId) { + w.WriteHeader(http.StatusNotImplemented) +} + // Создать пачку и зарезервировать место (moderator, admin) // (POST /api/v1/uploads) func (_ Unimplemented) CreateUploadBatch(w http.ResponseWriter, r *http.Request) { @@ -2445,6 +2525,46 @@ func (siw *ServerInterfaceWrapper) ListAudit(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } +// GetGeniusStats operation middleware +func (siw *ServerInterfaceWrapper) GetGeniusStats(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetGeniusStats(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ControlGenius operation middleware +func (siw *ServerInterfaceWrapper) ControlGenius(w http.ResponseWriter, r *http.Request) { + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ControlGenius(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // RetryAnalysis operation middleware func (siw *ServerInterfaceWrapper) RetryAnalysis(w http.ResponseWriter, r *http.Request) { @@ -4609,6 +4729,37 @@ func (siw *ServerInterfaceWrapper) GetTrack(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } +// GetTrackGenius operation middleware +func (siw *ServerInterfaceWrapper) GetTrackGenius(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "id" ------------- + var id EntityId + + err = runtime.BindStyledParameterWithOptions("simple", "id", chi.URLParam(r, "id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err}) + return + } + + ctx := r.Context() + + ctx = context.WithValue(ctx, BearerAuthScopes, []string{}) + + r = r.WithContext(ctx) + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetTrackGenius(w, r, id) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // CreateUploadBatch operation middleware func (siw *ServerInterfaceWrapper) CreateUploadBatch(w http.ResponseWriter, r *http.Request) { @@ -5066,6 +5217,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/admin/audit", wrapper.ListAudit) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/admin/genius", wrapper.GetGeniusStats) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/api/v1/admin/genius", wrapper.ControlGenius) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/admin/library/analysis/retry", wrapper.RetryAnalysis) }) @@ -5282,6 +5439,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/api/v1/tracks/{id}", wrapper.GetTrack) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/api/v1/tracks/{id}/genius", wrapper.GetTrackGenius) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/api/v1/uploads", wrapper.CreateUploadBatch) }) @@ -5359,6 +5519,39 @@ func (response ListAuditdefaultApplicationProblemPlusJSONResponse) VisitListAudi return json.NewEncoder(w).Encode(response.Body) } +type GetGeniusStatsRequestObject struct { +} + +type GetGeniusStatsResponseObject interface { + VisitGetGeniusStatsResponse(w http.ResponseWriter) error +} + +type GetGeniusStats200JSONResponse GeniusStats + +func (response GetGeniusStats200JSONResponse) VisitGetGeniusStatsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ControlGeniusRequestObject struct { + Body *ControlGeniusJSONRequestBody +} + +type ControlGeniusResponseObject interface { + VisitControlGeniusResponse(w http.ResponseWriter) error +} + +type ControlGenius200JSONResponse GeniusStats + +func (response ControlGenius200JSONResponse) VisitControlGeniusResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + type RetryAnalysisRequestObject struct { } @@ -7491,6 +7684,23 @@ func (response GetTrackdefaultApplicationProblemPlusJSONResponse) VisitGetTrackR return json.NewEncoder(w).Encode(response.Body) } +type GetTrackGeniusRequestObject struct { + Id EntityId `json:"id"` +} + +type GetTrackGeniusResponseObject interface { + VisitGetTrackGeniusResponse(w http.ResponseWriter) error +} + +type GetTrackGenius200JSONResponse GeniusContent + +func (response GetTrackGenius200JSONResponse) VisitGetTrackGeniusResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + type CreateUploadBatchRequestObject struct { Body *CreateUploadBatchJSONRequestBody } @@ -7882,6 +8092,12 @@ type StrictServerInterface interface { // Журнал действий (admin), новые сверху // (GET /api/v1/admin/audit) ListAudit(ctx context.Context, request ListAuditRequestObject) (ListAuditResponseObject, error) + + // (GET /api/v1/admin/genius) + GetGeniusStats(ctx context.Context, request GetGeniusStatsRequestObject) (GetGeniusStatsResponseObject, error) + + // (POST /api/v1/admin/genius) + ControlGenius(ctx context.Context, request ControlGeniusRequestObject) (ControlGeniusResponseObject, error) // Вернуть в очередь треки, которые не удалось проанализировать (admin) // (POST /api/v1/admin/library/analysis/retry) RetryAnalysis(ctx context.Context, request RetryAnalysisRequestObject) (RetryAnalysisResponseObject, error) @@ -8098,6 +8314,9 @@ type StrictServerInterface interface { // Трек // (GET /api/v1/tracks/{id}) GetTrack(ctx context.Context, request GetTrackRequestObject) (GetTrackResponseObject, error) + + // (GET /api/v1/tracks/{id}/genius) + GetTrackGenius(ctx context.Context, request GetTrackGeniusRequestObject) (GetTrackGeniusResponseObject, error) // Создать пачку и зарезервировать место (moderator, admin) // (POST /api/v1/uploads) CreateUploadBatch(ctx context.Context, request CreateUploadBatchRequestObject) (CreateUploadBatchResponseObject, error) @@ -8197,6 +8416,61 @@ func (sh *strictHandler) ListAudit(w http.ResponseWriter, r *http.Request, param } } +// GetGeniusStats operation middleware +func (sh *strictHandler) GetGeniusStats(w http.ResponseWriter, r *http.Request) { + var request GetGeniusStatsRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetGeniusStats(ctx, request.(GetGeniusStatsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetGeniusStats") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetGeniusStatsResponseObject); ok { + if err := validResponse.VisitGetGeniusStatsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ControlGenius operation middleware +func (sh *strictHandler) ControlGenius(w http.ResponseWriter, r *http.Request) { + var request ControlGeniusRequestObject + + var body ControlGeniusJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ControlGenius(ctx, request.(ControlGeniusRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ControlGenius") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ControlGeniusResponseObject); ok { + if err := validResponse.VisitControlGeniusResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // RetryAnalysis operation middleware func (sh *strictHandler) RetryAnalysis(w http.ResponseWriter, r *http.Request) { var request RetryAnalysisRequestObject @@ -10184,6 +10458,32 @@ func (sh *strictHandler) GetTrack(w http.ResponseWriter, r *http.Request, id Ent } } +// GetTrackGenius operation middleware +func (sh *strictHandler) GetTrackGenius(w http.ResponseWriter, r *http.Request, id EntityId) { + var request GetTrackGeniusRequestObject + + request.Id = id + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetTrackGenius(ctx, request.(GetTrackGeniusRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetTrackGenius") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetTrackGeniusResponseObject); ok { + if err := validResponse.VisitGetTrackGeniusResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // CreateUploadBatch operation middleware func (sh *strictHandler) CreateUploadBatch(w http.ResponseWriter, r *http.Request) { var request CreateUploadBatchRequestObject @@ -10568,278 +10868,284 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "AQA=", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/services/api/internal/genius/client.go b/services/api/internal/genius/client.go new file mode 100644 index 0000000..fa484d7 --- /dev/null +++ b/services/api/internal/genius/client.go @@ -0,0 +1,176 @@ +// Package genius retrieves song metadata from the API and plain lyrics from public HTML. +package genius + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +var ErrLyricsUnavailable = errors.New("genius: lyrics absent or page markup unsupported") + +// HTTPError preserves throttling information without exposing response bodies or credentials. +type HTTPError struct { + StatusCode int + RetryAfter string +} + +func (e *HTTPError) Error() string { return fmt.Sprintf("genius: HTTP %d", e.StatusCode) } + +type Artist struct { + ID int64 `json:"id"` + Name string `json:"name"` + ImageURL string `json:"image_url"` +} + +type Song struct { + ID int64 `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + ImageURL string `json:"song_art_image_url"` + ThumbURL string `json:"song_art_image_thumbnail_url"` + HeaderURL string `json:"header_image_url"` + Artist Artist `json:"primary_artist"` + Description struct { + Plain string `json:"plain"` + } `json:"description"` + Album struct { + Name string `json:"name"` + ImageURL string `json:"cover_art_url"` + ThumbURL string `json:"cover_art_thumbnail_url"` + } `json:"album"` +} + +type Lyrics struct { + Text string `json:"text"` + SourceURL string `json:"source_url"` + FetchedAt time.Time `json:"fetched_at"` +} + +type Client struct { + token string + http *http.Client + mu sync.Mutex + next time.Time + interval time.Duration +} + +// SetInterval must be called before sharing the client with workers. +func (c *Client) SetInterval(d time.Duration) { c.interval = max(d, time.Second) } + +func New(token string) *Client { + return &Client{token: strings.TrimSpace(token), http: &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("genius: too many redirects") + } + // Do not forward credentials or fetch content outside the initial host. + if req.URL.Scheme != "https" || req.URL.Host != via[0].URL.Host || req.URL.User != nil { + return errors.New("genius: refused redirect") + } + return nil + }, + }} +} + +// Search returns candidates; callers must select a version rather than assume the first hit matches. +func (c *Client) Search(ctx context.Context, query string) ([]Song, error) { + if c.token == "" { + return nil, errors.New("genius: GENIUS_ACCESS_TOKEN is required for search") + } + if strings.TrimSpace(query) == "" { + return nil, errors.New("genius: empty search query") + } + body, err := c.get(ctx, "https://api.genius.com/search?q="+url.QueryEscape(query), true) + if err != nil { + return nil, err + } + var result struct { + Response struct { + Hits []struct { + Type string `json:"type"` + Result Song `json:"result"` + } `json:"hits"` + } `json:"response"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("genius: decode search: %w", err) + } + songs := make([]Song, 0, len(result.Response.Hits)) + for _, hit := range result.Response.Hits { + if hit.Type == "song" { + songs = append(songs, hit.Result) + } + } + return songs, nil +} + +// FetchLyrics accepts a selected public song URL. The token is never sent to the HTML site. +func (c *Client) FetchLyrics(ctx context.Context, rawURL string) (Lyrics, error) { + u, err := url.Parse(rawURL) + if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil || !strings.HasSuffix(u.Path, "-lyrics") { + return Lyrics{}, errors.New("genius: expected https://genius.com/-lyrics") + } + u.RawQuery, u.Fragment = "", "" + body, err := c.get(ctx, u.String(), false) + if err != nil { + return Lyrics{}, err + } + text, err := parseLyrics(body) + if err != nil { + return Lyrics{}, err + } + return Lyrics{Text: text, SourceURL: u.String(), FetchedAt: time.Now().UTC()}, nil +} + +func (c *Client) get(ctx context.Context, endpoint string, api bool) ([]byte, error) { + c.mu.Lock() + wait := time.Until(c.next) + c.next = time.Now().Add(max(wait, 0) + c.interval) + c.mu.Unlock() + if wait > 0 { + timer := time.NewTimer(wait) + defer timer.Stop() + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-timer.C: + } + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "PipuMusic/1.0") + if api { + req.Header.Set("Authorization", "Bearer "+c.token) + req.Header.Set("Accept", "application/json") + } else { + req.Header.Set("Accept", "text/html") + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("genius: request failed: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, &HTTPError{StatusCode: resp.StatusCode, RetryAfter: resp.Header.Get("Retry-After")} + } + const maxBody = 8 << 20 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody+1)) + if err != nil { + return nil, err + } + if len(body) > maxBody { + return nil, errors.New("genius: response exceeds 8 MiB") + } + return body, nil +} diff --git a/services/api/internal/genius/client_test.go b/services/api/internal/genius/client_test.go new file mode 100644 index 0000000..9a87a02 --- /dev/null +++ b/services/api/internal/genius/client_test.go @@ -0,0 +1,92 @@ +package genius + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +type transportFunc func(*http.Request) (*http.Response, error) + +func (f transportFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +func TestLyricsFormatting(t *testing.T) { + text, err := parseLyrics([]byte(`
Not lyrics
Title
[Куплет 1]
Привет & мир

Next lineEmbed
[Припев]
Again
`)) + require.NoError(t, err) + require.Equal(t, "[Куплет 1]\nПривет & мир\n\nNext line\n\n[Припев]\nAgain", text) +} + +func TestUnavailable(t *testing.T) { + for _, page := range []string{`

Just a moment

`, `
`, ``} { + _, err := parseLyrics([]byte(page)) + require.ErrorIs(t, err, ErrLyricsUnavailable) + } +} + +func TestEmbeddedLyrics(t *testing.T) { + text, err := parseLyrics([]byte(``)) + require.NoError(t, err) + require.Equal(t, "[Verse]\nIt's \"fine\" & good\nПривет", text) +} + +func TestURLValidation(t *testing.T) { + c := New("secret") + c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) { t.Fatal("unexpected network call"); return nil, nil }) + for _, u := range []string{"https://evil.test/song-lyrics", "http://genius.com/song-lyrics", "https://genius.com:443/song-lyrics", "https://secret@genius.com/song-lyrics", "https://genius.com/artists/test"} { + _, err := c.FetchLyrics(context.Background(), u) + require.Error(t, err) + } +} + +func TestRequests(t *testing.T) { + c := New("secret") + c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { + body := `
Synthetic test text
` + if r.URL.Host == "api.genius.com" { + require.Equal(t, "Bearer secret", r.Header.Get("Authorization")) + require.Equal(t, "artist & title", r.URL.Query().Get("q")) + body = `{"response":{"hits":[{"type":"song","result":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","primary_artist":{"name":"Artist"}}}]}}` + } else { + require.Empty(t, r.Header.Get("Authorization")) + require.Empty(t, r.URL.RawQuery) + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }) + songs, err := c.Search(context.Background(), "artist & title") + require.NoError(t, err) + require.Len(t, songs, 1) + lyrics, err := c.FetchLyrics(context.Background(), songs[0].URL+"?tracking=1") + require.NoError(t, err) + require.Equal(t, "Synthetic test text", lyrics.Text) + require.Equal(t, songs[0].URL, lyrics.SourceURL) + require.False(t, lyrics.FetchedAt.IsZero()) +} + +func TestHTTPFailures(t *testing.T) { + for _, status := range []int{401, 403, 404, 429, 500} { + c := New("secret") + c.http.Transport = transportFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: status, Header: http.Header{"Retry-After": {"60"}}, Body: io.NopCloser(strings.NewReader("private upstream body"))}, nil + }) + _, err := c.Search(context.Background(), "test") + var httpErr *HTTPError + require.True(t, errors.As(err, &httpErr)) + require.Equal(t, status, httpErr.StatusCode) + require.Equal(t, "60", httpErr.RetryAfter) + require.NotContains(t, err.Error(), "private") + } +} + +func TestRedirects(t *testing.T) { + c := New("secret") + first, _ := http.NewRequest("GET", "https://api.genius.com/search", nil) + for _, endpoint := range []string{"http://api.genius.com/search", "https://genius.com/song-lyrics", "https://evil.test/"} { + req, _ := http.NewRequest("GET", endpoint, nil) + require.Error(t, c.http.CheckRedirect(req, []*http.Request{first})) + } +} diff --git a/services/api/internal/genius/handler.go b/services/api/internal/genius/handler.go new file mode 100644 index 0000000..9d1fe56 --- /dev/null +++ b/services/api/internal/genius/handler.go @@ -0,0 +1,98 @@ +package genius + +import ( + "context" + "encoding/json" + "errors" + + "github.com/jackc/pgx/v5" + "pipu/api/internal/api/gen" + "pipu/api/internal/auth" + "pipu/api/internal/platform/apperr" +) + +type Handler struct{ svc *Service } + +func NewHandler(s *Service) *Handler { return &Handler{svc: s} } + +func requireAdmin(ctx context.Context) error { + p, ok := auth.PrincipalFrom(ctx) + if !ok { + return apperr.Unauthorized("auth.unauthorized", "authentication required") + } + if p.Role != auth.RoleAdmin { + return apperr.Forbidden("auth.forbidden", "administrator required") + } + return nil +} + +func (h *Handler) GetGeniusStats(ctx context.Context, _ gen.GetGeniusStatsRequestObject) (gen.GetGeniusStatsResponseObject, error) { + if err := requireAdmin(ctx); err != nil { + return nil, err + } + stats, err := h.svc.Stats(ctx) + return gen.GetGeniusStats200JSONResponse(stats), err +} + +func (h *Handler) ControlGenius(ctx context.Context, req gen.ControlGeniusRequestObject) (gen.ControlGeniusResponseObject, error) { + if err := requireAdmin(ctx); err != nil { + return nil, err + } + if !h.svc.configured { + return nil, apperr.Conflict("genius.disabled", "configure GENIUS_ACCESS_TOKEN in deploy/.env") + } + if req.Body == nil { + return nil, apperr.Validation("action", "validation.required", "action required", nil) + } + tx, err := h.svc.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + switch string(req.Body.Action) { + case "pause": + _, err = tx.Exec(ctx, `UPDATE genius_state SET paused=true`) + case "start": + _, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`) + case "retry": + _, err = tx.Exec(ctx, `UPDATE genius_tracks SET status='pending',next_attempt=now(),last_error='' WHERE status IN ('error','partial','unmatched') AND locked_until<=now()`) + if err == nil { + _, err = tx.Exec(ctx, `UPDATE genius_state SET paused=false,last_error=''`) + } + default: + return nil, apperr.Validation("action", "validation.invalid", "unknown action", nil) + } + if err != nil { + return nil, err + } + if err = tx.Commit(ctx); err != nil { + return nil, err + } + h.svc.Wake() + stats, err := h.svc.Stats(ctx) + return gen.ControlGenius200JSONResponse(stats), err +} + +func (h *Handler) GetTrackGenius(ctx context.Context, req gen.GetTrackGeniusRequestObject) (gen.GetTrackGeniusResponseObject, error) { + if _, ok := auth.PrincipalFrom(ctx); !ok { + return nil, apperr.Unauthorized("auth.unauthorized", "authentication required") + } + content := emptyContent() + var body []byte + err := h.svc.pool.QueryRow(ctx, `SELECT COALESCE(g.content,'{}'),COALESCE(g.status,'pending') FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, req.Id).Scan(&body, &content.Status) + if errors.Is(err, pgx.ErrNoRows) { + return nil, apperr.NotFound("catalog.not_found", "track not found") + } + if err != nil { + return nil, err + } + status := content.Status + if err = json.Unmarshal(body, &content); err != nil { + return nil, err + } + content.Status = status + if !h.svc.configured && status == "pending" { + content.Status = "disabled" + } + return gen.GetTrackGenius200JSONResponse(content), nil +} diff --git a/services/api/internal/genius/lyrics.go b/services/api/internal/genius/lyrics.go new file mode 100644 index 0000000..38df938 --- /dev/null +++ b/services/api/internal/genius/lyrics.go @@ -0,0 +1,174 @@ +package genius + +import ( + "bytes" + "encoding/json" + "regexp" + "strings" + + "golang.org/x/net/html" +) + +// Parse only designated lyrics containers, never the entire page (navigation, ads, annotations). +func parseLyrics(body []byte) (string, error) { + doc, err := html.Parse(bytes.NewReader(body)) + if err != nil { + return "", err + } + var blocks []string + var walk func(*html.Node) + walk = func(n *html.Node) { + if excluded(n) { + return + } + if attr(n, "data-lyrics-container") == "true" { + if text := nodeText(n); text != "" { + blocks = append(blocks, text) + } + return + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(doc) + if len(blocks) == 0 { + if text := embeddedLyrics(doc); text != "" { + return text, nil + } + return "", ErrLyricsUnavailable + } + return strings.Join(blocks, "\n\n"), nil +} + +func attr(n *html.Node, name string) string { + for _, a := range n.Attr { + if a.Key == name { + return a.Val + } + } + return "" +} + +func excluded(n *html.Node) bool { + return n.Type == html.ElementNode && (n.Data == "script" || n.Data == "style" || n.Data == "button" || + attr(n, "data-exclude-from-selection") == "true" || attr(n, "aria-hidden") == "true" || + strings.Contains(attr(n, "class"), "LyricsHeader")) +} + +var statePattern = regexp.MustCompile(`(?s)window\.__PRELOADED_STATE__\s*=\s*JSON\.parse\('((?:[^'\\]|\\.)*)'\)`) + +// Read a JSON string literal only; never evaluate JavaScript from a remote page. +func embeddedLyrics(doc *html.Node) string { + var result string + var walk func(*html.Node) + walk = func(n *html.Node) { + if result != "" { + return + } + if n.Type == html.ElementNode && n.Data == "script" && n.FirstChild != nil { + match := statePattern.FindStringSubmatch(n.FirstChild.Data) + if len(match) == 2 { + decoded, ok := decodeJSString(match[1]) + if ok { + var state struct { + SongPage struct { + LyricsData struct { + Body struct { + HTML string `json:"html"` + } `json:"body"` + } `json:"lyricsData"` + } `json:"songPage"` + } + if json.Unmarshal([]byte(decoded), &state) == nil { + fragment, err := html.Parse(strings.NewReader(strings.ReplaceAll(state.SongPage.LyricsData.Body.HTML, "\n", ""))) + if err == nil { + result = nodeText(fragment) + } + } + } + } + return + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + } + walk(doc) + return result +} + +func decodeJSString(raw string) (string, bool) { + var quoted strings.Builder + quoted.WriteByte('"') + for i := 0; i < len(raw); i++ { + switch raw[i] { + case '"': + quoted.WriteString(`\"`) + case '\\': + i++ + if i >= len(raw) { + return "", false + } + switch raw[i] { + case '\'': + quoted.WriteByte('\'') + case 'x': + if i+2 >= len(raw) { + return "", false + } + quoted.WriteString(`\u00`) + quoted.WriteString(raw[i+1 : i+3]) + i += 2 + default: + quoted.WriteByte('\\') + quoted.WriteByte(raw[i]) + } + default: + quoted.WriteByte(raw[i]) + } + } + quoted.WriteByte('"') + var decoded string + err := json.Unmarshal([]byte(quoted.String()), &decoded) + return decoded, err == nil +} + +func nodeText(root *html.Node) string { + var out strings.Builder + var walk func(*html.Node) + walk = func(n *html.Node) { + if excluded(n) { + return + } + if n.Type == html.TextNode { + out.WriteString(n.Data) + return + } + if n.Type == html.ElementNode && n.Data == "br" { + out.WriteByte('\n') + return + } + block := n.Type == html.ElementNode && (n.Data == "p" || n.Data == "div") && n != root + if block { + out.WriteByte('\n') + } + for child := n.FirstChild; child != nil; child = child.NextSibling { + walk(child) + } + if block { + out.WriteByte('\n') + } + } + walk(root) + lines := strings.Split(strings.ReplaceAll(out.String(), "\r", ""), "\n") + clean := make([]string, 0, len(lines)) + for _, line := range lines { + line = strings.Join(strings.Fields(line), " ") + if line == "" && (len(clean) == 0 || clean[len(clean)-1] == "") { + continue + } + clean = append(clean, line) + } + return strings.TrimSpace(strings.Join(clean, "\n")) +} diff --git a/services/api/internal/genius/metadata.go b/services/api/internal/genius/metadata.go new file mode 100644 index 0000000..d19f459 --- /dev/null +++ b/services/api/internal/genius/metadata.go @@ -0,0 +1,181 @@ +package genius + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "strings" + "unicode" + + "pipu/api/internal/api/gen" +) + +func normalize(s string) string { + return strings.Join(strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsNumber(r) }), " ") +} + +// Exact normalized identity; retain version qualifiers such as live/remix. +func matchSong(songs []Song, artist, title string) (Song, bool) { + var matched Song + for _, s := range songs { + if normalize(s.Title) == normalize(title) && normalize(s.Artist.Name) == normalize(artist) { + if matched.ID != 0 && matched.ID != s.ID { + return Song{}, false + } + matched = s + } + } + return matched, matched.ID != 0 +} + +func imageURL(raw string) string { + u, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || u.Scheme != "https" || u.User != nil || strings.Contains(strings.ToLower(u.Path), "default") { + return "" + } + switch u.Host { + case "images.genius.com", "images.rapgenius.com", "t2.genius.com": + return u.String() + default: + return "" + } +} + +func firstImage(urls ...string) string { + for _, raw := range urls { + if u := imageURL(raw); u != "" { + return u + } + } + return "" +} + +// songArtwork prefers the full song resource: search hits often omit album art and the artist image. +func songArtwork(song Song) (songImage, artistImage, albumImage string) { + return firstImage(song.ImageURL, song.ThumbURL, song.HeaderURL), imageURL(song.Artist.ImageURL), firstImage(song.Album.ImageURL, song.Album.ThumbURL) +} + +func urlPath(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + return u.Path +} + +func sourceURL(raw string) string { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host != "genius.com" || u.User != nil { + return "" + } + return u.String() +} + +func (c *Client) Song(ctx context.Context, id int64) (Song, error) { + if id <= 0 { + return Song{}, errors.New("genius: song id required") + } + body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/songs/%d?text_format=plain", id), true) + if err != nil { + return Song{}, err + } + var data struct { + Response struct { + Song Song `json:"song"` + } `json:"response"` + } + if err = json.Unmarshal(body, &data); err != nil { + return Song{}, fmt.Errorf("genius: decode song: %w", err) + } + if data.Response.Song.ID == 0 { + return Song{}, errors.New("genius: song missing") + } + return data.Response.Song, nil +} + +func (c *Client) Artist(ctx context.Context, id int64) (Artist, error) { + if id <= 0 { + return Artist{}, errors.New("genius: artist id required") + } + body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/artists/%d", id), true) + if err != nil { + return Artist{}, err + } + var data struct { + Response struct { + Artist Artist `json:"artist"` + } `json:"response"` + } + if err = json.Unmarshal(body, &data); err != nil { + return Artist{}, fmt.Errorf("genius: decode artist: %w", err) + } + if data.Response.Artist.ID == 0 { + return Artist{}, errors.New("genius: artist missing") + } + return data.Response.Artist, nil +} + +func (c *Client) Annotations(ctx context.Context, id int64) ([]gen.GeniusAnnotation, error) { + out := []gen.GeniusAnnotation{} + var seen int64 + haveSeen := false + for page := 1; ; page++ { + body, err := c.get(ctx, fmt.Sprintf("https://api.genius.com/referents?song_id=%d&text_format=plain&per_page=50&page=%d", id, page), true) + if err != nil { + return nil, err + } + var data struct { + Response struct { + Referents []struct { + ID int64 `json:"id"` + Fragment string `json:"fragment"` + Annotations []struct { + Body struct { + Plain string `json:"plain"` + } `json:"body"` + URL string `json:"url"` + Verified bool `json:"verified"` + Authors []struct { + User struct { + Name string `json:"name"` + } `json:"user"` + } `json:"authors"` + } `json:"annotations"` + } `json:"referents"` + } `json:"response"` + } + if err := json.Unmarshal(body, &data); err != nil { + return nil, fmt.Errorf("genius: decode annotations: %w", err) + } + refs := data.Response.Referents + if len(refs) == 0 || (haveSeen && refs[0].ID == seen) { + return out, nil + } + if refs[0].ID != 0 { + seen = refs[0].ID + haveSeen = true + } + for _, r := range refs { + for _, a := range r.Annotations { + if a.Body.Plain == "" { + continue + } + names := []string{} + for _, author := range a.Authors { + if author.User.Name != "" { + names = append(names, author.User.Name) + } + } + out = append(out, gen.GeniusAnnotation{Fragment: r.Fragment, Text: a.Body.Plain, Url: sourceURL(a.URL), Verified: a.Verified, Authors: strings.Join(names, ", ")}) + } + } + if len(refs) < 50 { + return out, nil + } + if page == 20 { + return nil, fmt.Errorf("genius: annotation pagination limit") + } + } +} diff --git a/services/api/internal/genius/metadata_test.go b/services/api/internal/genius/metadata_test.go new file mode 100644 index 0000000..bb907c3 --- /dev/null +++ b/services/api/internal/genius/metadata_test.go @@ -0,0 +1,97 @@ +package genius + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMatchSong(t *testing.T) { + songs := []Song{ + {ID: 1, Title: "Alright (Live)", Artist: Artist{Name: "Kendrick Lamar"}}, + {ID: 2, Title: "Alright!", Artist: Artist{Name: "Kendrick Lamar"}}, + {ID: 3, Title: "Alright", Artist: Artist{Name: "Someone Else"}}, + } + got, ok := matchSong(songs, "kendrick-lamar", "Alright") + require.True(t, ok) + require.Equal(t, int64(2), got.ID) + _, ok = matchSong(append(songs, Song{ID: 4, Title: "Alright", Artist: Artist{Name: "Kendrick Lamar"}}), "Kendrick Lamar", "Alright") + require.False(t, ok) + _, ok = matchSong(songs, "Kendrick Lamar", "Alright Remix") + require.False(t, ok) +} + +func TestImageURL(t *testing.T) { + require.Equal(t, "https://images.genius.com/cover.jpg", imageURL("https://images.genius.com/cover.jpg")) + require.Equal(t, "https://images.rapgenius.com/a.png", imageURL("https://images.rapgenius.com/a.png")) + for _, raw := range []string{ + "http://images.genius.com/a.jpg", + "https://evil.test/a.jpg", + "https://images.genius.com/default_avatar.png", + "https://user@images.genius.com/a.jpg", + "", + } { + require.Empty(t, imageURL(raw)) + } + song := Song{ + ImageURL: "https://images.genius.com/default_cover_image.png", + ThumbURL: "https://images.genius.com/song.jpg", + Artist: Artist{ImageURL: "https://images.genius.com/artist.jpg"}, + } + song.Album.ImageURL = "https://images.genius.com/album.jpg" + songImage, artistImage, albumImage := songArtwork(song) + require.Equal(t, "https://images.genius.com/song.jpg", songImage) + require.Equal(t, "https://images.genius.com/artist.jpg", artistImage) + require.Equal(t, "https://images.genius.com/album.jpg", albumImage) +} + +func TestSongAndArtist(t *testing.T) { + c := New("secret") + c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { + require.Equal(t, "Bearer secret", r.Header.Get("Authorization")) + body := `{"response":{"artist":{"id":9,"name":"Artist","image_url":"https://images.genius.com/artist.jpg"}}}` + if strings.Contains(r.URL.Path, "/songs/") { + require.Equal(t, "plain", r.URL.Query().Get("text_format")) + body = `{"response":{"song":{"id":12,"title":"Example","url":"https://genius.com/example-lyrics","song_art_image_url":"https://images.genius.com/song.jpg","primary_artist":{"id":9,"name":"Artist"},"album":{"name":"Album","cover_art_url":"https://images.genius.com/album.jpg"},"description":{"plain":"About the song"}}}}` + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }) + song, err := c.Song(context.Background(), 12) + require.NoError(t, err) + require.Equal(t, "About the song", song.Description.Plain) + require.Equal(t, "Album", song.Album.Name) + artist, err := c.Artist(context.Background(), 9) + require.NoError(t, err) + require.Equal(t, "https://images.genius.com/artist.jpg", artist.ImageURL) + _, err = c.Song(context.Background(), 0) + require.Error(t, err) +} + +func TestAnnotations(t *testing.T) { + c := New("secret") + pages := 0 + one := `{"id":1,"fragment":"line","annotations":[{"body":{"plain":"means this"},"url":"https://genius.com/annotations/1","verified":true,"authors":[{"user":{"name":"Editor"}}]}]}` + full := `{"response":{"referents":[` + strings.TrimRight(strings.Repeat(one+",", 50), ",") + `]}}` + c.http.Transport = transportFunc(func(r *http.Request) (*http.Response, error) { + pages++ + require.Equal(t, "plain", r.URL.Query().Get("text_format")) + body := full + if pages > 1 { + body = `{"response":{"referents":[` + one + `]}}` + } + return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil + }) + notes, err := c.Annotations(context.Background(), 12) + require.NoError(t, err) + require.Equal(t, 2, pages) + require.Len(t, notes, 50) + require.Equal(t, "line", notes[0].Fragment) + require.Equal(t, "means this", notes[0].Text) + require.Equal(t, "Editor", notes[0].Authors) + require.True(t, notes[0].Verified) + require.Equal(t, "https://genius.com/annotations/1", notes[0].Url) +} diff --git a/services/api/internal/genius/service.go b/services/api/internal/genius/service.go new file mode 100644 index 0000000..75cf37c --- /dev/null +++ b/services/api/internal/genius/service.go @@ -0,0 +1,272 @@ +package genius + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "pipu/api/internal/api/gen" +) + +type Service struct { + pool *pgxpool.Pool + client *Client + configured bool + log *slog.Logger + wake chan struct{} +} + +func NewService(pool *pgxpool.Pool, token string, interval time.Duration, log *slog.Logger) *Service { + client := New(token) + client.SetInterval(interval) + return &Service{pool: pool, client: client, configured: strings.TrimSpace(token) != "", log: log, wake: make(chan struct{}, 1)} +} + +func (s *Service) Wake() { + select { + case s.wake <- struct{}{}: + default: + } +} + +// Run discovers new tracks, including imports, without making catalog requests wait for Genius. +func (s *Service) Run(ctx context.Context) { + if !s.configured { + return + } + timer := time.NewTicker(30 * time.Second) + defer timer.Stop() + for { + if err := s.drain(ctx); err != nil && ctx.Err() == nil { + s.log.Error("genius worker", "err", err) + } + select { + case <-ctx.Done(): + return + case <-timer.C: + case <-s.wake: + } + } +} + +func (s *Service) drain(ctx context.Context) error { + _, err := s.pool.Exec(ctx, `INSERT INTO genius_tracks(track_id) SELECT id FROM tracks ON CONFLICT DO NOTHING`) + if err != nil { + return err + } + for ctx.Err() == nil { + var id uuid.UUID + // Atomic claim and persisted lease recover interrupted work after a restart. + err := s.pool.QueryRow(ctx, `UPDATE genius_tracks SET status='processing', locked_until=now()+interval '10 minutes', attempts=attempts+1 + WHERE track_id=(SELECT track_id FROM genius_tracks WHERE status IN ('pending','processing','error','partial') + AND next_attempt<=now() AND locked_until<=now() + AND EXISTS(SELECT 1 FROM genius_state WHERE NOT paused AND cooldown_until<=now()) + ORDER BY next_attempt FOR UPDATE SKIP LOCKED LIMIT 1) RETURNING track_id`).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return nil + } + if err != nil { + return err + } + jobCtx, cancel := context.WithTimeout(ctx, 2*time.Minute) + content, albumImage, err := s.enrich(jobCtx, id) + cancel() + if ctx.Err() != nil { + return ctx.Err() + } + status, message, next := "ready", "", time.Now().Add(30*24*time.Hour) + if content.SourceUrl == "" { + status = "unmatched" + } + if err != nil { + message = errorCode(err) + status = "error" + if content.SourceUrl != "" { + status = "partial" + } + next = time.Now().Add(6 * time.Hour) + } + content.Status = status + body, marshalErr := json.Marshal(content) + if marshalErr != nil { + return marshalErr + } + tx, txErr := s.pool.Begin(ctx) + if txErr != nil { + return txErr + } + saveErr := func() error { + if _, e := tx.Exec(ctx, `UPDATE genius_tracks SET status=$2,content=$3,last_error=$4,next_attempt=$5, + locked_until='1970-01-01',updated_at=now() WHERE track_id=$1`, id, status, body, message, next); e != nil { + return e + } + if content.ArtistImageUrl != "" { + if _, e := tx.Exec(ctx, `UPDATE artists SET genius_image_url=$2 WHERE id=(SELECT artist_id FROM track_artists + WHERE track_id=$1 AND role='main' ORDER BY position LIMIT 1) AND image_path IS NULL AND genius_image_url IS NULL`, id, content.ArtistImageUrl); e != nil { + return e + } + } + if albumImage != "" { + if _, e := tx.Exec(ctx, `UPDATE albums SET genius_cover_url=$2 WHERE id=(SELECT album_id FROM tracks WHERE id=$1) + AND cover_path IS NULL AND genius_cover_url IS NULL`, id, albumImage); e != nil { + return e + } + } + if err != nil { + pause := false + cooldown := time.Now() + var he *HTTPError + if errors.As(err, &he) { + pause = he.StatusCode == 401 || he.StatusCode == 403 + if he.StatusCode == 429 { + cooldown = time.Now().Add(retryDelay(he.RetryAfter)) + } + } + if _, e := tx.Exec(ctx, `UPDATE genius_state SET last_error=$1, paused=paused OR $2, + cooldown_until=GREATEST(cooldown_until,$3)`, message, pause, cooldown); e != nil { + return e + } + } + return tx.Commit(ctx) + }() + _ = tx.Rollback(ctx) + if saveErr != nil { + return saveErr + } + } + return ctx.Err() +} + +func emptyContent() gen.GeniusContent { + return gen.GeniusContent{Status: "pending", Annotations: []gen.GeniusAnnotation{}} +} + +func clip(s string, n int) string { + s = strings.TrimSpace(s) + if r := []rune(s); len(r) > n { + return strings.TrimSpace(string(r[:n])) + } + return s +} + +func (s *Service) enrich(ctx context.Context, id uuid.UUID) (gen.GeniusContent, string, error) { + content := emptyContent() + var title, artist, album string + var old []byte + err := s.pool.QueryRow(ctx, `SELECT t.title, COALESCE((SELECT a.name FROM track_artists ta JOIN artists a ON a.id=ta.artist_id + WHERE ta.track_id=t.id AND ta.role='main' ORDER BY ta.position LIMIT 1),''), al.title, g.content + FROM tracks t JOIN albums al ON al.id=t.album_id JOIN genius_tracks g ON g.track_id=t.id WHERE t.id=$1`, id).Scan(&title, &artist, &album, &old) + if err != nil { + return content, "", err + } + if err = json.Unmarshal(old, &content); err != nil { + return content, "", err + } + if artist == "" || title == "" { + return content, "", nil + } + songs, err := s.client.Search(ctx, artist+" "+title) + if err != nil { + return content, "", err + } + song, ok := matchSong(songs, artist, title) + if !ok { + return content, "", nil + } + content.SourceUrl = sourceURL(song.URL) + content.SongImageUrl, content.ArtistImageUrl, _ = songArtwork(song) + details, err := s.client.Song(ctx, song.ID) + if err != nil { + return content, "", err + } + if u := sourceURL(details.URL); u != "" { + content.SourceUrl = u + } + songImage, artistImage, albumImage := songArtwork(details) + if songImage != "" { + content.SongImageUrl = songImage + } + if artistImage != "" { + content.ArtistImageUrl = artistImage + } + if normalize(details.Album.Name) != normalize(album) { + albumImage = "" + } + if text := clip(details.Description.Plain, 8000); text != "" { + content.Description = text + } + if content.ArtistImageUrl == "" && details.Artist.ID > 0 { + geniusArtist, artistErr := s.client.Artist(ctx, details.Artist.ID) + if artistErr != nil { + var he *HTTPError + if !errors.As(artistErr, &he) || he.StatusCode != http.StatusNotFound { + return content, albumImage, artistErr + } + } else { + content.ArtistImageUrl = imageURL(geniusArtist.ImageURL) + } + } + pageURL := song.URL + if candidate := sourceURL(details.URL); strings.HasSuffix(urlPath(candidate), "-lyrics") { + pageURL = candidate + } + annotations, err := s.client.Annotations(ctx, song.ID) + if err != nil { + return content, albumImage, err + } + content.Annotations = annotations + lyrics, err := s.client.FetchLyrics(ctx, pageURL) + if err != nil { + return content, albumImage, err + } + content.Lyrics = lyrics.Text + return content, albumImage, nil +} + +func errorCode(err error) string { + var he *HTTPError + if errors.As(err, &he) { + return "http_" + strconv.Itoa(he.StatusCode) + } + if errors.Is(err, ErrLyricsUnavailable) { + return "lyrics_unavailable" + } + return "request_failed" +} + +func retryDelay(raw string) time.Duration { + if seconds, err := strconv.Atoi(raw); err == nil { + return max(time.Minute, min(time.Duration(seconds)*time.Second, 24*time.Hour)) + } + if date, err := http.ParseTime(raw); err == nil { + return max(time.Minute, min(time.Until(date), 24*time.Hour)) + } + return 15 * time.Minute +} + +func (s *Service) Stats(ctx context.Context) (gen.GeniusStats, error) { + out := gen.GeniusStats{Configured: s.configured} + err := s.pool.QueryRow(ctx, `SELECT paused,last_error,cooldown_until FROM genius_state`).Scan(&out.Paused, &out.LastError, &out.CooldownUntil) + if err != nil { + return out, err + } + err = s.pool.QueryRow(ctx, `SELECT count(*), + count(*) FILTER(WHERE g.track_id IS NULL OR g.status IN ('pending','processing')), + count(*) FILTER(WHERE COALESCE(g.content->>'source_url','')<>''), + count(*) FILTER(WHERE COALESCE(g.content->>'lyrics','')<>''), + count(*) FILTER(WHERE jsonb_array_length(COALESCE(g.content->'annotations','[]'))>0), + count(*) FILTER(WHERE COALESCE(g.content->>'song_image_url','')<>'' OR COALESCE(g.content->>'artist_image_url','')<>''), + count(*) FILTER(WHERE g.status='unmatched'), + count(*) FILTER(WHERE g.status IN ('error','partial')), + COALESCE(bool_or(g.status='processing' AND g.locked_until>now()),false) + FROM tracks t LEFT JOIN genius_tracks g ON g.track_id=t.id`).Scan(&out.Total, &out.Pending, &out.Matched, &out.Lyrics, &out.Annotations, &out.Images, &out.Unmatched, &out.Errors, &out.Running) + return out, err +} diff --git a/services/api/internal/library/catalog.go b/services/api/internal/library/catalog.go index 499bce2..7ebd4f9 100644 --- a/services/api/internal/library/catalog.go +++ b/services/api/internal/library/catalog.go @@ -18,13 +18,14 @@ type Ref struct { // AlbumRow — альбом для карточек и страниц. type AlbumRow struct { - ID uuid.UUID - Title string - Year *int - CoverPath *string - CoverColors []string - Artist Ref - TrackCount int + ID uuid.UUID + Title string + Year *int + CoverPath *string + ExternalCoverURL *string + CoverColors []string + Artist Ref + TrackCount int } // TrackRow — трек с исполнителями, альбомом и жанрами. @@ -47,6 +48,7 @@ type TrackRow struct { // ArtistRow — исполнитель со счётчиками. type ArtistRow struct { + ImageURL *string ID uuid.UUID Name string AlbumCount int @@ -59,7 +61,7 @@ const trackSelect = ` SELECT t.id, t.title, t.duration_ms, t.disc_no, t.track_no, COALESCE((SELECT json_agg(json_build_object('id', a.id, 'name', a.name) ORDER BY ta.role DESC, ta.position) FROM track_artists ta JOIN artists a ON a.id = ta.artist_id WHERE ta.track_id = t.id), '[]'), - al.id, al.title, al.year, al.cover_path, COALESCE(al.cover_colors, '{}'), aa.id, aa.name, + al.id, al.title, al.year, al.cover_path, COALESCE(al.genius_cover_url, (SELECT NULLIF(content->>'song_image_url','') FROM genius_tracks WHERE track_id=t.id)), COALESCE(al.cover_colors, '{}'), aa.id, aa.name, COALESCE((SELECT array_agg(g.name ORDER BY g.name) FROM track_genres tg JOIN genres g ON g.id = tg.genre_id WHERE tg.track_id = t.id), '{}'), EXISTS (SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id @@ -76,7 +78,7 @@ func scanTrack(row pgx.Row) (TrackRow, error) { artists []byte ) err := row.Scan(&t.ID, &t.Title, &t.DurationMs, &t.DiscNo, &t.TrackNo, &artists, - &t.Album.ID, &t.Album.Title, &t.Album.Year, &t.Album.CoverPath, &t.Album.CoverColors, + &t.Album.ID, &t.Album.Title, &t.Album.Year, &t.Album.CoverPath, &t.Album.ExternalCoverURL, &t.Album.CoverColors, &t.Album.Artist.ID, &t.Album.Artist.Name, &t.Genres, &t.Available, &t.Analyzed, &t.BPM, &t.Energy, &t.LoudnessLUFS) if errors.Is(err, pgx.ErrNoRows) { @@ -147,13 +149,13 @@ func (st *Store) AlbumTracks(ctx context.Context, albumID uuid.UUID) ([]TrackRow } const albumSelect = ` - SELECT al.id, al.title, al.year, al.cover_path, COALESCE(al.cover_colors, '{}'), aa.id, aa.name, + SELECT al.id, al.title, al.year, al.cover_path, al.genius_cover_url, COALESCE(al.cover_colors, '{}'), aa.id, aa.name, (SELECT count(*) FROM tracks t WHERE t.album_id = al.id) FROM albums al JOIN artists aa ON aa.id = al.album_artist_id` func scanAlbum(row pgx.Row) (AlbumRow, error) { var a AlbumRow - err := row.Scan(&a.ID, &a.Title, &a.Year, &a.CoverPath, &a.CoverColors, &a.Artist.ID, &a.Artist.Name, &a.TrackCount) + err := row.Scan(&a.ID, &a.Title, &a.Year, &a.CoverPath, &a.ExternalCoverURL, &a.CoverColors, &a.Artist.ID, &a.Artist.Name, &a.TrackCount) if errors.Is(err, pgx.ErrNoRows) { return AlbumRow{}, ErrNotFound } @@ -194,10 +196,10 @@ func (st *Store) ArtistAlbums(ctx context.Context, artistID uuid.UUID) ([]AlbumR func (st *Store) Artist(ctx context.Context, id uuid.UUID) (ArtistRow, error) { var a ArtistRow err := st.pool.QueryRow(ctx, ` - SELECT a.id, a.name, + SELECT a.id, a.name, a.genius_image_url, (SELECT count(*) FROM albums al WHERE al.album_artist_id = a.id), (SELECT count(DISTINCT ta.track_id) FROM track_artists ta WHERE ta.artist_id = a.id) - FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.AlbumCount, &a.TrackCount) + FROM artists a WHERE a.id = $1`, id).Scan(&a.ID, &a.Name, &a.ImageURL, &a.AlbumCount, &a.TrackCount) if errors.Is(err, pgx.ErrNoRows) { return ArtistRow{}, ErrNotFound } diff --git a/services/api/internal/library/handler.go b/services/api/internal/library/handler.go index 9b7e1f6..09b491c 100644 --- a/services/api/internal/library/handler.go +++ b/services/api/internal/library/handler.go @@ -393,6 +393,9 @@ func (d DTO) albumRef(a AlbumRow) gen.AlbumRef { // coverURL — подписанная ссылка на обложку без размера (клиент добавляет &size=); nil — обложки нет. func (d DTO) coverURL(a AlbumRow) *string { + if a.CoverPath == nil { + return a.ExternalCoverURL + } if a.CoverPath == nil || d.Signer == nil { return nil } @@ -442,7 +445,7 @@ func (h *Handler) GetArtist(ctx context.Context, req gen.GetArtistRequestObject) if err != nil { return nil, catalogErr(err) } - return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil + return gen.GetArtist200JSONResponse{Id: a.ID, Name: a.Name, ImageUrl: a.ImageURL, AlbumCount: a.AlbumCount, TrackCount: a.TrackCount}, nil } // ListArtistAlbums реализует gen.StrictServerInterface. diff --git a/services/api/internal/platform/config/config.go b/services/api/internal/platform/config/config.go index 84dba64..4cde790 100644 --- a/services/api/internal/platform/config/config.go +++ b/services/api/internal/platform/config/config.go @@ -52,7 +52,9 @@ type API struct { TranscodeConcurrency int `env:"TRANSCODE_CONCURRENCY" envDefault:"0"` TranscodeCacheLimitGB int `env:"TRANSCODE_CACHE_LIMIT_GB" envDefault:"50"` - RecommenderURL string `env:"RECOMMENDER_URL" envDefault:"http://recommender:8000"` + RecommenderURL string `env:"RECOMMENDER_URL" envDefault:"http://recommender:8000"` + GeniusAccessToken string `env:"GENIUS_ACCESS_TOKEN"` + GeniusRequestInterval time.Duration `env:"GENIUS_REQUEST_INTERVAL" envDefault:"2s"` MeiliURL string `env:"MEILI_URL" envDefault:"http://meilisearch:7700"` MeiliMasterKey string `env:"MEILI_MASTER_KEY"` diff --git a/services/api/internal/server/router.go b/services/api/internal/server/router.go index 01d0967..54e68ab 100644 --- a/services/api/internal/server/router.go +++ b/services/api/internal/server/router.go @@ -11,6 +11,7 @@ import ( "pipu/api/internal/api/gen" "pipu/api/internal/appupdate" "pipu/api/internal/auth" + "pipu/api/internal/genius" "pipu/api/internal/library" "pipu/api/internal/platform/httpx" "pipu/api/internal/playback" @@ -22,6 +23,7 @@ import ( // Deps — модули, реализующие части контракта API. type Deps struct { + Genius *genius.Handler Log *slog.Logger System *system.Handler Auth *auth.Handler @@ -44,6 +46,7 @@ type Deps struct { // По мере появления модулей (catalog, ...) они встраиваются сюда. // Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler. type ( + geniusHandler = genius.Handler systemHandler = system.Handler authHandler = auth.Handler libraryHandler = library.Handler @@ -55,6 +58,7 @@ type ( ) type handlers struct { + *geniusHandler *systemHandler *authHandler *libraryHandler @@ -85,7 +89,7 @@ func NewRouter(d Deps) http.Handler { d.TUS.Mount(r) } - strict := gen.NewStrictHandlerWithOptions(handlers{systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads}, + strict := gen.NewStrictHandlerWithOptions(handlers{geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads}, []gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{ RequestErrorHandlerFunc: httpx.RequestError, ResponseErrorHandlerFunc: httpx.ResponseError(log), diff --git a/services/api/migrations/00014_genius.sql b/services/api/migrations/00014_genius.sql new file mode 100644 index 0000000..ca87b8e --- /dev/null +++ b/services/api/migrations/00014_genius.sql @@ -0,0 +1,26 @@ +-- +goose Up +CREATE TABLE genius_state ( + id boolean PRIMARY KEY DEFAULT true CHECK (id), + paused boolean NOT NULL DEFAULT false, + last_error text NOT NULL DEFAULT '', + cooldown_until timestamptz NOT NULL DEFAULT '1970-01-01' +); +INSERT INTO genius_state (id) VALUES (true); +CREATE TABLE genius_tracks ( + track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','processing','ready','partial','unmatched','error')), + content jsonb NOT NULL DEFAULT '{}', + attempts integer NOT NULL DEFAULT 0, + last_error text NOT NULL DEFAULT '', + next_attempt timestamptz NOT NULL DEFAULT now(), + locked_until timestamptz NOT NULL DEFAULT '1970-01-01', + updated_at timestamptz NOT NULL DEFAULT now() +); +CREATE INDEX genius_queue_idx ON genius_tracks (next_attempt) WHERE status IN ('pending','processing','error','partial'); +ALTER TABLE artists ADD COLUMN genius_image_url text; +ALTER TABLE albums ADD COLUMN genius_cover_url text; +-- +goose Down +ALTER TABLE albums DROP COLUMN genius_cover_url; +ALTER TABLE artists DROP COLUMN genius_image_url; +DROP TABLE genius_tracks; +DROP TABLE genius_state;