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

View File

@@ -9,6 +9,7 @@ import { NowPlayingModule } from './now-playing/now-playing.module';
import { HealthCheckModule } from './health-check/health-check.module'; import { HealthCheckModule } from './health-check/health-check.module';
import { ChartsModule } from './charts/charts.module'; import { ChartsModule } from './charts/charts.module';
import { AppVersionModule } from './app-version/app-version.module'; import { AppVersionModule } from './app-version/app-version.module';
import { ShazamModule } from './shazam/shazam.module';
@Module({ @Module({
imports: [ imports: [
@@ -22,6 +23,7 @@ import { AppVersionModule } from './app-version/app-version.module';
HealthCheckModule, HealthCheckModule,
ChartsModule, ChartsModule,
AppVersionModule, AppVersionModule,
ShazamModule,
], ],
}) })
export class AppModule {} export class AppModule {}

View File

@@ -1,18 +1,13 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { EnrichmentService } from '../enrich/enrichment.service'; import { EnrichmentService } from '../enrich/enrichment.service';
import { NON_MUSIC_GENRES } from '../common/station-classification';
// Жанры, исключённые из чарта: разговорные/шуточные/без названий треков. // Жанры, исключённые из чарта: разговорные/шуточные/без названий треков.
// Их «треки» — это названия передач/реприз/спектаклей, не музыка. // Их «треки» — это названия передач/реприз/спектаклей, не музыка.
const EXCLUDED_CHART_GENRES = [ // Единый список — в station-classification.ts (там же используется для флага
'Станция Кассиопея', // `musical` станции, по которому клиент показывает кнопку распознавания).
'Юмор ФМ', const EXCLUDED_CHART_GENRES: string[] = [...NON_MUSIC_GENRES];
'Рассказы',
'Радио Вера',
'Comedy Radio',
'ВГТРК',
'Старое радио',
];
// Период чарта // Период чарта
export type ChartPeriod = 'day' | 'week' | 'month' | 'all'; export type ChartPeriod = 'day' | 'week' | 'month' | 'all';

View File

@@ -0,0 +1,28 @@
/**
* Единый признак «музыкальная ли станция». Используется в двух местах:
* • ChartsService — НЕ засчитывать «треки» разговорных станций в чарт;
* • StationsService — выставить флаг `musical` в ответе /stations, по которому
* клиент показывает кнопку «Распознать трек» (Shazam) только для музыки.
*
* Жанры разговорных/юмористических/новостных станций: их «треки» — это названия
* передач/реприз/спектаклей, не музыка, распознавать там нечего.
*
* ⚠️ Добавил разговорную станцию — впиши её genre сюда (одно место на весь проект).
*/
export const NON_MUSIC_GENRES = [
'Станция Кассиопея',
'Юмор ФМ',
'Рассказы',
'Радио Вера',
'Comedy Radio',
'ВГТРК',
'Старое радио',
] as const;
const NON_MUSIC_SET = new Set<string>(NON_MUSIC_GENRES);
/** true — на станции играет музыка (а не разговор/юмор/новости). */
export function isMusicStation(genre?: string | null): boolean {
if (!genre) return true; // без жанра считаем музыкальной (консервативно)
return !NON_MUSIC_SET.has(genre.trim());
}

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

View File

