feat(charts): сбор статистики проигрываний и API чартов

- модели Track / TrackPlay / TrackLike (+ миграция add_charts)
- сбор проигрываний в now-playing-поллере: при смене трека на станции
  пишется TrackPlay (нормализация artist+song -> Track), fire-and-forget
  обогащение через MusicBrainz (album/releaseDate)
- ChartsModule: GET /charts/tracks (период day/week/month/all, ранг, тренд,
  проигрывания, станции, лайки), GET /charts/tracks/:id (метрики, таймлайны
  популярности и лайков по дням, топ станций, isLiked), POST/DELETE like
- OptionalAuthGuard для публичной детальной страницы с опц. userId
This commit is contained in:
nk
2026-06-02 23:40:13 +03:00
parent bbfec76a7b
commit 38fe92d695
12 changed files with 716 additions and 16 deletions

View File

@@ -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,

View File

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