- ShazamClient: POST /api/recognize (multipart file) → uuid, затем поллинг
POST /api/results/{uuid} до status="completed" (12×1.2с ≈ до 15с)
- из ответа берём track.title (песня) и track.subtitle (исполнитель); обложки
в API нет — подтягиваем из нашей БД по normKey (resolveCover в сервисе)
- авторизация Authorization: Bearer; база https://shazam-api.com/api по умолч.
- SHAZAM_API_KEY проброшен в docker-compose + .env.example (значение — на сервере)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
4.5 KiB
TypeScript
114 lines
4.5 KiB
TypeScript
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 <key>`.
|
||
* Настройки через 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<string>('SHAZAM_API_KEY'));
|
||
}
|
||
|
||
private base(): string {
|
||
return this.config.get<string>('SHAZAM_API_URL') ?? this.DEFAULT_BASE;
|
||
}
|
||
|
||
private authHeader(): Record<string, string> {
|
||
const key = this.config.get<string>('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<RecognitionResult | null> {
|
||
const uuid = await this.submit(audio, contentType);
|
||
return this.pollResult(uuid);
|
||
}
|
||
|
||
/** Стадия 1: загрузка аудио, получение uuid задачи. */
|
||
private async submit(audio: Buffer, contentType: string): Promise<string> {
|
||
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<RecognitionResult | null> {
|
||
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;
|
||
}
|
||
}
|