@@ -0,0 +1,17 @@
import { Controller, Post, Param, ParseIntPipe } from '@nestjs/common';
import { ApiTags, ApiOperation } from '@nestjs/swagger';
import { ShazamService } from './shazam.service';
@ApiTags('shazam')
@Controller('shazam')
export class ShazamController {
constructor(private readonly shazamService: ShazamService) {}
@Post('recognize/:stationId')
@ApiOperation({
summary: 'Распознать играющий сейчас трек на станции (по station_id)',
})
async recognize(@Param('stationId', ParseIntPipe) stationId: number) {
return this.shazamService.recognize(stationId);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ShazamController } from './shazam.controller';
import { ShazamService } from './shazam.service';
import { ShazamClient } from './shazam.client';
import { PrismaModule } from '../prisma/prisma.module';
@Module({
imports: [PrismaModule],
controllers: [ShazamController],
providers: [ShazamService, ShazamClient],
})
export class ShazamModule {}

View File

@@ -0,0 +1,106 @@
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
ServiceUnavailableException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { ShazamClient } from './shazam.client';
import { fetchStreamChunk } from './stream-audio';
import { isMusicStation } from '../common/station-classification';
export interface RecognizeResponse {
matched: boolean;
artist?: string;
song?: string;
coverUrl?: string | null;
album?: string | null;
}
interface CacheEntry {
at: number;
result: RecognizeResponse;
}
@Injectable()
export class ShazamService {
private readonly logger = new Logger(ShazamService.name);
// Кэш последнего результата по станции: троттлинг + экономия платных вызовов,
// когда несколько клиентов распознают одну станцию почти одновременно.
private readonly cache = new Map<number, CacheEntry>();
private readonly CACHE_TTL_MS = 15000;
constructor(
private readonly prisma: PrismaService,
private readonly shazam: ShazamClient,
) {}
async recognize(
stationId: number,
now: number = Date.now(),
): Promise<RecognizeResponse> {
if (!this.shazam.isConfigured()) {
throw new ServiceUnavailableException('Распознавание временно недоступно');
}
const cached = this.cache.get(stationId);
if (cached && now - cached.at < this.CACHE_TTL_MS) {
return cached.result;
}
const station = await this.prisma.station.findUnique({
where: { stationId },
select: { streamUrl: true, genre: true, name: true },
});
if (!station) throw new NotFoundException('Станция не найдена');
if (!isMusicStation(station.genre)) {
throw new BadRequestException('На этой станции нет музыки');
}
let result: RecognizeResponse;
try {
const audio = await fetchStreamChunk(station.streamUrl);
const match = await this.shazam.recognize(audio);
if (!match) {
result = { matched: false };
} else {
const coverUrl =
match.coverUrl ??
(await this.resolveCover(match.artist, match.title));
result = {
matched: true,
artist: match.artist,
song: match.title,
coverUrl,
album: match.album,
};
}
} catch (err) {
this.logger.error(
`Распознавание «${station.name}» не удалось: ${(err as Error).message}`,
);
throw new ServiceUnavailableException('Не удалось распознать трек');
}
this.cache.set(stationId, { at: now, result });
return result;
}
// Если у распознанного трека нет обложки от Shazam — пробуем взять обложку
// уже обогащённого трека из нашей БД (по тому же normKey, что и чарты).
private async resolveCover(
artist: string,
song: string,
): Promise<string | null> {
const normKey =
artist.trim().toLowerCase().replace(/\s+/g, ' ') +
'|' +
song.trim().toLowerCase().replace(/\s+/g, ' ');
const track = await this.prisma.track.findUnique({
where: { normKey },
select: { coverUrl: true },
});
return track?.coverUrl ?? null;
}
}

View File

@@ -0,0 +1,57 @@
/**
* Захват короткого аудио-фрагмента из вещательного потока (icecast/shoutcast,
* mp3/aac). Открываем поток, копим байты до целевого размера или таймаута, затем
* обрываем соединение. Этого фрагмента хватает Shazam для построения отпечатка.
*
* Размер по умолчанию рассчитан на ~6 сек при 128 kbps (≈96 KB). Точная
* длительность не важна — алгоритму распознавания достаточно нескольких секунд.
*/
export async function fetchStreamChunk(
streamUrl: string,
opts: { bytes?: number; timeoutMs?: number } = {},
): Promise<Buffer> {
const targetBytes = opts.bytes ?? 96 * 1024;
const timeoutMs = opts.timeoutMs ?? 12000;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(streamUrl, {
signal: controller.signal,
headers: {
// Часть icecast-серверов без User-Agent отдаёт 403/плейлист.
'User-Agent': 'radiOLA/1.0 (track recognition)',
// Просим НЕ слать ICY-метаданные — нам нужно чистое аудио.
'Icy-MetaData': '0',
},
});
if (!res.ok || !res.body) {
throw new Error(`Stream responded ${res.status}`);
}
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let collected = 0;
while (collected < targetBytes) {
const { done, value } = await reader.read();
if (done) break;
if (value) {
chunks.push(value);
collected += value.length;
}
}
// Обрываем чтение — больше байт не нужно.
await reader.cancel().catch(() => undefined);
if (collected === 0) {
throw new Error('Stream returned no audio');
}
return Buffer.concat(chunks);
} finally {
clearTimeout(timer);
}
}