feat(charts): сбор статистики проигрываний и API чартов

- модели Track / TrackPlay / TrackLike (+ миграция add_charts)
- сбор проигрываний в now-playing-поллере: при смене трека на станции
  пишется TrackPlay (нормализация artist+song -> Track), fire-and-forget
  обогащение через MusicBrainz (album/releaseDate)
- ChartsModule: GET /charts/tracks (период day/week/month/all, ранг, тренд,
  проигрывания, станции, лайки), GET /charts/tracks/:id (метрики, таймлайны
  популярности и лайков по дням, топ станций, isLiked), POST/DELETE like
- OptionalAuthGuard для публичной детальной страницы с опц. userId
This commit is contained in:
nk
2026-06-02 23:40:13 +03:00
parent bbfec76a7b
commit 38fe92d695
12 changed files with 716 additions and 16 deletions

View File

@@ -7,6 +7,7 @@ import { StationsModule } from './stations/stations.module';
import { UsersModule } from './users/users.module';
import { NowPlayingModule } from './now-playing/now-playing.module';
import { HealthCheckModule } from './health-check/health-check.module';
import { ChartsModule } from './charts/charts.module';
@Module({
imports: [
@@ -18,6 +19,7 @@ import { HealthCheckModule } from './health-check/health-check.module';
UsersModule,
NowPlayingModule,
HealthCheckModule,
ChartsModule,
],
})
export class AppModule {}

View File

@@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { AuthGuard } from './auth.guard';
import { OptionalAuthGuard } from './optional-auth.guard';
@Module({
imports: [
@@ -19,7 +20,7 @@ import { AuthGuard } from './auth.guard';
}),
],
controllers: [AuthController],
providers: [AuthService, AuthGuard],
exports: [AuthService, AuthGuard, JwtModule],
providers: [AuthService, AuthGuard, OptionalAuthGuard],
exports: [AuthService, AuthGuard, OptionalAuthGuard, JwtModule],
})
export class AuthModule {}

View File

@@ -0,0 +1,36 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
// Guard для публичных роутов: не требует токен, но достаёт userId если токен есть
@Injectable()
export class OptionalAuthGuard implements CanActivate {
constructor(
private readonly jwtService: JwtService,
private readonly config: ConfigService,
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const token = this.extractTokenFromHeader(request);
if (token) {
try {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.config.get<string>('JWT_SECRET'),
});
request['user'] = payload;
} catch {
// Невалидный токен — просто не устанавливаем user, не бросаем ошибку
}
}
return true;
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,74 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Query,
UseGuards,
Req,
HttpCode,
HttpStatus,
} from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ChartsService, ChartPeriod } from './charts.service';
import { AuthGuard } from '../auth/auth.guard';
import { OptionalAuthGuard } from '../auth/optional-auth.guard';
import type { Request } from 'express';
@ApiTags('charts')
@Controller('charts')
export class ChartsController {
constructor(private readonly chartsService: ChartsService) {}
@Get('tracks')
@ApiOperation({ summary: 'Чарт треков за период' })
async getTopTracks(
@Query('period') period: string = 'week',
@Query('limit') limit: string = '100',
) {
const validPeriod: ChartPeriod =
period === 'day' || period === 'week' || period === 'month' || period === 'all'
? (period as ChartPeriod)
: 'week';
const parsedLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 200);
return this.chartsService.getTopTracks(validPeriod, parsedLimit);
}
@Get('tracks/:trackId')
@UseGuards(OptionalAuthGuard)
@ApiOperation({ summary: 'Детальная страница трека' })
async getTrackDetail(
@Param('trackId') trackId: string,
@Req() req: Request,
) {
const user = req['user'] as { sub: string } | undefined;
return this.chartsService.getTrackDetail(trackId, user?.sub);
}
@Post('tracks/:trackId/like')
@UseGuards(AuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Лайкнуть трек' })
async likeTrack(
@Param('trackId') trackId: string,
@Req() req: Request,
) {
const user = req['user'] as { sub: string; email: string };
return this.chartsService.likeTrack(trackId, user.sub);
}
@Delete('tracks/:trackId/like')
@UseGuards(AuthGuard)
@ApiBearerAuth()
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: 'Убрать лайк с трека' })
async unlikeTrack(
@Param('trackId') trackId: string,
@Req() req: Request,
) {
const user = req['user'] as { sub: string; email: string };
return this.chartsService.unlikeTrack(trackId, user.sub);
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ChartsController } from './charts.controller';
import { ChartsService } from './charts.service';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [AuthModule],
controllers: [ChartsController],
providers: [ChartsService],
exports: [ChartsService],
})
export class ChartsModule {}

