import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; export interface RecognitionResult { artist: string; title: string; coverUrl: string | null; album: string | null; } const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); /** * Адаптер к 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(=исполнитель)} (обложки нет). * * Авторизация: заголовок `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_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 — сервис ничего не нашёл * (тишина/реклама/джингл) или не успел за бюджет поллинга. Бросает при * сетевой ошибке / отказе API (401/403/4xx-5xx). */ async recognize( audio: Buffer, contentType = 'audio/mpeg', ): Promise { 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('file', blob, 'sample.mp3'); 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 recognize ${res.status}: ${body.slice(0, 200)}`); } const data = (await res.json()) as { uuid?: string }; if (!data?.uuid) throw new Error('Shazam recognize: нет uuid в ответе'); return data.uuid; } /** Стадия 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); 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 data = (await res.json()) as { status?: string; results?: Array<{ track?: { title?: string; subtitle?: string } }>; }; 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; } }