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>
This commit is contained in:
@@ -21,6 +21,10 @@ PORT=3000
|
|||||||
|
|
||||||
# Обогащение треков (Discogs): личный токен из discogs.com → Settings → Developers
|
# Обогащение треков (Discogs): личный токен из discogs.com → Settings → Developers
|
||||||
DISCOGS_TOKEN=
|
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)
|
# Базовый публичный URL бэкенда — для абсолютных ссылок на обложки (/covers/*.webp)
|
||||||
PUBLIC_BASE_URL=http://121.127.37.212:3000
|
PUBLIC_BASE_URL=http://121.127.37.212:3000
|
||||||
# Каталог для сохранённых обложек (в docker — volume /data/covers)
|
# Каталог для сохранённых обложек (в docker — volume /data/covers)
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ services:
|
|||||||
- DISCOGS_TOKEN2=${DISCOGS_TOKEN2}
|
- DISCOGS_TOKEN2=${DISCOGS_TOKEN2}
|
||||||
- DISCOGS_TOKEN3=${DISCOGS_TOKEN3}
|
- DISCOGS_TOKEN3=${DISCOGS_TOKEN3}
|
||||||
- DISCOGS_PROXY=${DISCOGS_PROXY}
|
- DISCOGS_PROXY=${DISCOGS_PROXY}
|
||||||
|
# Распознавание треков (shazam-api.com). Ключ — только в .env на сервере.
|
||||||
|
- SHAZAM_API_KEY=${SHAZAM_API_KEY}
|
||||||
- COVERS_DIR=/data/covers
|
- COVERS_DIR=/data/covers
|
||||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000}
|
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -8,102 +8,106 @@ export interface RecognitionResult {
|
|||||||
album: string | null;
|
album: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Изолированный адаптер к внешнему сервису распознавания (shazam-api.com).
|
* Адаптер к shazam-api.com. API асинхронный (две стадии):
|
||||||
* Единственное место, зависящее от формата их API. Настройки — через env:
|
* 1) POST {base}/recognize (multipart: file) → { uuid, status:"processing" }
|
||||||
* SHAZAM_API_URL — эндпоинт распознавания (POST, multipart/form-data)
|
* 2) POST {base}/results/{uuid} — поллим, пока status != "completed";
|
||||||
* SHAZAM_API_KEY — ключ (в git НЕ коммитим, только env на сервере)
|
* результат: results[0].track.{title, subtitle(=исполнитель)} (обложки нет).
|
||||||
* SHAZAM_API_AUTH — схема заголовка: 'bearer' | 'apikey-header' (по умолч. bearer)
|
|
||||||
* SHAZAM_API_FIELD — имя файлового поля в форме (по умолч. 'file')
|
|
||||||
*
|
*
|
||||||
* ⚠️ TODO(уточнить по докам shazam-api.com из ЛК):
|
* Авторизация: заголовок `Authorization: Bearer <key>`.
|
||||||
* — точный URL и имя файлового поля;
|
* Настройки через env:
|
||||||
* — заголовок авторизации;
|
* SHAZAM_API_KEY — ключ (ОБЯЗАТЕЛЬНО; в git НЕ коммитим, только env на сервере)
|
||||||
* — реальная форма JSON-ответа (маппинг в RecognitionResult ниже —
|
* SHAZAM_API_URL — база API (необязательно, по умолч. https://shazam-api.com/api)
|
||||||
* защитный, покрывает типовые варианты; поправить под факт).
|
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ShazamClient {
|
export class ShazamClient {
|
||||||
private readonly logger = new Logger(ShazamClient.name);
|
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) {}
|
constructor(private readonly config: ConfigService) {}
|
||||||
|
|
||||||
isConfigured(): boolean {
|
isConfigured(): boolean {
|
||||||
return Boolean(
|
return Boolean(this.config.get<string>('SHAZAM_API_KEY'));
|
||||||
this.config.get<string>('SHAZAM_API_URL') &&
|
}
|
||||||
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, если сервис ничего
|
* Распознать трек по аудио-фрагменту. null — сервис ничего не нашёл
|
||||||
* не нашёл (тишина/реклама/джингл). Бросает, если не настроен или сервис упал.
|
* (тишина/реклама/джингл) или не успел за бюджет поллинга. Бросает при
|
||||||
|
* сетевой ошибке / отказе API (401/403/4xx-5xx).
|
||||||
*/
|
*/
|
||||||
async recognize(
|
async recognize(
|
||||||
audio: Buffer,
|
audio: Buffer,
|
||||||
contentType = 'audio/mpeg',
|
contentType = 'audio/mpeg',
|
||||||
): Promise<RecognitionResult | null> {
|
): Promise<RecognitionResult | null> {
|
||||||
const url = this.config.get<string>('SHAZAM_API_URL');
|
const uuid = await this.submit(audio, contentType);
|
||||||
const key = this.config.get<string>('SHAZAM_API_KEY');
|
return this.pollResult(uuid);
|
||||||
if (!url || !key) {
|
|
||||||
throw new Error('Shazam API is not configured (SHAZAM_API_URL/KEY)');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const fieldName = this.config.get<string>('SHAZAM_API_FIELD') ?? 'file';
|
/** Стадия 1: загрузка аудио, получение uuid задачи. */
|
||||||
const authScheme =
|
private async submit(audio: Buffer, contentType: string): Promise<string> {
|
||||||
this.config.get<string>('SHAZAM_API_AUTH') ?? 'bearer';
|
|
||||||
|
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
const blob = new Blob([new Uint8Array(audio)], { type: contentType });
|
const blob = new Blob([new Uint8Array(audio)], { type: contentType });
|
||||||
form.append(fieldName, blob, 'sample.mp3');
|
form.append('file', blob, 'sample.mp3');
|
||||||
|
|
||||||
const headers: Record<string, string> =
|
const res = await fetch(`${this.base()}/recognize`, {
|
||||||
authScheme === 'apikey-header'
|
method: 'POST',
|
||||||
? { 'x-api-key': key }
|
headers: this.authHeader(),
|
||||||
: { Authorization: `Bearer ${key}` };
|
body: form,
|
||||||
|
});
|
||||||
const res = await fetch(url, { method: 'POST', headers, body: form });
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const body = await res.text().catch(() => '');
|
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 { uuid?: string };
|
||||||
|
if (!data?.uuid) throw new Error('Shazam recognize: нет uuid в ответе');
|
||||||
|
return data.uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await res.json()) as unknown;
|
/** Стадия 2: поллинг результата по uuid до status="completed". */
|
||||||
return this.parse(data);
|
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 {
|
||||||
* Защитный разбор ответа: ищем artist/title/cover в типовых местах. Поправить
|
status?: string;
|
||||||
* под реальную схему, когда будет дока. Если ничего не нашли — null (нет матча).
|
results?: Array<{ track?: { title?: string; subtitle?: string } }>;
|
||||||
*/
|
|
||||||
private parse(data: any): RecognitionResult | null {
|
|
||||||
if (!data || typeof data !== 'object') return null;
|
|
||||||
|
|
||||||
// Возможные обёртки: { track: {...} } | { result: {...} } | сам объект
|
|
||||||
const t = data.track ?? data.result ?? data.matches?.[0] ?? data;
|
|
||||||
|
|
||||||
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 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user