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/schedule": "^5.0.0",
|
||||||
"@nestjs/swagger": "^11.0.0",
|
"@nestjs/swagger": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.0.1",
|
"@nestjs/websockets": "^11.0.1",
|
||||||
"@prisma/client": "^6.2.0",
|
"@prisma/client": "^6.19.3",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^6.2.0",
|
"prisma": "^6.19.3",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
|
|||||||
14
package.json
14
package.json
@@ -34,7 +34,7 @@
|
|||||||
"@nestjs/schedule": "^5.0.0",
|
"@nestjs/schedule": "^5.0.0",
|
||||||
"@nestjs/swagger": "^11.0.0",
|
"@nestjs/swagger": "^11.0.0",
|
||||||
"@nestjs/websockets": "^11.0.1",
|
"@nestjs/websockets": "^11.0.1",
|
||||||
"@prisma/client": "^6.2.0",
|
"@prisma/client": "^6.19.3",
|
||||||
"bullmq": "^5.34.0",
|
"bullmq": "^5.34.0",
|
||||||
"class-transformer": "^0.5.1",
|
"class-transformer": "^0.5.1",
|
||||||
"class-validator": "^0.14.1",
|
"class-validator": "^0.14.1",
|
||||||
@@ -64,7 +64,7 @@
|
|||||||
"globals": "^17.0.0",
|
"globals": "^17.0.0",
|
||||||
"jest": "^30.0.0",
|
"jest": "^30.0.0",
|
||||||
"prettier": "^3.4.2",
|
"prettier": "^3.4.2",
|
||||||
"prisma": "^6.2.0",
|
"prisma": "^6.19.3",
|
||||||
"source-map-support": "^0.5.21",
|
"source-map-support": "^0.5.21",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.2.5",
|
||||||
@@ -75,13 +75,19 @@
|
|||||||
"typescript-eslint": "^8.20.0"
|
"typescript-eslint": "^8.20.0"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"moduleFileExtensions": ["js", "json", "ts"],
|
"moduleFileExtensions": [
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"ts"
|
||||||
|
],
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"testRegex": ".*\\.spec\\.ts$",
|
"testRegex": ".*\\.spec\\.ts$",
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+\\.(t|j)s$": "ts-jest"
|
"^.+\\.(t|j)s$": "ts-jest"
|
||||||
},
|
},
|
||||||
"collectCoverageFrom": ["**/*.(t|j)s"],
|
"collectCoverageFrom": [
|
||||||
|
"**/*.(t|j)s"
|
||||||
|
],
|
||||||
"coverageDirectory": "../coverage",
|
"coverageDirectory": "../coverage",
|
||||||
"testEnvironment": "node"
|
"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")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
favorites UserFavorite[]
|
favorites UserFavorite[]
|
||||||
history PlayHistory[]
|
history PlayHistory[]
|
||||||
settings UserSettings?
|
settings UserSettings?
|
||||||
|
trackLikes TrackLike[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -51,9 +52,10 @@ model Station {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
favorites UserFavorite[]
|
favorites UserFavorite[]
|
||||||
history PlayHistory[]
|
history PlayHistory[]
|
||||||
nowPlaying NowPlaying?
|
nowPlaying NowPlaying?
|
||||||
|
trackPlays TrackPlay[]
|
||||||
|
|
||||||
@@index([isOnline])
|
@@index([isOnline])
|
||||||
@@index([source])
|
@@index([source])
|
||||||
@@ -112,3 +114,50 @@ model UserSettings {
|
|||||||
|
|
||||||
@@map("user_settings")
|
@@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 { UsersModule } from './users/users.module';
|
||||||
import { NowPlayingModule } from './now-playing/now-playing.module';
|
import { NowPlayingModule } from './now-playing/now-playing.module';
|
||||||
import { HealthCheckModule } from './health-check/health-check.module';
|
import { HealthCheckModule } from './health-check/health-check.module';
|
||||||
|
import { ChartsModule } from './charts/charts.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -18,6 +19,7 @@ import { HealthCheckModule } from './health-check/health-check.module';
|
|||||||
UsersModule,
|
UsersModule,
|
||||||
NowPlayingModule,
|
NowPlayingModule,
|
||||||
HealthCheckModule,
|
HealthCheckModule,
|
||||||
|
ChartsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
|||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { AuthGuard } from './auth.guard';
|
import { AuthGuard } from './auth.guard';
|
||||||
|
import { OptionalAuthGuard } from './optional-auth.guard';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -19,7 +20,7 @@ import { AuthGuard } from './auth.guard';
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, AuthGuard],
|
providers: [AuthService, AuthGuard, OptionalAuthGuard],
|
||||||
exports: [AuthService, AuthGuard, JwtModule],
|
exports: [AuthService, AuthGuard, OptionalAuthGuard, JwtModule],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
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 { NowPlayingGateway } from './now-playing.gateway';
|
||||||
import { NowPlayingService } from './now-playing.service';
|
import { NowPlayingService } from './now-playing.service';
|
||||||
import { RecordStationSyncService } from './record-station-sync.service';
|
import { RecordStationSyncService } from './record-station-sync.service';
|
||||||
import { IcyNowPlayingService } from './icy-now-playing.service';
|
import { IcyNowPlayingService } from './icy-now-playing.service';
|
||||||
|
import { ChartsModule } from '../charts/charts.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [forwardRef(() => ChartsModule)],
|
||||||
providers: [
|
providers: [
|
||||||
NowPlayingGateway,
|
NowPlayingGateway,
|
||||||
NowPlayingService,
|
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 { Interval } from '@nestjs/schedule';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { NowPlayingGateway } from './now-playing.gateway';
|
import { NowPlayingGateway } from './now-playing.gateway';
|
||||||
import { RecordStationSyncService } from './record-station-sync.service';
|
import { RecordStationSyncService } from './record-station-sync.service';
|
||||||
|
import { ChartsService } from '../charts/charts.service';
|
||||||
|
|
||||||
interface RecordTrack {
|
interface RecordTrack {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -26,6 +27,8 @@ export class NowPlayingService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly gateway: NowPlayingGateway,
|
private readonly gateway: NowPlayingGateway,
|
||||||
private readonly recordSync: RecordStationSyncService,
|
private readonly recordSync: RecordStationSyncService,
|
||||||
|
@Inject(forwardRef(() => ChartsService))
|
||||||
|
private readonly chartsService: ChartsService,
|
||||||
) {
|
) {
|
||||||
this.logger.log('NowPlayingService initialized');
|
this.logger.log('NowPlayingService initialized');
|
||||||
}
|
}
|
||||||
@@ -55,6 +58,11 @@ export class NowPlayingService {
|
|||||||
|
|
||||||
const coverUrl = np.track.image600 ?? np.track.image200 ?? np.track.image100;
|
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({
|
const updated = await this.prisma.nowPlaying.upsert({
|
||||||
where: { stationId: mapping.dbId },
|
where: { stationId: mapping.dbId },
|
||||||
create: {
|
create: {
|
||||||
@@ -77,6 +85,20 @@ export class NowPlayingService {
|
|||||||
updatedAt: updated.updatedAt,
|
updatedAt: updated.updatedAt,
|
||||||
});
|
});
|
||||||
updatedCount++;
|
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(
|
this.logger.log(
|
||||||
@@ -91,6 +113,11 @@ export class NowPlayingService {
|
|||||||
stationId: string,
|
stationId: string,
|
||||||
data: { song: string; artist: string; coverUrl?: string },
|
data: { song: string; artist: string; coverUrl?: string },
|
||||||
) {
|
) {
|
||||||
|
// Получаем текущее состояние до апдейта, чтобы определить смену трека
|
||||||
|
const prev = await this.prisma.nowPlaying.findUnique({
|
||||||
|
where: { stationId },
|
||||||
|
});
|
||||||
|
|
||||||
const nowPlaying = await this.prisma.nowPlaying.upsert({
|
const nowPlaying = await this.prisma.nowPlaying.upsert({
|
||||||
where: { stationId },
|
where: { stationId },
|
||||||
create: {
|
create: {
|
||||||
@@ -113,6 +140,18 @@ export class NowPlayingService {
|
|||||||
updatedAt: nowPlaying.updatedAt,
|
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;
|
return nowPlaying;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user