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:
4
package-lock.json
generated
4
package-lock.json
generated
@@ -19,7 +19,7 @@
|
||||
"@nestjs/schedule": "^5.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.1",
|
||||
"@prisma/client": "^6.2.0",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
@@ -49,7 +49,7 @@
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.2.0",
|
||||
"prisma": "^6.19.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
|
||||
14
package.json
14
package.json
@@ -34,7 +34,7 @@
|
||||
"@nestjs/schedule": "^5.0.0",
|
||||
"@nestjs/swagger": "^11.0.0",
|
||||
"@nestjs/websockets": "^11.0.1",
|
||||
"@prisma/client": "^6.2.0",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"bullmq": "^5.34.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
@@ -64,7 +64,7 @@
|
||||
"globals": "^17.0.0",
|
||||
"jest": "^30.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prisma": "^6.2.0",
|
||||
"prisma": "^6.19.3",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
"ts-jest": "^29.2.5",
|
||||
@@ -75,13 +75,19 @@
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": ["js", "json", "ts"],
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
"ts"
|
||||
],
|
||||
"rootDir": "src",
|
||||
"testRegex": ".*\\.spec\\.ts$",
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
||||
"collectCoverageFrom": [
|
||||
"**/*.(t|j)s"
|
||||
],
|
||||
"coverageDirectory": "../coverage",
|
||||
"testEnvironment": "node"
|
||||
}
|
||||
|
||||
68
prisma/migrations/20250602130000_add_charts/migration.sql
Normal file
68
prisma/migrations/20250602130000_add_charts/migration.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- CreateTable: tracks
|
||||
CREATE TABLE "tracks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"norm_key" TEXT NOT NULL,
|
||||
"artist" TEXT NOT NULL,
|
||||
"song" TEXT NOT NULL,
|
||||
"cover_url" TEXT,
|
||||
"album" TEXT,
|
||||
"release_date" TIMESTAMPTZ,
|
||||
"first_seen_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
"enriched_at" TIMESTAMPTZ,
|
||||
|
||||
CONSTRAINT "tracks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable: track_plays
|
||||
CREATE TABLE "track_plays" (
|
||||
"id" TEXT NOT NULL,
|
||||
"track_id" TEXT NOT NULL,
|
||||
"station_id" TEXT NOT NULL,
|
||||
"played_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT "track_plays_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable: track_likes
|
||||
CREATE TABLE "track_likes" (
|
||||
"id" TEXT NOT NULL,
|
||||
"track_id" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT "track_likes_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "tracks_norm_key_key" ON "tracks"("norm_key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "track_plays_track_id_played_at_idx" ON "track_plays"("track_id", "played_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "track_plays_played_at_idx" ON "track_plays"("played_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "track_plays_station_id_idx" ON "track_plays"("station_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "track_likes_track_id_user_id_key" ON "track_likes"("track_id", "user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "track_likes_track_id_idx" ON "track_likes"("track_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "track_plays" ADD CONSTRAINT "track_plays_track_id_fkey"
|
||||
FOREIGN KEY ("track_id") REFERENCES "tracks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "track_plays" ADD CONSTRAINT "track_plays_station_id_fkey"
|
||||
FOREIGN KEY ("station_id") REFERENCES "stations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "track_likes" ADD CONSTRAINT "track_likes_track_id_fkey"
|
||||
FOREIGN KEY ("track_id") REFERENCES "tracks"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "track_likes" ADD CONSTRAINT "track_likes_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -14,9 +14,10 @@ model User {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
favorites UserFavorite[]
|
||||
history PlayHistory[]
|
||||
settings UserSettings?
|
||||
favorites UserFavorite[]
|
||||
history PlayHistory[]
|
||||
settings UserSettings?
|
||||
trackLikes TrackLike[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -51,9 +52,10 @@ model Station {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
favorites UserFavorite[]
|
||||
history PlayHistory[]
|
||||
nowPlaying NowPlaying?
|
||||
favorites UserFavorite[]
|
||||
history PlayHistory[]
|
||||
nowPlaying NowPlaying?
|
||||
trackPlays TrackPlay[]
|
||||
|
||||
@@index([isOnline])
|
||||
@@index([source])
|
||||
@@ -112,3 +114,50 @@ model UserSettings {
|
||||
|
||||
@@map("user_settings")
|
||||
}
|
||||
|
||||
// Уникальный трек (нормализованный ключ artist+song)
|
||||
model Track {
|
||||
id String @id @default(cuid())
|
||||
normKey String @unique @map("norm_key")
|
||||
artist String
|
||||
song String
|
||||
coverUrl String? @map("cover_url")
|
||||
album String?
|
||||
releaseDate DateTime? @map("release_date")
|
||||
firstSeenAt DateTime @default(now()) @map("first_seen_at")
|
||||
enrichedAt DateTime? @map("enriched_at")
|
||||
|
||||
plays TrackPlay[]
|
||||
likes TrackLike[]
|
||||
|
||||
@@map("tracks")
|
||||
}
|
||||
|
||||
// Факт проигрывания трека на станции
|
||||
model TrackPlay {
|
||||
id String @id @default(cuid())
|
||||
trackId String @map("track_id")
|
||||
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
||||
stationId String @map("station_id")
|
||||
station Station @relation(fields: [stationId], references: [id], onDelete: Cascade)
|
||||
playedAt DateTime @default(now()) @map("played_at")
|
||||
|
||||
@@index([trackId, playedAt])
|
||||
@@index([playedAt])
|
||||
@@index([stationId])
|
||||
@@map("track_plays")
|
||||
}
|
||||
|
||||
// Лайк трека пользователем
|
||||
model TrackLike {
|
||||
id String @id @default(cuid())
|
||||
trackId String @map("track_id")
|
||||
track Track @relation(fields: [trackId], references: [id], onDelete: Cascade)
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@unique([trackId, userId])
|
||||
@@index([trackId])
|
||||
@@map("track_likes")
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
36
src/auth/optional-auth.guard.ts
Normal file
36
src/auth/optional-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
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 {};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user