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:
@@ -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 {}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
28
src/common/station-classification.ts
Normal file
28
src/common/station-classification.ts
Normal 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
109
src/shazam/shazam.client.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/shazam/shazam.controller.ts
Normal file
17
src/shazam/shazam.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/shazam/shazam.module.ts
Normal file
12
src/shazam/shazam.module.ts
Normal 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 {}
|
||||||
106
src/shazam/shazam.service.ts
Normal file
106
src/shazam/shazam.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
57
src/shazam/stream-audio.ts
Normal file
57
src/shazam/stream-audio.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user