From 1616c231b72984385f2e751fb8d4482ca5bff790 Mon Sep 17 00:00:00 2001 From: nk Date: Sun, 7 Jun 2026 18:37:53 +0300 Subject: [PATCH] =?UTF-8?q?feat(shazam):=20=D1=80=D0=B0=D1=81=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=BD=D0=B0=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=82=D1=80?= =?UTF-8?q?=D0=B5=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20Shazam=20?= =?UTF-8?q?API=20=D0=B4=D0=BB=D1=8F=20=D1=81=D1=82=D0=B0=D0=BD=D1=86=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B1=D0=B5=D0=B7=20=D0=BC=D0=B5=D1=82=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - новый модуль 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 --- src/app.module.ts | 2 + src/charts/charts.service.ts | 13 +--- src/common/station-classification.ts | 28 +++++++ src/shazam/shazam.client.ts | 109 +++++++++++++++++++++++++++ src/shazam/shazam.controller.ts | 17 +++++ src/shazam/shazam.module.ts | 12 +++ src/shazam/shazam.service.ts | 106 ++++++++++++++++++++++++++ src/shazam/stream-audio.ts | 57 ++++++++++++++ 8 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 src/common/station-classification.ts create mode 100644 src/shazam/shazam.client.ts create mode 100644 src/shazam/shazam.controller.ts create mode 100644 src/shazam/shazam.module.ts create mode 100644 src/shazam/shazam.service.ts create mode 100644 src/shazam/stream-audio.ts diff --git a/src/app.module.ts b/src/app.module.ts index 90369f3..eacc2fa 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { NowPlayingModule } from './now-playing/now-playing.module'; import { HealthCheckModule } from './health-check/health-check.module'; import { ChartsModule } from './charts/charts.module'; import { AppVersionModule } from './app-version/app-version.module'; +import { ShazamModule } from './shazam/shazam.module'; @Module({ imports: [ @@ -22,6 +23,7 @@ import { AppVersionModule } from './app-version/app-version.module'; HealthCheckModule, ChartsModule, AppVersionModule, + ShazamModule, ], }) export class AppModule {} diff --git a/src/charts/charts.service.ts b/src/charts/charts.service.ts index 03b4fbc..19d2969 100644 --- a/src/charts/charts.service.ts +++ b/src/charts/charts.service.ts @@ -1,18 +1,13 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { EnrichmentService } from '../enrich/enrichment.service'; +import { NON_MUSIC_GENRES } from '../common/station-classification'; // Жанры, исключённые из чарта: разговорные/шуточные/без названий треков. // Их «треки» — это названия передач/реприз/спектаклей, не музыка. -const EXCLUDED_CHART_GENRES = [ - 'Станция Кассиопея', - 'Юмор ФМ', - 'Рассказы', - 'Радио Вера', - 'Comedy Radio', - 'ВГТРК', - 'Старое радио', -]; +// Единый список — в station-classification.ts (там же используется для флага +// `musical` станции, по которому клиент показывает кнопку распознавания). +const EXCLUDED_CHART_GENRES: string[] = [...NON_MUSIC_GENRES]; // Период чарта export type ChartPeriod = 'day' | 'week' | 'month' | 'all'; diff --git a/src/common/station-classification.ts b/src/common/station-classification.ts new file mode 100644 index 0000000..c357574 --- /dev/null +++ b/src/common/station-classification.ts @@ -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(NON_MUSIC_GENRES); + +/** true — на станции играет музыка (а не разговор/юмор/новости). */ +export function isMusicStation(genre?: string | null): boolean { + if (!genre) return true; // без жанра считаем музыкальной (консервативно) + return !NON_MUSIC_SET.has(genre.trim()); +} diff --git a/src/shazam/shazam.client.ts b/src/shazam/shazam.client.ts new file mode 100644 index 0000000..8101256 --- /dev/null +++ b/src/shazam/shazam.client.ts @@ -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('SHAZAM_API_URL') && + this.config.get('SHAZAM_API_KEY'), + ); + } + + /** + * Распознать трек по аудио-фрагменту. Возвращает null, если сервис ничего + * не нашёл (тишина/реклама/джингл). Бросает, если не настроен или сервис упал. + */ + async recognize( + audio: Buffer, + contentType = 'audio/mpeg', + ): Promise { + const url = this.config.get('SHAZAM_API_URL'); + const key = this.config.get('SHAZAM_API_KEY'); + if (!url || !key) { + throw new Error('Shazam API is not configured (SHAZAM_API_URL/KEY)'); + } + + const fieldName = this.config.get('SHAZAM_API_FIELD') ?? 'file'; + const authScheme = + this.config.get('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 = + 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, + }; + } +} diff --git a/src/shazam/shazam.controller.ts b/src/shazam/shazam.controller.ts new file mode 100644 index 0000000..9727deb --- /dev/null +++ b/src/shazam/shazam.controller.ts @@ -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); + } +} diff --git a/src/shazam/shazam.module.ts b/src/shazam/shazam.module.ts new file mode 100644 index 0000000..f902ab0 --- /dev/null +++ b/src/shazam/shazam.module.ts @@ -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 {} diff --git a/src/shazam/shazam.service.ts b/src/shazam/shazam.service.ts new file mode 100644 index 0000000..3e7642c --- /dev/null +++ b/src/shazam/shazam.service.ts @@ -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(); + private readonly CACHE_TTL_MS = 15000; + + constructor( + private readonly prisma: PrismaService, + private readonly shazam: ShazamClient, + ) {} + + async recognize( + stationId: number, + now: number = Date.now(), + ): Promise { + 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 { + 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; + } +} diff --git a/src/shazam/stream-audio.ts b/src/shazam/stream-audio.ts new file mode 100644 index 0000000..5953504 --- /dev/null +++ b/src/shazam/stream-audio.ts @@ -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 { + 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); + } +}