diff --git a/package-lock.json b/package-lock.json index 979812f..91e3a9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 37f9a5c..d42886f 100644 --- a/package.json +++ b/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" } diff --git a/prisma/migrations/20250602130000_add_charts/migration.sql b/prisma/migrations/20250602130000_add_charts/migration.sql new file mode 100644 index 0000000..f05c80c --- /dev/null +++ b/prisma/migrations/20250602130000_add_charts/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d64aa4..7b069f6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") +} diff --git a/src/app.module.ts b/src/app.module.ts index 228c5b4..73fbf61 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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 {} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 94bfb50..4ff552e 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -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 {} diff --git a/src/auth/optional-auth.guard.ts b/src/auth/optional-auth.guard.ts new file mode 100644 index 0000000..333ac3c --- /dev/null +++ b/src/auth/optional-auth.guard.ts @@ -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 { + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); + + if (token) { + try { + const payload = await this.jwtService.verifyAsync(token, { + secret: this.config.get('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; + } +} diff --git a/src/charts/charts.controller.ts b/src/charts/charts.controller.ts new file mode 100644 index 0000000..71dd1e2 --- /dev/null +++ b/src/charts/charts.controller.ts @@ -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); + } +} diff --git a/src/charts/charts.module.ts b/src/charts/charts.module.ts new file mode 100644 index 0000000..497eeb0 --- /dev/null +++ b/src/charts/charts.module.ts @@ -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 {} diff --git a/src/charts/charts.service.ts b/src/charts/charts.service.ts new file mode 100644 index 0000000..917abea --- /dev/null +++ b/src/charts/charts.service.ts @@ -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 { + 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 { + 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` + 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` + 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(); + // Сортируем 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` + 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` + 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` + 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 { + // Проверяем, что трек существует + 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 { + await this.prisma.trackLike.deleteMany({ + where: { trackId, userId }, + }); + return {}; + } +} diff --git a/src/now-playing/now-playing.module.ts b/src/now-playing/now-playing.module.ts index d09189c..434e4f1 100644 --- a/src/now-playing/now-playing.module.ts +++ b/src/now-playing/now-playing.module.ts @@ -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, diff --git a/src/now-playing/now-playing.service.ts b/src/now-playing/now-playing.service.ts index 45c4edb..05abfe7 100644 --- a/src/now-playing/now-playing.service.ts +++ b/src/now-playing/now-playing.service.ts @@ -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; }