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:
74
src/charts/charts.controller.ts
Normal file
74
src/charts/charts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
12
src/charts/charts.module.ts
Normal file
12
src/charts/charts.module.ts
Normal 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 {}
|
||||
411
src/charts/charts.service.ts
Normal file
411
src/charts/charts.service.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user