View File

@@ -0,0 +1,411 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
// Период чарта
export type ChartPeriod = 'day' | 'week' | 'month' | 'all';
// Тренд позиции в чарте
export type ChartTrend = 'up' | 'down' | 'new' | 'same';
export interface ChartEntry {
rank: number;
trackId: string;
artist: string;
song: string;
coverUrl: string | null;
plays: number;
stationsCount: number;
likes: number;
prevRank: number | null;
trend: ChartTrend;
}
interface RecordPlayParams {
artist: string;
song: string;
coverUrl?: string | null;
stationDbId: string;
}
// Строки из $queryRaw — Prisma возвращает bigint
interface RawCountRow {
track_id: string;
stations_count: bigint;
}
interface RawLikesRow {
track_id: string;
likes_count: bigint;
}
interface RawTimelineRow {
day: Date;
value: bigint;
}
interface RawStationRow {
station_id_int: number;
name: string;
plays: bigint;
}
@Injectable()
export class ChartsService {
private readonly logger = new Logger(ChartsService.name);
constructor(private readonly prisma: PrismaService) {}
// Возвращает метку начала периода
private periodStart(period: ChartPeriod): Date {
const now = new Date();
switch (period) {
case 'day':
return new Date(now.getTime() - 24 * 60 * 60 * 1000);
case 'week':
return new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
case 'month':
return new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
case 'all':
return new Date(0);
}
}
// Длительность периода в миллисекундах
private periodDuration(period: ChartPeriod): number {
switch (period) {
case 'day':
return 24 * 60 * 60 * 1000;
case 'week':
return 7 * 24 * 60 * 60 * 1000;
case 'month':
return 30 * 24 * 60 * 60 * 1000;
case 'all':
// для 'all' сравниваем с таким же окном (30 дней назад)
return 30 * 24 * 60 * 60 * 1000;
}
}
// Записывает факт смены трека на станции (вызывается из NowPlayingService)
async recordPlay(params: RecordPlayParams): Promise<void> {
try {
const { artist, song, coverUrl, stationDbId } = params;
// Нормализованный ключ: нижний регистр, схлопнуть пробелы
const normKey =
artist.trim().toLowerCase().replace(/\s+/g, ' ') +
'|' +
song.trim().toLowerCase().replace(/\s+/g, ' ');
const track = await this.prisma.track.upsert({
where: { normKey },
create: { normKey, artist, song, coverUrl: coverUrl ?? null },
update: { coverUrl: coverUrl ?? null },
});
await this.prisma.trackPlay.create({
data: { trackId: track.id, stationId: stationDbId },
});
this.logger.debug(`Записан трек: "${artist}${song}"`);
// Асинхронное обогащение нового трека (fire-and-forget)
if (!track.enrichedAt) {
void this.enrichTrack(track.id, artist, song);
}
} catch (error) {
// Ошибка сбора не должна ронять поллер
this.logger.error(`Ошибка записи проигрывания: ${error.message}`);
}
}
// Обогащение трека через MusicBrainz (fire-and-forget, best-effort)
private async enrichTrack(
trackId: string,
artist: string,
song: string,
): Promise<void> {
try {
const query = encodeURIComponent(`recording:"${song}" AND artist:"${artist}"`);
const url = `https://musicbrainz.org/ws/2/recording/?query=${query}&fmt=json&limit=1`;
const res = await fetch(url, {
headers: {
'User-Agent': 'radiOLA/1.0 ( blinnafeg@gmail.com )',
Accept: 'application/json',
},
});
if (!res.ok) return;
const data = (await res.json()) as {
recordings?: Array<{
releases?: Array<{
title: string;
date?: string;
}>;
}>;
};
const recording = data.recordings?.[0];
if (!recording) return;
const release = recording.releases?.[0];
const album = release?.title ?? null;
const releaseDate = release?.date ? new Date(release.date) : null;
// Проверяем, что дата валидна
const validDate =
releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate : null;
await this.prisma.track.update({
where: { id: trackId },
data: { album, releaseDate: validDate, enrichedAt: new Date() },
});
this.logger.debug(`Трек ${trackId} обогащён: альбом="${album}"`);
} catch (error) {
// Игнорируем ошибки обогащения — не критично
this.logger.debug(`Обогащение трека ${trackId} не удалось: ${error.message}`);
}
}
// Чарт треков за период
async getTopTracks(period: ChartPeriod, limit: number): Promise<{ items: ChartEntry[] }> {
const since = this.periodStart(period);
const duration = this.periodDuration(period);
const prevSince = new Date(since.getTime() - duration);
// Топ текущего периода: группировка по trackId
const currentGroups = await this.prisma.trackPlay.groupBy({
by: ['trackId'],
where: { playedAt: { gte: since } },
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
take: limit,
});
if (currentGroups.length === 0) {
return { items: [] };
}
const trackIds = currentGroups.map((g) => g.trackId);
// Число различных станций и лайков через $queryRaw
const stationsCounts = await this.prisma.$queryRaw<RawCountRow[]>`
SELECT track_id, COUNT(DISTINCT station_id)::bigint AS stations_count
FROM track_plays
WHERE track_id = ANY(${trackIds}::text[])
AND played_at >= ${since}
GROUP BY track_id
`;
const likesCounts = await this.prisma.$queryRaw<RawLikesRow[]>`
SELECT track_id, COUNT(*)::bigint AS likes_count
FROM track_likes
WHERE track_id = ANY(${trackIds}::text[])
GROUP BY track_id
`;
// Топ предыдущего периода для расчёта тренда
const prevGroups = await this.prisma.trackPlay.groupBy({
by: ['trackId'],
where: {
trackId: { in: trackIds },
playedAt: { gte: prevSince, lt: since },
},
_count: { id: true },
orderBy: { _count: { id: 'desc' } },
});
// Карты для быстрого доступа
const stationsMap = new Map(
stationsCounts.map((r) => [r.track_id, Number(r.stations_count)]),
);
const likesMap = new Map(
likesCounts.map((r) => [r.track_id, Number(r.likes_count)]),
);
// Позиции предыдущего периода (индекс 0 = ранг 1)
const prevRankMap = new Map<string, number>();
// Сортируем prev по убыванию count для присвоения рангов
const prevSorted = [...prevGroups].sort(
(a, b) => b._count.id - a._count.id,
);
prevSorted.forEach((g, idx) => {
prevRankMap.set(g.trackId, idx + 1);
});
// Получаем данные треков
const tracks = await this.prisma.track.findMany({
where: { id: { in: trackIds } },
select: { id: true, artist: true, song: true, coverUrl: true },
});
const tracksMap = new Map(tracks.map((t) => [t.id, t]));
const items: ChartEntry[] = currentGroups.map((g, idx) => {
const rank = idx + 1;
const prevRank = prevRankMap.get(g.trackId) ?? null;
let trend: ChartTrend;
if (prevRank === null) {
trend = 'new';
} else if (rank < prevRank) {
trend = 'up';
} else if (rank > prevRank) {
trend = 'down';
} else {
trend = 'same';
}
const track = tracksMap.get(g.trackId);
return {
rank,
trackId: g.trackId,
artist: track?.artist ?? '',
song: track?.song ?? '',
coverUrl: track?.coverUrl ?? null,
plays: g._count.id,
stationsCount: stationsMap.get(g.trackId) ?? 0,
likes: likesMap.get(g.trackId) ?? 0,
prevRank,
trend,
};
});
return { items };
}
// Детальная страница трека
async getTrackDetail(
trackId: string,
userId?: string,
): Promise<{
trackId: string;
artist: string;
song: string;
album: string | null;
coverUrl: string | null;
releaseDate: string | null;
firstSeen: string | null;
totalPlays: number;
totalLikes: number;
isLiked: boolean;
currentRank: number | null;
peakRank: number | null;
stations: Array<{ stationId: number; name: string; plays: number }>;
playsTimeline: Array<{ date: string; value: number }>;
likesTimeline: Array<{ date: string; value: number }>;
}> {
const track = await this.prisma.track.findUnique({
where: { id: trackId },
});
if (!track) {
throw new NotFoundException('Трек не найден');
}
// Суммарные проигрывания и лайки
const [totalPlaysResult, totalLikes] = await Promise.all([
this.prisma.trackPlay.count({ where: { trackId } }),
this.prisma.trackLike.count({ where: { trackId } }),
]);
// isLiked — только если авторизован
let isLiked = false;
if (userId) {
const like = await this.prisma.trackLike.findUnique({
where: { trackId_userId: { trackId, userId } },
});
isLiked = like !== null;
}
// Топ станций по проигрываниям
const stationsRaw = await this.prisma.$queryRaw<RawStationRow[]>`
SELECT s.station_id AS station_id_int, s.name, COUNT(tp.id)::bigint AS plays
FROM track_plays tp
JOIN stations s ON s.id = tp.station_id
WHERE tp.track_id = ${trackId}
GROUP BY s.id, s.station_id, s.name
ORDER BY plays DESC
LIMIT 10
`;
const stations = stationsRaw.map((r) => ({
stationId: Number(r.station_id_int),
name: r.name,
plays: Number(r.plays),
}));
// Проигрывания по дням за 30 дней
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const playsTimelineRaw = await this.prisma.$queryRaw<RawTimelineRow[]>`
SELECT date_trunc('day', played_at) AS day, COUNT(*)::bigint AS value
FROM track_plays
WHERE track_id = ${trackId}
AND played_at >= ${thirtyDaysAgo}
GROUP BY day
ORDER BY day
`;
const playsTimeline = playsTimelineRaw.map((r) => ({
date: r.day.toISOString().substring(0, 10),
value: Number(r.value),
}));
// Лайки по дням за 30 дней
const likesTimelineRaw = await this.prisma.$queryRaw<RawTimelineRow[]>`
SELECT date_trunc('day', created_at) AS day, COUNT(*)::bigint AS value
FROM track_likes
WHERE track_id = ${trackId}
AND created_at >= ${thirtyDaysAgo}
GROUP BY day
ORDER BY day
`;
const likesTimeline = likesTimelineRaw.map((r) => ({
date: r.day.toISOString().substring(0, 10),
value: Number(r.value),
}));
// Ранг в недельном чарте (если есть)
const weekChart = await this.getTopTracks('week', 100);
const rankEntry = weekChart.items.find((e) => e.trackId === trackId);
const currentRank = rankEntry?.rank ?? null;
return {
trackId: track.id,
artist: track.artist,
song: track.song,
album: track.album ?? null,
coverUrl: track.coverUrl ?? null,
releaseDate: track.releaseDate ? track.releaseDate.toISOString() : null,
firstSeen: track.firstSeenAt ? track.firstSeenAt.toISOString() : null,
totalPlays: totalPlaysResult,
totalLikes,
isLiked,
currentRank,
peakRank: currentRank, // peakRank: используем текущий ранг как лучшее известное значение
stations,
playsTimeline,
likesTimeline,
};
}
// Лайкнуть трек
async likeTrack(trackId: string, userId: string): Promise<object> {
// Проверяем, что трек существует
const track = await this.prisma.track.findUnique({ where: { id: trackId } });
if (!track) {
throw new NotFoundException('Трек не найден');
}
await this.prisma.trackLike.upsert({
where: { trackId_userId: { trackId, userId } },
create: { trackId, userId },
update: {},
});
return {};
}
// Убрать лайк
async unlikeTrack(trackId: string, userId: string): Promise<object> {
await this.prisma.trackLike.deleteMany({
where: { trackId, userId },
});
return {};
}
}

