diff --git a/.env.example b/.env.example index f2925ce..4057e5e 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,10 @@ PORT=3000 # Обогащение треков (Discogs): личный токен из discogs.com → Settings → Developers DISCOGS_TOKEN= +# Распознавание треков (shazam-api.com): ключ из ЛК (Authorization: Bearer) +SHAZAM_API_KEY= +# База API Shazam (необязательно, по умолчанию https://shazam-api.com/api) +# SHAZAM_API_URL=https://shazam-api.com/api # Базовый публичный URL бэкенда — для абсолютных ссылок на обложки (/covers/*.webp) PUBLIC_BASE_URL=http://121.127.37.212:3000 # Каталог для сохранённых обложек (в docker — volume /data/covers) diff --git a/docker-compose.yml b/docker-compose.yml index 001e75f..8cc8694 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,8 @@ services: - DISCOGS_TOKEN2=${DISCOGS_TOKEN2} - DISCOGS_TOKEN3=${DISCOGS_TOKEN3} - DISCOGS_PROXY=${DISCOGS_PROXY} + # Распознавание треков (shazam-api.com). Ключ — только в .env на сервере. + - SHAZAM_API_KEY=${SHAZAM_API_KEY} - COVERS_DIR=/data/covers - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000} volumes: diff --git a/src/shazam/shazam.client.ts b/src/shazam/shazam.client.ts index 8101256..d8f65a1 100644 --- a/src/shazam/shazam.client.ts +++ b/src/shazam/shazam.client.ts @@ -8,102 +8,106 @@ export interface RecognitionResult { album: string | null; } +const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); + /** - * Изолированный адаптер к внешнему сервису распознавания (shazam-api.com). - * Единственное место, зависящее от формата их API. Настройки — через env: - * SHAZAM_API_URL — эндпоинт распознавания (POST, multipart/form-data) - * SHAZAM_API_KEY — ключ (в git НЕ коммитим, только env на сервере) - * SHAZAM_API_AUTH — схема заголовка: 'bearer' | 'apikey-header' (по умолч. bearer) - * SHAZAM_API_FIELD — имя файлового поля в форме (по умолч. 'file') + * Адаптер к shazam-api.com. API асинхронный (две стадии): + * 1) POST {base}/recognize (multipart: file) → { uuid, status:"processing" } + * 2) POST {base}/results/{uuid} — поллим, пока status != "completed"; + * результат: results[0].track.{title, subtitle(=исполнитель)} (обложки нет). * - * ⚠️ TODO(уточнить по докам shazam-api.com из ЛК): - * — точный URL и имя файлового поля; - * — заголовок авторизации; - * — реальная форма JSON-ответа (маппинг в RecognitionResult ниже — - * защитный, покрывает типовые варианты; поправить под факт). + * Авторизация: заголовок `Authorization: Bearer `. + * Настройки через env: + * SHAZAM_API_KEY — ключ (ОБЯЗАТЕЛЬНО; в git НЕ коммитим, только env на сервере) + * SHAZAM_API_URL — база API (необязательно, по умолч. https://shazam-api.com/api) */ @Injectable() export class ShazamClient { private readonly logger = new Logger(ShazamClient.name); + private readonly DEFAULT_BASE = 'https://shazam-api.com/api'; + // Бюджет поллинга: ~12 попыток × 1.2с ≈ до 15с ожидания распознавания. + private readonly POLL_ATTEMPTS = 12; + private readonly POLL_INTERVAL_MS = 1200; constructor(private readonly config: ConfigService) {} isConfigured(): boolean { - return Boolean( - this.config.get('SHAZAM_API_URL') && - this.config.get('SHAZAM_API_KEY'), - ); + return Boolean(this.config.get('SHAZAM_API_KEY')); + } + + private base(): string { + return this.config.get('SHAZAM_API_URL') ?? this.DEFAULT_BASE; + } + + private authHeader(): Record { + const key = this.config.get('SHAZAM_API_KEY'); + if (!key) throw new Error('Shazam API key is not configured'); + return { Authorization: `Bearer ${key}` }; } /** - * Распознать трек по аудио-фрагменту. Возвращает null, если сервис ничего - * не нашёл (тишина/реклама/джингл). Бросает, если не настроен или сервис упал. + * Распознать трек по аудио-фрагменту. null — сервис ничего не нашёл + * (тишина/реклама/джингл) или не успел за бюджет поллинга. Бросает при + * сетевой ошибке / отказе API (401/403/4xx-5xx). */ async recognize( audio: Buffer, contentType = 'audio/mpeg', ): Promise { - const url = this.config.get('SHAZAM_API_URL'); - const key = this.config.get('SHAZAM_API_KEY'); - if (!url || !key) { - throw new Error('Shazam API is not configured (SHAZAM_API_URL/KEY)'); - } - - const fieldName = this.config.get('SHAZAM_API_FIELD') ?? 'file'; - const authScheme = - this.config.get('SHAZAM_API_AUTH') ?? 'bearer'; + const uuid = await this.submit(audio, contentType); + return this.pollResult(uuid); + } + /** Стадия 1: загрузка аудио, получение uuid задачи. */ + private async submit(audio: Buffer, contentType: string): Promise { const form = new FormData(); const blob = new Blob([new Uint8Array(audio)], { type: contentType }); - form.append(fieldName, blob, 'sample.mp3'); + form.append('file', blob, 'sample.mp3'); - const headers: Record = - authScheme === 'apikey-header' - ? { 'x-api-key': key } - : { Authorization: `Bearer ${key}` }; - - const res = await fetch(url, { method: 'POST', headers, body: form }); + const res = await fetch(`${this.base()}/recognize`, { + method: 'POST', + headers: this.authHeader(), + body: form, + }); if (!res.ok) { const body = await res.text().catch(() => ''); - throw new Error(`Shazam API ${res.status}: ${body.slice(0, 200)}`); + throw new Error(`Shazam recognize ${res.status}: ${body.slice(0, 200)}`); } - - const data = (await res.json()) as unknown; - return this.parse(data); + const data = (await res.json()) as { uuid?: string }; + if (!data?.uuid) throw new Error('Shazam recognize: нет uuid в ответе'); + return data.uuid; } - /** - * Защитный разбор ответа: ищем artist/title/cover в типовых местах. Поправить - * под реальную схему, когда будет дока. Если ничего не нашли — null (нет матча). - */ - private parse(data: any): RecognitionResult | null { - if (!data || typeof data !== 'object') return null; + /** Стадия 2: поллинг результата по uuid до status="completed". */ + private async pollResult(uuid: string): Promise { + for (let i = 0; i < this.POLL_ATTEMPTS; i++) { + await delay(this.POLL_INTERVAL_MS); - // Возможные обёртки: { track: {...} } | { result: {...} } | сам объект - const t = data.track ?? data.result ?? data.matches?.[0] ?? data; + const res = await fetch(`${this.base()}/results/${uuid}`, { + method: 'POST', + headers: this.authHeader(), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Shazam results ${res.status}: ${body.slice(0, 200)}`); + } - const artist: string | undefined = - t.subtitle ?? t.artist ?? t.artists?.[0]?.name ?? t.creator; - const title: string | undefined = t.title ?? t.song ?? t.name; - if (!artist || !title) return null; + const data = (await res.json()) as { + status?: string; + results?: Array<{ track?: { title?: string; subtitle?: string } }>; + }; - const coverUrl: string | null = - t.images?.coverarthq ?? - t.images?.coverart ?? - t.coverUrl ?? - t.cover ?? - t.albumart ?? - null; - const album: string | null = - t.sections?.[0]?.metadata?.find((m: any) => m.title === 'Album')?.text ?? - t.album ?? - null; - - return { - artist: String(artist).trim(), - title: String(title).trim(), - coverUrl: coverUrl ? String(coverUrl) : null, - album: album ? String(album) : null, - }; + if (data.status === 'completed') { + const track = data.results?.[0]?.track; + const title = track?.title?.trim(); + const artist = track?.subtitle?.trim(); + if (!title || !artist) return null; // completed, но матча нет + return { artist, title, coverUrl: null, album: null }; + } + // status "processing" — ждём следующую попытку + } + // Не успели за бюджет — считаем, что не распознали. + this.logger.warn(`Shazam: поллинг ${uuid} истёк без результата`); + return null; } }