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=", + "H4sIAAAAAAAC/+y9fW8cx5Uv/FUas/sAZHZEUrLlJBIWD2T5TbtWzIjy9b2IdIfNmSLZq57ucXcPJcYQ", + "QIpxZF8q4tqb3Q2StR1v7mIvkH9GFEcaUXwB/Am6v0I+yUWdU1Vd1V3V3UP2UPbFAkEszvR0VZ06deq8", + "/s4njbbf7fke8aKwcemTRs8O7C6JSAB/Xe0HoR/Qf3VI2A6cXuT4XuNSI/4qHsbHyUZ8FD9PNuJB8jA+", + "TLbjF1a8n2wlG8lmfJRsWPEofm4teuRe1GrDixYt+NEw3ku2471kK/k8HsYvrGQzeUDfEh/Go+TXyXaj", + "2XDoKB/3SbDeaDY8u0salxr4jkazEbZXSdems4rWe/SbMAocb6Vx/36z8bYXOdH6tQ79Fl7Ss6PV9B1O", + "p9FsBOTjvhOQTuNSFPSJ/L5lP+jaUeNSo9+HJ/Pvf9/pOpF4eWaGLnwpv7BDlu2+GzUuXZxrNrr2Pafb", + "7zYuXZijfzke/nVejON4EVkhAQy04PeDNpncQj7sub7dWYjsqB++47gR0W3zt8mDeJA8SLaSzWTbSh7G", + "Q9i+51b8PB7Ex8kOfPe40WyQez3X75DGpWXbDYl+A0MYTKGPE5EucNpfB2S5canxV7MpO87iY+GsPNXG", + "fbEYOwjsdfp3GK279AO6aPr3hyEJJkW4+/THYc/3QgLzng/8JZd06T/bvhcRD5jD7vVcp21TMs728Im/", + "+YeQ0vQTaayiNfP3woiZXfk6+SwexU/i/XhgTd1456r109cv/ngadpX9nL79SqfreJQUcKwDv0eCyMFJ", + "twNiR6TTsiNlyR07Iucip0vy6242Ok7Yc+31FpLxk4bXd117idIdSZj7gdOpQM9mwwlbS67fvkM60oFe", + "8n2X2B793rXDqOX6K45XNN3S2bh+23aJRmY0G91+GLXaq7a3QlrtgHSIFzm2G+qnIz/cs8Pwrh8YJh6Q", + "NYfcDVt94N5Qc7r+LT6Kn8XD5IEFMnQz2YwP4LhtxKN4F/71yIqPxLHbo38exgMr3ouP4ifxIN6NX8ZD", + "Kjbj4YxFhbKVbMJHe8kWvBhEMMjpl/HIov87EIPGT5JtGCLZoq8dJpvxPn0weRTvx6PkUyt+mTyO97iI", + "pgL6ebKV7qG8Vh9pW8TRN+gz95uNfkgCzkR5sZSezF/gURWPZ1iQDZqns9hrhbcMO2fe/aZ8SrJseFsQ", + "wV/6B9KO6LrEeZu3V0j+zAlJV0nkpYdXI++kG7XCScwSFYZXX6JdjrvU7+aXYQeRE0al04enbpBl+qa2", + "v0aCVtt3/UAlQO4kZheKv+wHbiWB0+kHIHFbytvFnVpZIkVOZJAUUWC371TfxJv0cd261oldtHOyGpA7", + "Dzg79g6ZRBlCN/lWqYQRizDuOd21/I1xRnt46i06C9IaSbfQ73btYP0HdWrqORSttt9H1Sd/7M6Y2+X5", + "lO3U+2xXTiWq5Z3PbYdW+mqn5dnueuiETM3NsxD9/pckaK2RoJo49D2i35Bl23EVdUv67uM+6Zu+C/qe", + "R1+u/TLyey0SBFpr8dt4AJrNDjUhqKLzAP6IjyRFFjSMYbxHdRSqjGzFe1TBoU8knyabyY71l43fWvFx", + "fBTvgaYyiJ/T/29aycPkQXxEXz0CC/JB8qhcI8zsC1t2ukZGP0GsproB8nr1u9kJfKdzg7jEDjXaQMe/", + "61F1hR/TvFLqeK2w3+v5QYTbkSHpl1QnTDbjEdJrRHU6i2qFyQ7YZofJNvwZH8ZH8S5ShVKx0dTsnedH", + "JGx1deP8J9CWveUofhofWVPX7eAOXYDQLodUu6TDTutER7hqX7j4hnaVofNLoogfx4veeF07xzUShA5a", + "UOSe3e2BxTc388bMed2Y7OlWG2zST8pkDX955ocSZdhcxWqa6hZmN0zLE73eVddhFiLxqHb1i4aNjNJo", + "Nu46Xse/C/qr4/Xv0VfabV8WFunyxKvecVwNd7XFMIWCS8znfrOcIZcdlxjU9nq2OLMlbA3SwGILlLkW", + "Ujo00WYM8a6QWqeNS4fHxIdF39XJo3xxWqIIHSRzsdD7q+j+rmrId+0VYmQfI+uUqA86pYBxgzzxCte+", + "UK7y9321BY5htMKj2ln0O050pR35wYmncVL72Tidt71Iq7O2o5y8pS+bQbtYNzWbL8x23Q+WG5d+UXK0", + "UmLcv531ctH7G658esmByjCkOgS9c17GI4vpBy+TL+JD7ns+Zt6L53BZDeAnLzXKwP3miTxgFfenZ69T", + "4QRk6HQcuhzbnZdIi/qIQT9J9yaygxUStfSjlmp/7Nf4+dg+AtB3YGOanA/UV8rTS5es0NXIb7X4R1LG", + "fWUOkn60etO/Qzydst5ukzBsRfRrrdQj93pOQMKW42m97hvxUbxvxc9AqzqMRxa+8BxVxuJ9cPlRxXcz", + "Hsb7yVZ8GO9B1ESjspPlgISr6UwyQ/02fpnsWIt4b8DWXrKYSrJoxaPMNx0S3on83qLWQKQjCH7j6s2b", + "xA5IoNVh+sw1XbTN10luexTSKsMqVGXv123cm74fhVFg966m7r4b5OM+0d2OHrmr8/NKtwK526oukpWn", + "m+rbdXO9Co7JefaIcZbtfhDQbSqdacEDmpkWTw0442Zmw++SJbCVuFbLWEbLAVdBWHwYksC4sGzUoWvf", + "e594K9Fq49Ibr2teWe50/5Y7sq14N9mmlwi62Qdwo4ySBxjbeppsJFtwk+zPWJKnHr7kP4LjiP7yIws+", + "P6A26Bm6yCVeggG0G4UyWR8Kiki35wd2sK6wRoZm34DUoVTbphcrJQRY5NQWpwu2kKaNE55yydmtWR59", + "a36SuoW+vUa86E07aq/m10nWxtL65117HV4Htrh97xr+6OLcXImDh41TPL0bJISgsO7a6EUm90unj0FF", + "YnBvB4SOpPUWfEt1puQziO8cWMwBs5Fsx0Nr6lZ/bu41Yr02Z8V78WE8jF9MCyVrE3wxcAvtxkP4HdW1", + "DiwIUg3xfeW2nFiXsghpxjpyveMQt/M29yhl3dEdYrBSiVtBuOFjTXyPbvB3ief0wyue50c2V4Mze9WP", + "VplrNj+LwF7pEsWgkS5Kck//hcl0WiOBs+zo46PZdfGB2TD4UukVTTFv86qvpnHsrAuSk6P6QcoRUqOu", + "oeu2VWxAKhyt+d5dD5y2fjtC31speXsI2Rbmr4VTtpivRIYDm43y4txENCtX19lUSG7esYXIjsLS/cpL", + "jLbvLTsr/cAUe2/7vtvx73qtvhc5bnVLCfyipiAcXavhOwiyCidy+RZLP+1SuWqSnD27H5rW2CNex+jU", + "znu8pV9GfmS7+t/1vYL5ZB1d6SaImcp+aBwmnWi6VonN5K0WNJbnITZFoXJui3U89h6x3WjVFJdIjwZX", + "Af07Gm1Pf1C0wzlh5Afr9MY9sY+E6oxjWvfgP6oY0dWZy+mQ/F0Fi6vDBJbp9Kps4Pf8MHrLCfRhtI4z", + "buQSMrVKhSzL54LX6yb1vrNEdUWD6mAWL8uOS1oVGSwgbsswWyHzK70qp5jgHOSXSKOxU1y67Dr4SyHj", + "q2IwNgljUNRd6puyPWwWUC21PtTAq9BHDK+FHSq64OgDpkvKCUPHW2kVPILbXn2XME0U524SBWnaSn68", + "u/ZaqVn6kb1GrpMIrpqcas9o1eR7IYbjlMguWyVhumJpx9i0tAzhrzie2Q+SeqvKFiW5L0C7XHPaRLgZ", + "ctnOA0hcHsUH8TDZsL7789XVwO8Sa9b6CKN2371sWt/9ed65R1zrJ9+9hEtaeCrOS1aj7Ccu8NWcyAMg", + "ZbXJhNCR8Tr5fmeF/le65vcmXdNp9QKyXJ6k7czDY2eb4Slmd7r8Tt0R+cBb8u2A2wa6fC5VSk8kYS6b", + "+R0/iV8CR2DKDOTVYFZG/ASZIfmMfzAA3qAfY9ws2cQIGSbJQGxsp0oo6XTh0dNEeOVfjpmPl6XqCvEC", + "UrRhNU1fM/Mq00tfUerjEf9uSpcvW14xH19d9Z02MXFzy+mo9CndcdkvWonipXdi+sbXyhyt0qQLlz9v", + "R6vzdhSRQOPEc8IW8SjzGy+FFXIvP/O5MvUWf9eU32+YG52X3nzq4Zdj+KyllZbsTt6iwqEKJjmPUvi0", + "VgV7jUlVHcdxwm2I9Dfl0zdqjpOkdrMRsgSK0xSEVd8wEbvQ3EpeRC0uFOmlx5E/no3qgnbONH459TYk", + "dtBeBfHsuoTnDayio6LBkku1ocDlwO+W5O2rV+GHH157a+3HVN16YMX7kH84jA+TB/EA80T3qTaUPKQa", + "1R5kjhzTR9kzh/ERxDJG1Bgpk3J+G6Kr4+nF/vKy6yj5t7LLEV1FpuX2/NAprGPAy6Vq4nZm70C/CiM7", + "iJjTqkU88DfccXqwheQO90OC6hX2YYWucwf0LY/9o+OE4iP+b93GUlZphSSEhLWTeETSR6SFq7tiOgRL", + "dvvOVWRhkycxb3jYS8Qt0NM+qWmJxglTU54YT2715KosAe7f1qRBMbMXJ12qCJYyJp7vsXSIrKSEV7Qc", + "r4P3ri7K2SN21OqyCKBwOi8vU67wUC65Wl4MV/vLy67hUBYdqlLK9HudMtO59B1rvttXVFCv313SyH7p", + "EMgbwqmvklDI8Ea6fJWGYmCZG5QV3S7j1mterx8VsuxYjJphy3JHisqW4hqda9bDooXh/xzHFg//veTe", + "lPOEQnK+qVtIHRxp4iaXKcHVpRv9RVp3k0tsqaVWT7dSndZ1W1qF4ThkAtgSW1+Y0/K1Jufp/AXUE8Xf", + "pkDWaYw5ZTpIB3rLOx/3CXsCbg+94auGr9O5FG17HVVYeYY4aSFW9k31+ElLshcytasV6mFO65Wh9lCm", + "NCGnYhYUBVS48armyWeZRsws6wBS61iV8uwK1xXd0w/hsfHFDB7pvJCRiTiG+SZ+ZhAkko1sjl+Wq2t2", + "tNqCTKfQnIxudkim1GPWZnrH5cIjRywwkmxC6v0xq9wbgiMy+SweML8lpC2LzH7IbOY5hOaLSrk+C8Ku", + "gciqKxX010lkw516x+n1TM6fyF45FeFymy6CuCpB02moe8ZmIBZWFPyVYEj0mXK5WMYAai9F7cQ+3Qe5", + "InNkxXuQHO6c/4nXhECGEgNbtPvR6ozjrdmu05E964t6+RfZjqtPLU12kk1IM4TACB8W+OUpZSdrin4r", + "Pj/myajxwFTy8Xi6OCvJiKcyAocCeAzoWw+T7eRTNgjEVRjszUZ8lGzGg0az2t0kZTJqkx4CuxuWlYvk", + "djyXkiZLb2aqCmdTw17y+9GlJdf27jSaVZPYjNmRN4jdjkjnJk+ZOf39eIrsG+UeMGfeyHOuIz1CocGp", + "0yMKsskMXk+eFlaWQQHz5IVczNzI+HB01gb/mUGZDaS3llIJ0j5z0pB9YZhzx5jz0V4lTLE3CWb1eC/6", + "dxZ5QvPisu24i9YUSjJWMY4eQzWXf5fLHwjRDpMNqKHeNFQ4Z+TJH+Nh/DzZgmgc4GdZDCttF94EYoZK", + "sGcQGGZSBd9PpWvqmyw49FKyHR1/JbA7igO89GwjFfXUh5oho6u8pKaIlb5b8YjXC1226MrjJ1SSstB4", + "PEi+SB5YaT0TJXnb9+84pJq77AYLYYs66k4Xqn6oZR3YWLmWqQBK92uhbXvzgb8SkFCXVNTpmBJJqytf", + "xRlCnhOuntJdtOz3PcMsl/uua0iHwEwcQ3aSv2ZMoA38NgnD6sAQ0pjgax73OvBbbMhC66OCuEzzaIEq", + "ynTUneAkVUaXl95knJEOz2mWElZKszUra5T9jKeLb15OT4KTQ0UWw+R7ZMW7VDZZya/iQfwifplsNy2q", + "E1FlSZVmgN4A1R6QVRI/pzpcfChqZ4eaLBHdoVuA+A5WrxRm49UAnNLUJllUhs/JZ1UEAdSatBCUME/h", + "f01VO5D/e1AB9hQkNCb8QJxpEO9RidW0IAMH0nSOKY1HyWbyKRgzlNoHmKRDrxg0glCFPQS1+yWI+EdV", + "TvmJtAi9jvtr0LD3KYccxU8AevMpg02BhQxk622PqeMv8ARUTbksSpw0ZyFWTinUZlJMws9XlNXISJxn", + "qXJdbIEEayS45i37msPTc1pajJM1LcDJEi9ibcmpI1qhsYsW3i6yp1SniHX1wNsP4iP6X8rUnydfMG5F", + "tWSYbCSfUVEhAqs81qpkp0lmGQqnDFKPIRWt63gtVi3aygJi5GxWmHN8wDKb0HJlP7rqd4jFVI9zzFDF", + "TClg5GSnCTYuW+gRXR5MkHJ6sgGV1qyyMVX4hskGFBWznTg/N/fTn2rhc5iPLd2zntPrn+v2Q6etBcTh", + "MDEtzGMLlR/TO4sOC6px9bQxiXdMUEHJjrK2eGBNLZDufyPBtLzMxtzM+Zm5UiuRue5SVBKZgXVrLNhr", + "LTvrD1AYaqvxTpz4qxqrWcqBsnqQ7IjTA0Z/NR2G1WRrqzGHkD2KXDmKn6us+YLuErgg4gFVjCWfg/YI", + "ZXKXa0vBtcOo1Q9P7eOVp6cmJusgNvmAKf0K+KCOwAFnqZMHDDAF31TNW5RR/A2IKS7TWAIwpqps8Gxg", + "OaEYsb70CcXW1M/eWUCJfEjNR9BeRKH2gJqy8MVRfCCkPH8dcBjcBo+mtSw2QbgU2PWwbdcA7ytcw5ry", + "9T1Qa46Z/sNzgEQi7CP6t/X+tTdvXLnxP1rvfbBws0mN9i0wxY/w5sMK6Edgu1MdHD0ElbQ4UxDGWw5b", + "5B4VlJVOLn3c72ULOtXvQ9AxKr2OUr16LEQxmrXZJLCL9F4M1my31XW8fqRzyMT/GO8iIwIdQUcGPWGE", + "oo8qDIdSmCDjmaGMDXjzCDxeHjRInSZVC2nSX7W6JAyZt7AC5s5Kqxc4fuBE66V6qL0yzx+VPLblM+QX", + "193AiUgrIt2ey0JaGSr/nzTqgqz8AM87mILJp8JsRBd7x2+Hs0xSzXQ7f/XjaTVQMxTIF0WhmgqxPib5", + "JVZVjoHK5MrBzhDYxG/NnODNEaspQdGr25yRR5kbCg6M+Qow+Eo194AG5lwRYJVyAfRJA4pEOaUE0UoM", + "7QkXwc+LVU7kKz4oEhEvludyy3xr3vtaVBFUJE6riczrEVDK1ZFv0S2EcTVUOdBGYsm0TEf52TsL55JP", + "ecMOQBb5nOofwV3Ap0oe4iWbqhwDvVIhZd6rE4EWEih46E3NRx9h9ZNmZIFGMmLYJkzM7XD15xC0J3Cs", + "J1vxMTUdp5sWZU8JVTY70CZDPR1oZ/8DP7AqxedM1IZAftld3aj5jNd6tV3O6nHJVvwEfR7JI6zCY+Yw", + "+hMgCANcG+8j9CD8avH1CxcsiHVDCsoMn12LRcAXb3nJprWIwdyZgNih7y3OWKDip4Ef5ntDj4sU62E3", + "rIDvlbTSAQ98Q1MdzvzMqYHbNqS6/HEeo8laBEqe43OdhaDP4swtT61PNQhBg3hZyMWgJGQKv+9FKdoE", + "xhiz9S9SHCZbvHxSvId6jAgzvG3divLZaKQFWlhl/adE5blJwsiIZtXvOH5a6567bPYV8DSEX4uPFLUU", + "sk5eMG/0EYvbqsdJCHy0hkcqQBk9P+fn5uamtTJKImW+guOOXlFD4TSOWYC/wPdliPAnBDHFeAD635+n", + "kHQpyimTxosz4FjkpzqMFundJ5MRRUXmqr9sYZybCm9Q6qfNp8FUdQhx5pRL5L0t4I5M7Ym3LFR6vTyI", + "AmJ3f963XXZpiOhuP/IbzYYfOCuOB2kPq87KKsyo40ANkuvf1b5SvlqkF0b2SthadgIoW2JJV/QP7Ss4", + "+ty8VEBeBUWv+DhWBLUzJNnYvD1LaYSNBcRqjabZa7bDuCfH0/8MWtYjK6tw0buVnW1rSqPFZZQwOPHN", + "9Cd4FR4wFLqhXqXsOGG75flVuk1U6BezTOyoH3B6VxH9sFnv8J/lQY3jf+RijoUonoPgoorrFLj9AA7A", + "YvKMg+rjLc/S3wbcX5j8GsH/d7GmO9kGfx5VgDZAFh4yWYiKQvwUJOcBeJp5fknuYtLU7JYGHioj5qQZ", + "Q9WImeYO6e9QKisMdfLc4wcpO/EhCD+qQSWbyXb8Em8O4Cdx7VhT5y9YycPpy7LfCa2KvPkwrOIDKe+Z", + "Uo1Ri9qiqBnRmahlWhedHgxpaPkUK+SU9ko6A0bZ9I50SlQZtdTrFiyQF5NQQ5AEK+uVHnX9fscjYdhy", + "+whJUfKLDPHojMR42bcZV3itE+o007DGMqLcJhdMpw5XQ7UwuNnTkGZRm2+mPDw8APanvZDyD5i/ogxc", + "TayfRIaVnNRqA5+wz5GonlbIk55hftbFWWa9kHBmjDK6PeKILPkd6i45KjqpEurxw3DZ7pBWqFSEvdEs", + "KW1b9bukReXGmqNPxMzCCKOWAGL4KcQ6d8GEf8GyBennu8l2/AR7yWJTmx1wQuzgbUnV4wPQfh+ye+4x", + "+HT0ugHMr+v7ndqndog9jTCvgN23J5hg119yXNKC6vBAVlYB7CQPiiUZrlT2uKzRiiYDD9K2Wm3fde1e", + "SLTG2C4mgEBK7K6cwkGv0H1IiNoHGhzH+8kWIAO/hLsQqPIEmsImv6JUTLbopWsmAHj39BRgE+VFhJqJ", + "/hO1BelswOw5pltBL2lpX9I5G2fQJl7Ekh44heETeiOQZXr2AmdlVW8MRKukq9g0HTu4A/Xx9BfNRrge", + "RqRrLoVfswPHVlsA+cESvOBjenFof0iWNFyxBFLACV3bMyT+5iUC5C1eJ5Xh1nPx6i3RvEtw/D5PoN4E", + "zxaLqoL9Gh8AwlAZaHsB2NfY6FPmZY+FM382Cy/rfVuOSfalDGGPOe4gKYWHszqwPaaE7hQB2zfFyTtI", + "8waHFsgDev7k9mI1QN9rysxYLhvm5o/iw7GXkM15G2dJxUeLbhGguzO8fY0+TL+titsh/GYmgDFMpDHA", + "WBRgwf+eV3o9EGVUlIt57BdrtZf7rtsUn9FNaX3c9yM780Xk+y3XDlZI+pEAeJd+7qXZYGzdVayn05S0", + "R/2wcgdMnGarAP51PD9rujO5d6cTk7ZIWmo5VFjWoOEsxRnmdjFvitigXsBhjjgW1e2woAQ/VI+TB1Kq", + "+YyFEGxLdkjeeB2NZoiYSblMRylaXbJFXzuA2OBoRp9UXCE0K5sIpY/KE8wv+e/m3363ac3/7F0uNz8i", + "S/NNtuYUS29o/YSKGAywcPfMHmt9QMUq+qOZm6UKa45hJhTtpBkD1e/y3gNlqGVjSpnyWGY+6Hnh4sUS", + "+ORcoromN4v190q2WN2DCFDDnfw5aN1MQedpXsMcButc8wwbF7aUaIvSOlJ3RDPOga7jsT/PlwiAslOv", + "bxCJTSBKlSrQmMABjHJGYq1SNh/rsltyolaH9JAhyi3tJScKWGS4/GG2HzLLlU6+7XdIu9qTJwGD4Fdk", + "yyVriDbFlXrRXbRLIrtjRzYWKa2QoBc4XtRo0gndblZphs7H8Jdbp7pKM27ycoLzEepLRWb4fi30kOjg", + "4ACimQUKmbEq5WjGQ1Bxvvtz/ASUYbidsjm0R1a8T5X7LSpPQNvfTbaSxyBdoJxHwlNNtgDjeWxaioVw", + "8MoTriSNiqSrGPGYZ/EqdDCwj0+0Fs6hlR36KJEQe0Hn0udvbJGOE5lsIh4DbBmj9T0/DJ0ll7TEGdC/", + "6WOMM7b6Paie1WzFvyO84DMoqMNgAGgIR+D8SB4KiAvYpIec4ByD8Fm8h1Wu9KL6TCpEEZVjuzzcBfU1", + "tuv6d1vZaWktKtImzhrptJbWWT5PBcQYtCZPWXaKSJat6uKX3n1jTbNacgTyUi45AhNvqmU/nzaAxHEg", + "KCt8pgbbRvFzK4zsFcdbwYCStOkiwWFgJVvYtvuQSqRqXT1XTonlwtp2Fihg36RaliFKa6kJWtZUJlH4", + "mIXKqGo8il9SqdS0MiX38a6lbNn0GE1NC/trjGMa5o+77DHYRQ8BCE2RXyWJ3l3eSO0BuDyHzN7FTKg6", + "IAMrpPNI9p+qovFGX3Kujyo685qRclhzQkZcEVw9SvUwVSzI6lw2RMkQbSTtJiv0dZekRgMwaTh57Up7", + "IeSlv4a78qclK2dUoWrGpJLtfknuFKvtdaCUSEbAq2rhIt365lhhud1PnxzD+B/j0TFDi5UsYRFQLB39", + "rAOMxfHFccKLsK+GZHNZOayqElaT75V9sfMBgXxXM9JCie/jJE6pvH04lgOlNCWpDn+FOdxd4MkY24Vx", + "Kq9Funf61NLTOcf1BkIuJMqxEEBpU6IJB+h4wmooRNDImGUMKY2qUk0p+sBNgRGoezq9nq9XZD5qWqcC", + "CAFzvOuDlJK9n10WQg/t09kpXVxl9IeX1hS0pHlClyziSlSt+xTcuSPmF84SYXp8O7KA5QTsnYovm926", + "sX3mZcz3876vu6tcp+tEY1kyXfseev7H+VGPBNAhvFU2Xvl9AbXU44wN4475qwxx5VkrMzCsLD9ojm7m", + "nbpR3NOhQ9oCnVOcp14v8NeIiL/ouxFguOZ0CV4FPlzIsAu1CQ7fqFYSq8gB6Mx8zccudDeA0K4wVyq4", + "vfU9ByFNkFOsnOZ6wZzXTg3opRXxVxmspdZdVNF5qE/k1xlTkONuhm06Yaoc81SQKHK8Fa2aofP6VHJG", + "IU6d8EQdZP1QQ2h/AT4oYCepb9kRewdgB6S4UQh8BJJfRqBT0CYAU/KURvirFo0Y5R5XpGf2PP8So5DL", + "rFdHxqaBE8p5yhTU/QMEY0d89w1gqTtQaGLw9GiypxQQv8fc6QxQZPFT+qHsD9pNtjMVhbPZavL4aFoX", + "EDYciooMqVIinQ9LVMuudmBa7cmc/KW8XYQVbeZz06LyRIbSgQpbLy+v7sNTlC2abWekPzT5nS04C7mq", + "RNkbx0D82B9YrthiDa2aXCfoQOfsnh9E+Bz+W/q4tWw7bk4VZwWgakbHXSda7QT2XQ+QlXoZQKOUV3iI", + "9aSFjydttagj5Ef2GnnTVDsOab3rlRzry7brLjGnhgYZdR/RwuNDTFzDEL+2xoLXZg9YSTlYKE/Y0QXv", + "ObrgP0cgQygQfAkW2H48amI/zCdghiH0YT7yFY9Mqbh+tZY4Y7X9mRBYnTSHZrpTbBFyO16+L6bNN/ZK", + "Gmv7K9LuvmES7xDSWdKWvPn9qO2rybeUZi5BDzTrX3XH6bWIHbjradOqIpzjMXppmbue8JmZCMubJ+sw", + "7Yasgex+ppbreTywfmwBnORQA8O4tA5NZPRhjCGqiWiip28HnMpdjgbHu8si0thIBVafWjtvzVpX3l+w", + "Zi124J7xZKfkc/rCaZb7KqrNBH47tVXwGB5WRUgHItHliDbTeZMJNrUF+1sSfZTKhZw7aawypx2hpOB1", + "xtb5uTnZI7JrvgvlAcKIeK1oNfD7K6vVJwaQDQY0XXaeq0Blstfo55EnmUwQaZxmyk1GDpY3J2/VnXZr", + "JkZnWFXLiI5h3IUMndPXNE9DcxN1f6aVusuSJKx8ioT4LO8Aqp3KB6JHTUYfaWvdFb9DdMQRnJhDEDPX", + "7YgEju1aC+vdJR+QUnOEv0PWVcDMFKa5WOjSHzZxMrcLVxAabzFnjO76EkU0EkkUE536XdmITTpTPopp", + "tQvQyfFMbmxoDtk5saqabUTJA0yZLqKlEPYMQExrDWi5OnAicpPlKlxdJTrVouuEGGEeYzvl115nv1dP", + "3XlNtzgtDMSXkD4gkGywd3uq61J1IIWNOcRm39SOTaG8OVwzteX/8vl/WD+d+/8Uhz4gI0H0QQLPPTC4", + "eAK/73VaUeD0DBeU3SVZ5CM5ulSxkwW423gbC2lMeYCmvDW3K+2u0R18cggw8cvSGQhGOEXfprRlkyYp", + "6aWKZAyWDWzr0+Q3AFh0nGzr/BsMwfwYEEYYuICAih/E+5etRdaVaDHN5FE46Ejq13SISIwINfCMq36N", + "cfCKCzKQ/qhwqZT4naZO6REGvA4xAGHznKbjTD+qZCsHc6jJXho7jCUljIhZiY0t8jErzLTQX1khof4+", + "lpn5xMwLIr3dD5xofYGKNWZaEDsgwZU+7g3+9Q6X7n/30U1ABcIySPZtuhurUdRr3KcvdhjKeX4n0LkM", + "EJ50d2+8vXDTujJ/zZp3en3rej902jNW/DtE7KJPUe0C8fUfADTXtoSIpYBZ+3bPOdf2O2SFeNO3PGyz", + "knaGBpxLv0c8+hidL05sesaCYC8U+qZpmsARgIpp95yZbgdRs1jkvJHOlU5dwsHm8NlU1ONQjUuN12bm", + "Zl5jQDNAYvrO2bXzs9C1ZNbudxyQVysE/kP3GdIArnUalxrvO2F0BZ5gvaJIRILQGDpJH5l93+k62LWu", + "5MGrmL1z/zYEXXu+FyInXJibE41cMe3f7qHjy/G92X9gESy8EUvxZOgSIKUJ2CMblsaaPCD9r+OBFT9L", + "tpINJqgGkpvXNIyYdxozAqR11sOxEf9L+kIs4HnBgiT01pyCjZhuyq0ykk1mK3+K0PWQeskbzdymb1d3", + "cYV4Dnogtdv4LonehScWIjtiXd0mQ2l5GA2t5ymnUsslsogXOO3VLv1nGNlU/XLaISOG5Xvu+jSqVJml", + "Qxhes8SrvhcFvovjN1AKkTB60++sj7W4vCqrxnBF93LRpDwK1ssVR2PDKfVBFl18VduDxcodaUO0e5Bj", + "P9dZCuxgfdb2bHc9dOiJiFj3Uu1m3aBfX2EPn5Ydsw2iIEJQqT0Pf1S/K8WYcSyElDyUsDohgqlAptch", + "PL5kAJFp/Zk0BBQbcwDSnHKGitSWAvTGYOxklCdRSAgjMHkkiR22vUU7n7acMt4j7+Ojb/MeRT/g+0Re", + "SvVrhfe3PIr36+CL/y11Xqq67UoLpxNt9KofRuc6Tslev+eH0VuObptNyrEJFh+WRtl/Jw+Kj19RBesR", + "xMIalxqiGw7EpRo8cVrsalZKT5JLGBEAMknHIGprgFpkReaV2QKugdJlIA3gMiwZ6Xeo5AI4xjmNRXci", + "1kmLa0xKCjtXC7xoYNIHmJfwaE/vw+SL5AFkQwFFRgJyONmuY6u+ZVh4R8kOD148AfHwEpBIHzBMaaR0", + "0xK9BSsSHeFsZgMiGiebrmJ4ALur5Ul+wdwODtLEoBo92UREwGSHN4zZYnXRg3qYGsutN1NHlAIQJDWY", + "S2eUbGuYFImiJxems8xKcCcmLs0kVU2QTTMj6fj0Kwn/6UVdYiT30mwnmDxpOd1uY89ubU7QnxiewyNu", + "6dI9E9kimAY2wuhhirh1xFvYZfrZ8fst2YGSWuZDEawgw83AAp7FeywjmldTZfQouHx2oaQV0RUgu2im", + "0cxwAEP0yTPByQyO6vuPqVZnbDG8Khb8nfAujwQg2sl5Mn/eIa2nWIlZYM9MkLpS/wa9zYFVoEfxfs6v", + "WgeR/2Osiz01vTNNjkFNWJS7GUj9jjRV5IoSojrSLluL3nLI3pXrJ2WFkR/YK+ScvUK8aOaWl1vBUIGq", + "R9nAakD30F2s9s7Mdl4QLRsy+vMlpd6SaQNHopklyjLUp1CX3YUKhSF67jKuCijUYw03JiM45J4wlQTG", + "+ZqH1rKzfrMQdZzK7vo04PK2HeMosExYzH7idO4j+0Pejyb7JbXEU7QixTpLPrXiJ1DGPcp1/U5+w7HA", + "cvNtStddmvfKuvcpGn7+vnoLZiuxm7Lxrxcgww/r2pSv8709cvjyY25S06iemVY690pYfBJiuowy43lW", + "mKzogMvEpLr9LhPsBZzwPaaWDyxo/ydKtYQVWdTJJ7/fJlXrDCTlK1GtzpqNsipVnWJxlgVwCw0nQWt8", + "coKkZWMYFSupiQ8V0JCciJ0vdyijJlt10Ds7CGRYDNOIzwRObz/Sopqh3xHUMcyAPobo8g6D8df1GGL7", + "KVoMWVKHIfAYLFIZnu05lDvB833drtd/jHMbfnbHuIzXvpX0UdH0Xk0EqMWh9K/xQD3en52E/6qd8tke", + "K2C49Mmp+FXrkprHd58l37AhefrOq+EgNgktE/0RsrKYn1a4bLHrnNS0qBYNmlnUHGKI1evxzLAnvFt7", + "yk+8sknXJOzkvMYbX9XAX5pQHnS3FgWKu4gffhgPL/MA2VNUblgLtnjXWnz37ZuW1qW9OGPFf0Lbkr8R", + "RercTy325Ax0tWKZpho5udC2vcnqOW3bk9j7vt7TW9tQaQ8yQyhV6i7OnMV1yUAeJBKdFHGwkzNixDP6", + "6hZ0N0l4ZtaK1CxNL1+G8fNkC4Bnwc1hZdqdjeoSLfyVo9SLrzZeGp1KD9V1HZyMFMk0ggTMDYQrTlvA", + "7VsXpbqOgc62HzRzybdPICIvFbgyt3cm6THZiQ8wKVJKz82LFkhMVXL7JiRizOmwZ3yfarKudTz/O4wu", + "nBGny+oYK4zVtOMcNyxfxPshpnBOhvu/zCbgajlbSuLdhaDPELp3HMYDlgmBQNcjGQD/KD5QlAtF22FY", + "fsN4Fyj3udxvcVeh8cwtL/4qnwcxhYCHagdy7rrIHTCRlot+jF0qrHhn3+nUbuKXvGjM2vdEf6tFnQuY", + "ZdfmT+VZnAgptVeblsD0P0RPHzKL5YVC3Po8tXJIVz0iorcu5E3rUWVfsI4YyVa8D675jXEvj7J4LzXr", + "PhS93Mc7R3J1+DuOG0GJwifaRJUUMFHsZ2lFqP5NAMVYx4u0yIW5TBoJ1OSHlcOVwTqsmMFVc27f12oE", + "mnVcVrGh9fpQQWyTfTW7hKUrImwxHvO+7UVOtF7mFtYAOj9qKnDVAGD6FPt+KFpSqt7sS8UV9Ae7lgqT", + "QF+hoCDMWPHXaX8H7IjFOqtkm/DCkNAVhzn4HpgdzVKHg4kG9KVuGBPQjCYF2VkRdEhzjuheYYrsS9kD", + "JkXIRHuOunSvAcBbDzRIccmnFlOaBf+M2MmjTPRYTCbZOtmxA4i3SR66UpBkli7Fgm3b8oFg7g0hXXAn", + "HoCFwdo3QcoBa0X3WNKtVHzBsY7URE/TK0yLwYOi4XiWm1snNzN7WQf/qMdQOiXzSjn8p2Bhc/o/UvAa", + "iPXGK98pJYs/1UuH9am6DCJPatPGT4/c3ke55k69hQj3LOm2OQWHQconW5AfA67LxRQkelEGjFe1nxnr", + "hu2tEO7A2YNvnqm3r8bNCa8WQuGknGXQWMm9XiMrBLSKsBljTf/i0FkpfPEJssr7Hcef/ZHKxmJ+S45n", + "w/jZF983N/VWgP9PzrSsJLNx6Re38w4N0XBgoJTEKkNj60vewAC0QQB9xbZ2DBLmIB5IbrExGVwKPRnE", + "C/0+Ndomd/XceJVRIw0kZrGXi/UKiZ+BeDug+8FVsFri3V8LsSnQ9mQI0ZHCM9r87KLND4lSc6IJsm5g", + "qiBI8X0Oq5e2a0y2ZqzFjxctvB6Z2FW+t7haM8RqcxbW2sJSACxkB0WJGxt5AQfuAphqTrrpBMvHikld", + "3LLTJJygw2WzItNgq8sfmq1+hbIAJWt1U10LOghoVjXd5/mXlxRr5CtYc3UVA+BegKR7islWcJh49quc", + "KmtxyK7nqivU+tGPMH0dAb8H8fMf/eiy0NmP2QvALt8FRG3oaczz6qEpzCH42+RcgplbHrQ6xVuedS+V", + "kjex05CczrhIz+uiAj8OYVSAxePAEUeUaOYMWbrhExLf6QBjCe/zNU+gA0s01YblMTMfKQmzNVUhsS0U", + "0Tg9VGdpDTAIaE2KrC4ZVexs1VTU5It6lvsnJeXUCEqbv5mkwzueCzgkQYk5/0d+5KXooamDMMBF4MEc", + "iBNk4V31EspalAvwIH7KrBrWpBJv5OepdY8D7aMrwEgQev6/4TYKgwV8ihPbh+gVtqh/qnY7RlhnfB+4", + "4lANgILjXM/ozPSRH0UWBTDXjGuHUQt2QhtUSVttT0zp65xEakzgHqzsZnth2tNHEynSgebjTYtfY00N", + "X1YDtEilCZ0Fic717DC86wed8R0S8hHUX7xSc+9dUPEOpLAXuAPFtay5cdFBpt65lxkoNV7Zmxzy0giC", + "/RxcPMNkA2yrxybz+QYlBepBjBoTZLabpNvzAztYF4PpmO7LQoLVc0VlOtzL7z8JKwUEG5Vhpms4CY7K", + "Yw1jpi1UsbEGC8K5ChFxyL3ZYWVGmfQPzMT7FeTsAg/uQ9O3gTUFEhWD48B+8dOmdfWdcyDw90WT+Seg", + "GENJ43TzlscrmQSsUVFHQYR1S/FZ6S0hkGIvW/kJpM8+E6cjda7Rm+QPEkzyHtwHAjgZLTBWF72BEfgj", + "Nl9+KjSSX5yLG5mtraRjcAb7rL6ClyzPpq1uVIYo4V93qd9NdSpTWv0VBmI4uWsHBtB6n6Q4X7KpZDrV", + "E8spHiClXtuObNdfOYGGJvmrZdL3erO21wl8pzPb8e96rm93Zj9hQFvm3XiLPXoFf3qld8fgB2DAFMyE", + "5wBeRT5GlnrduNT4n7dudf7m1q0Z6T9T54I2++f0///XGsfheNb5mteZYauf6dntO1ByGbRXnTVyao/l", + "lfm/r907eWX+71Vw6al4P/kN9LpitjOv6xyo9abDptGBzf3biIQBrq19HqVULINeL3NqJdZxbZ48anJb", + "pQY9dHs9oBIVInRCCNO5vOtExLZYfTp2z+Jqu4ixZ1YslaFyiGkLFfO0DJ5XHCZbPDafJv2Mkse3PEx/", + "QrcA9ISWUPKteB/nNWPFX4lhRI32kH/N9Xw4uQg4RHfBopvGFPzXLbvXm8GelaW5U1TiIXnfR+pOUvLh", + "QDeIS+yQmCx1yS6i92W60Am44eXRkh0IzXK+T3YsNt9zmt3csabATS9tw1G8yzmLR3aeK+ejlM+xZVqB", + "X1ZuHCQGxkGbVvIQsHxSvBk2/UuUN5oWy+MeMFgHQH63PnK8jn83bFpXer1rXXuFzHbIUtN66/q7LOGv", + "nA+VzL8sSONJObLXu8poMUl2TEfRseJvpRYWhxxHUKRO1s+Mv09px5wHOQ+Bcuin2P6yHR8mm6ikxsel", + "jJbewchy98e5jDnVKt3Fbf6o+SqutEWSw/77fdn77YhE59JQ8enikX/SnFl28CZx84v4vXKQB/UpAMWc", + "CbDhFTR03vd2cpIBR9AHADU5erV4f7QvnqBOLlGb2UbFwLXw/BV8cNLm0QLSxVgFKhkyVFZq+gHRG5pr", + "eAxgBGX3IYMgSzZ5oEY1g6ZrNrTqn98Z8UTk986lXY5K+OKm37vJuxNViNRCdzAlzCoofn4OgPex4djF", + "ueIOaxONk8KCjCwoo5OArcFVBJbTEQ95tyoWJmQw/TUFTI/RxQVgjttSI0nm8tGxGyvM13umkh0MYlJx", + "/mlBwHei7NePVmddf8Xx5HSUrIs0HsZPrKlF1l+YcsQl6y5ZWpzms94SqBgBWQ5IuHoute+seNdq+/4d", + "h1iLPafXbwXR4i1vavG9KOp94Lnrl60FejeSy9aC3SULTkT+diEKnHZ02Zq3o9W/lae6ON3kOrYAK0Ky", + "D61FNnQr8u8QCLBQbZpdk8yzK3IlZLAPydk/YID+WAur+NI5Zm4u0CP7jdEGQakih6g5UqVo83jLW3x9", + "7rxF1zTDEApa7YB0iBc5tgsFud+wMqFt9OspbQmPgR23kcggt2QwE1bW81N8feT7ra7trbeoStbtRaHW", + "BHgfeGAyYSd496uKOPWj1ZuUIUKTtgfb9hnfQ7Zh9et5XzJO0CftpGz4WNbX+lEW7JGdVx+BMvT5Y+/j", + "9xV9xyy8k43iQIi2mTu5FsPgrREe88tkWz4lIsCLad37rLz6hRKKqkqkc7brlhHqiutWo9WXJ42IDeuC", + "yEhJJRL+4fJATwNCCzJk/kHyaTmRmMg0y/4bGnH+BFb1hQzileOSKYjgPJkWchY8N3ilpXY0dI+A2MgG", + "VuiJUXiQh4VdRmIwfs+mJGfy9+CyyAI6lrKV6e+HvOZyL/lfyY5SlySnE6DrBWsoQFIfJI/oP2VP5a4+", + "egNEYmJGr40hhaTiNKRUObZz/eKYTbcQTOHMBPBX7NzscBk4yBC8fkn8R9h0DvibV1iwQY7x4LT9NTlR", + "qDwxPfk1cyHto04g8tThTZimLhecgfUvpSAj1H0zRSmBc74fDy6pVUDglJbqKg6sxbVFTGFJW10dxoPp", + "y7c82dugVOyx9J3F0PkloVqIcE9QmvGZeyuOd0/2uf73c1fabeKeu0E6TkDakcHJeNXHLoA1p82vjZXb", + "XlOKvPE1vyR6K+u1ubkm78LxxutN+uf5C3Nzt09kYjngOb5Llnqn93j9HuQvtAeiXFT/gcuUOmZY/SOy", + "NK83caRDR9a4m96Ye6rIfBYOAnduWmfEELi3IXVgx5qC55l33+lMc9xUue4sTWLYgzviWbyXbGtgwvww", + "entNuM/rF9rw8jdfRaVYOnJBkv43EK5hgNf1GNyZxtWwadjPzGBNW1NUkFkX5+Zkduq59jo2bpb5CTsh", + "m9yd1yeKZXCdGEkokmTqIOG/C+15VJRGl5KqSwrRz1kHPWux77R6AVkOFy0EIlbOSbKpIhjzRL6DeHQ5", + "g0ahlAAes7fjI5kSa1N15nUy0UzJ6+QVWa0GFjElSNbNOca0yB3QlfebWjTzEYQuqG3wK+wQpihSwFzK", + "CZxd8v0ojAK7d05yfRTI+H+XU4qxIGCx2w+jVnvV9lZIzoEi5z3ugZeIJQFQXQa76A1SzOwjTCZOLc6n", + "8dHMLS/tL8raz+RMwExGMkPr3k22ub7EQzclmq4WZ4VEb3IqXZWINBmu1w31ffXbYEkTEx6KJHkucCTB", + "7m5WIH19XT5YGi4HhtuM93OdHWQnZCY7N809FyVaTKcpOUqsS38heu1b/JkfdOuqG8RuR6QDkYKq1VR1", + "bO9v1UTPKt0O277rEtY3L7Nfq04Y+Vidbqo0ueoSO3iPPVfJP/W15Jk7qgvdZSRhESqZrY8hmibr09lM", + "17TVaC5fV42pyXQy46KntPgBsy9bRPU6QInkNTUmUbOTjWnTp+TwUnH0/n/JopNu4R9qkkK+t+TbQcfx", + "VkDr6ms2asFeIx+kj01G70gHuLrqOxxSvkzZeN1QRi0j+B7V1elMwJMw5Jq06VyugAD8dzy8CHe7ogkw", + "eFe+ZcesB5giEe/aa/k7Xqkg0jeyafeDgHhRiz+6iBiNO1QfEQHlw6YV72OAMg2fPwT1SFGn05dMiTgm", + "Rq8ygVGWf5uGsKZTvblUVb6sKt1Q1Z+LZtD316NQX4W1KeVHE6jOVQb5fmrQinUk/MPUrDwrhflb1cbU", + "V0VpdF3u0TkXRqyHu+mKmWdPLkQThqdUBzKB6CvdEi/nuoEew+mE0vhh8nnyBXOFSKYEuE43k52aMlqU", + "ZFfWeCrT0VHMiQo1nvDCUg6fw4U2AmtX6URncL819TfMfF+zTxMAypfHGKOx1FkzieI3g+Blfl8mc6mV", + "7r9rh9E5xOi963hhFS8rHFbXCaPijLZ58dSE6e9CH2d9ftlCv90GxPcTEtdgzhjavQN+Ap/SBHmevv6V", + "9FETazsrUhsYryKkg7IVZXom/qRTN6sU3WPuhBOwz3y7qtQyOJ1qeGgGYODbhisHvftndPhwsFdx2bzC", + "0xdQy9jxvXAW85lLDyF4u8CQvsF+WjV/rcagI3sZr3cexPug4DxuVGXhsaAlteY2ifJEmEQKEL5+jIvh", + "zKxt5twQmWNKhb2UmV/NxRGSMOQgCUb14/r6An9skn1McIxqzXJTs7nAEVdL2YQAwOHvT+31KV1e4XSx", + "dcYJXnrib5A1/w4RpG+czX1w6pTYurr88JcK3ZsP+phVeeVJPyygvOrK0yalLXLCSZ2OARKDg2flPFmX", + "jFkDltzJnPmQP2UYMi8tBAoZZtxf1HrFHHsswoHmGIp7G0swH2LXBGhzYUgky3gkJ3RgpVG0yDHgsBiB", + "tQb1o6MJmWpf5yFKUggV2EpN+QnDInkG09ug03sCLUtexiNww7HCFYYOkPYoGRS6IUNiB5gcomcxeuL+", + "1nalZtq4kZuQ/MiD95g1KHdr4rXMHNwT2wXFo3gvHvDER+jC8pRhzwD6CCYyUrbdh+KHB7zTB5VoxzAF", + "AasP7vdkG9pAeuRe1GpDgEDf3QRXWRkB0yyRJETMC3NzlSEx4TFtFmHDhmx1lkfI/mJFZPRfvFiQlZHd", + "1g75Q4qx4F5g6llYrfVXPbn2WkRO9NXLZ4SdCM0pkfsX6bOE/1lq5M4dfRCNS7YsGPehImYPQQfaQ1Wr", + "mUHTlJLeWQ6v6LplxXtQRfyU3iUA8AWreAnpBvv0zfgHPW/YIH4j2b7lTX3355XVpU7kfffS+suvv7S+", + "+zNDZQBMju9eThshPFEeyTD9PFVJWiO0JGz7QUDaEem04AQUnEbW9OcsDuWrZWdoK8Tuy+d1tZT4MtkG", + "8bkHgRfMCBTBJ6pwytyNsnsvPrJe018tQ6qUvqZm8h7Fu03rYqqfH8W7packWCPBOcdb9s1HhE78udoC", + "QtwHA9Zj6xBSC36FQGB4M1yysACFsqgAbZDapGFqoYw98litqx8Azhg8fsC6Nsk9zjLV+rwSKFeFz2tU", + "WeIc6NS/gdsK+kdqmgcmO9MGpWcB6HWNkmuiDCpG0RspuxAyEP3FjjJ4BPWnj5cOKbSYDBaKzH/rYUS6", + "Gf4DxIg6CjmUhgPCQM0CyRcUi41EVYd1/oKVPJy+bKkVGgcIhKMWa6DpsPgx1LqerFDjksV0b6ZXcSz8", + "fC88gT7DbkWm0u1RMf5Brx/i7xhqxUArx4FK4N6ovwakfyrD8Pvej6FZBX298FQD7X/et116wl5Ng4f6", + "JUPaO0Jz6irgoeijVsxjucTT4PUBnHeZpy6cZLstGOFaJzxr73FFHAZefw8qJ1MlJQ0vHlwW3SulvlmS", + "V+Dz+kpjpUlhv0WeWZNsWU4HClLp5S1XlSploFJrmNJqpIxP2xS24aJusttUtEU1EnaCIBQSOWdXiOf0", + "w1KqvouPTZC2OMJV9ioNja/a7VXSsdz1wGmHlu11LNvz/AiBWy9bHlkjgdW17xD6pdXvoZJgBSIbaHIE", + "lXqCGkopfot6x3FatgWN5RRoOYCRiPoh6jVTUT+0zs/Mzcw1VXD3996+8pY1a81fuXn1PcgsQ2ffCC2I", + "N1634n+Lv5hGjEVUgVKFehdSToeg1f9l47e3vEU29ZmP+6RPWst91120Zi3xcT8kQevjvh/Z+F2T2rZb", + "6NbCCfMnRdPPRVE5KwYeWOht2kVPWEEHh7NsqPiKOjlIM2BNHYz5YlJBHmvhUI8/PNfEgfcvZOj6qLwy", + "lV9F2Bd7emRNia4lTataZx59x8M0YKGSQGZ0dslI8KOAGCNQWJItnuy5EY+SL+IDpREZ/S09TlPQdwD8", + "L0fgRN5gDQkG1gc94l2Zvzadr3z7yIlWO4F9V+pMWKEy4AG6afDua8pkg5t6F4yKI/C+1lg+IEblQQ56", + "4r4QcAwyvieacegbZrGWkdK4I93BWoXkbNfxSEl0sOb+yf+vdxnW7W4tbYv+DaHbNO9Pdk5x/HsBaUOT", + "ffN9+VXWGZsVSQjSOGMtvHfl3IWLb4BJL7qSyqY8b2vKbBIGmjzV8dshn9BMt/NXr2tO/jyb51l0huNj", + "vdLecOkkvgfd4XhbecmLiHgGXAGBeOYe9+Q/BZ54jj6/E7MmqDpF2jAS6ufw2MT3A4fRbcMfODYZxkGB", + "Aiwxh5Vz421Tx0b8nult2Y6eRe3HT7wBISGe2Qlw3Q74aVygD1a+jClBHtZ702Jj3ZF005plpbDQDygl", + "sU8sr9KvSpm+x2mj5c0P4Wskzluk7dSSXqO2Bm/7ffyRwKuc07nglK7f+JtKXb//k+GH1JRalg2AcyWH", + "YTPI4Pj6XcMOEmzrPmNlxCxM/hyvlBMw+l17jcz6vagwSepdEn1kr5EPetGkk6TkYSokXdSVjYOyaoS+", + "+Az8OgcX4WEakTXhnP+Jx1pusiyEsiouILackqYXKwuRHQDBJ3TF01fDGHrwr/O1DiSB1hQlWknNHUXd", + "0cPT4RGlW/wVvAytkO/+TPXI5HHaBmjru5fVd600r+1tryO2rvQu+NdXkV4m2h/lV92spd+/Ltt6wuwM", + "jrp7Z66nFjN4BtyuNnbOVM6lyhbVdWY18otlj1hUJUoeCOeEcOlwSB4s6Mf2XTzcPubRmPXoNkyi6/7P", + "yL1JC8WfMRZ6dSzzTb18AvV9yRbkfKh8CEBNvDM0dKeSUt6pTsIARrcVcJUU81rPFqvEdqPVXxYpEu+x", + "RyYJsgBDoN+lAGvr1ygULQwSFoca/wXiiCL941h+gZoPJmcaBMTurBdS4wY+MVmQgs56AS0YvtFzllKw", + "mea5MB++3JKFcuXFudfObHJfQ3WqSD7Q5JJqJz5EBh7mp1+0x/8EZuURNoPlO30UP8ESeR5KTmUuT9bb", + "tqYqkm9ayyjqnD5pLBE7IMGVfrRKp0gFIqZKoSDtB27jUmMWBCV71ycirwDfeb8pPgH8UunvLlG+hZ59", + "0geusxRQakgf8eCU9BHL5pI+EZF0+YdpVYb0KQgLeQq9XuP+7fv/NwAA//9E86lIS20BAA==", } // 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;