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

@@ -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);
}
}