Files
radiola-backend/src/shazam/shazam.client.ts
nk 059ebc9c45 feat(shazam): реальный двухстадийный флоу shazam-api.com (recognize → poll)
- 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>
2026-06-07 18:45:00 +03:00

114 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}