View File

@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { NowPlayingGateway } from './now-playing.gateway';
import { NowPlayingService } from './now-playing.service';
import { RecordStationSyncService } from './record-station-sync.service';
import { IcyNowPlayingService } from './icy-now-playing.service';
import { ChartsModule } from '../charts/charts.module';
@Module({
imports: [forwardRef(() => ChartsModule)],
providers: [
NowPlayingGateway,
NowPlayingService,

View File

@@ -1,8 +1,9 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Inject, forwardRef } from '@nestjs/common';
import { Interval } from '@nestjs/schedule';
import { PrismaService } from '../prisma/prisma.service';
import { NowPlayingGateway } from './now-playing.gateway';
import { RecordStationSyncService } from './record-station-sync.service';
import { ChartsService } from '../charts/charts.service';
interface RecordTrack {
id: number;
@@ -26,6 +27,8 @@ export class NowPlayingService {
private readonly prisma: PrismaService,
private readonly gateway: NowPlayingGateway,
private readonly recordSync: RecordStationSyncService,
@Inject(forwardRef(() => ChartsService))
private readonly chartsService: ChartsService,
) {
this.logger.log('NowPlayingService initialized');
}
@@ -55,6 +58,11 @@ export class NowPlayingService {
const coverUrl = np.track.image600 ?? np.track.image200 ?? np.track.image100;
// Получаем текущее состояние до апдейта, чтобы определить смену трека
const prev = await this.prisma.nowPlaying.findUnique({
where: { stationId: mapping.dbId },
});
const updated = await this.prisma.nowPlaying.upsert({
where: { stationId: mapping.dbId },
create: {
@@ -77,6 +85,20 @@ export class NowPlayingService {
updatedAt: updated.updatedAt,
});
updatedCount++;
// Засчитываем проигрывание только при смене трека
const trackChanged =
!prev ||
prev.song !== np.track.song ||
prev.artist !== np.track.artist;
if (trackChanged) {
void this.chartsService.recordPlay({
artist: np.track.artist,
song: np.track.song,
coverUrl,
stationDbId: mapping.dbId,
});
}
}
this.logger.log(
@@ -91,6 +113,11 @@ export class NowPlayingService {
stationId: string,
data: { song: string; artist: string; coverUrl?: string },
) {
// Получаем текущее состояние до апдейта, чтобы определить смену трека
const prev = await this.prisma.nowPlaying.findUnique({
where: { stationId },
});
const nowPlaying = await this.prisma.nowPlaying.upsert({
where: { stationId },
create: {
@@ -113,6 +140,18 @@ export class NowPlayingService {
updatedAt: nowPlaying.updatedAt,
});
// Засчитываем проигрывание только при смене трека
const trackChanged =
!prev || prev.song !== data.song || prev.artist !== data.artist;
if (trackChanged) {
void this.chartsService.recordPlay({
artist: data.artist,
song: data.song,
coverUrl: data.coverUrl,
stationDbId: stationId,
});
}
return nowPlaying;
}