feat(shazam): распознавание трека через Shazam API для станций без метаданных

- новый модуль shazam: POST /shazam/recognize/:stationId — тянет ~6с аудио из
  потока станции, отдаёт в изолированный ShazamClient, возвращает artist/song/cover
- ShazamClient — адаптер к shazam-api.com, ключ из env (SHAZAM_API_KEY); точный
  контракт запроса/ответа помечен TODO до получения доки из ЛК
- кэш результата по станции (15с) — троттлинг + экономия платных вызовов
- общий реестр не-музыкальных жанров (common/station-classification.ts);
  charts.service переведён на него, shazam использует для гейта «есть ли музыка»

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-07 18:37:53 +03:00
parent 05e3796b85
commit 1616c231b7
8 changed files with 335 additions and 9 deletions

109
src/shazam/shazam.client.ts Normal file
View File

@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
export interface RecognitionResult {
artist: string;
title: string;
coverUrl: string | null;
album: string | null;
}
/**
* Изолированный адаптер к внешнему сервису распознавания (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')
*
* ⚠️ TODO(уточнить по докам shazam-api.com из ЛК):
* — точный URL и имя файлового поля;
* — заголовок авторизации;
* — реальная форма JSON-ответа (маппинг в RecognitionResult ниже —
* защитный, покрывает типовые варианты; поправить под факт).
*/
@Injectable()
export class ShazamClient {
private readonly logger = new Logger(ShazamClient.name);
constructor(private readonly config: ConfigService) {}
isConfigured(): boolean {
return Boolean(
this.config.get<string>('SHAZAM_API_URL') &&
this.config.get<string>('SHAZAM_API_KEY'),
);
}
/**
* Распознать трек по аудио-фрагменту. Возвращает null, если сервис ничего
* не нашёл (тишина/реклама/джингл). Бросает, если не настроен или сервис упал.
*/
async recognize(
audio: Buffer,
contentType = 'audio/mpeg',
): Promise<RecognitionResult | null> {
const url = this.config.get<string>('SHAZAM_API_URL');
const key = this.config.get<string>('SHAZAM_API_KEY');
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';
const authScheme =
this.config.get<string>('SHAZAM_API_AUTH') ?? 'bearer';
const form = new FormData();
const blob = new Blob([new Uint8Array(audio)], { type: contentType });
form.append(fieldName, blob, 'sample.mp3');
const headers: Record<string, string> =
authScheme === 'apikey-header'
? { 'x-api-key': key }
: { Authorization: `Bearer ${key}` };
const res = await fetch(url, { method: 'POST', headers, body: form });
if (!res.ok) {
const body = await res.text().catch(() => '');
throw new Error(`Shazam API ${res.status}: ${body.slice(0, 200)}`);
}
const data = (await res.json()) as unknown;
return this.parse(data);
}
/**
* Защитный разбор ответа: ищем artist/title/cover в типовых местах. Поправить
* под реальную схему, когда будет дока. Если ничего не нашли — null (нет матча).
*/
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,
};
}
}