diff --git a/src/enrich/covers.controller.ts b/src/enrich/covers.controller.ts new file mode 100644 index 0000000..9626cc7 --- /dev/null +++ b/src/enrich/covers.controller.ts @@ -0,0 +1,32 @@ +import { Body, Controller, Post } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { IsString, MaxLength } from 'class-validator'; +import { EnrichmentService } from './enrichment.service'; + +class SubmitCoverDto { + @IsString() + @MaxLength(300) + artist!: string; + + @IsString() + @MaxLength(300) + song!: string; + + @IsString() + @MaxLength(1000) + artworkUrl!: string; +} + +@ApiTags('covers') +@Controller('covers') +export class CoversController { + constructor(private readonly enrichment: EnrichmentService) {} + + // Клиент прислал ссылку на найденную им (со своего IP) обложку iTunes. + // Сервер скачивает её и кладёт WebP к себе; возвращает наш coverUrl. + @Post('submit') + @ApiOperation({ summary: 'Принять обложку, найденную клиентом' }) + async submit(@Body() dto: SubmitCoverDto) { + return this.enrichment.submitCover(dto.artist, dto.song, dto.artworkUrl); + } +} diff --git a/src/enrich/enrich.module.ts b/src/enrich/enrich.module.ts index 7c6d8a1..cc3e53a 100644 --- a/src/enrich/enrich.module.ts +++ b/src/enrich/enrich.module.ts @@ -2,8 +2,10 @@ import { Module } from '@nestjs/common'; import { DiscogsService } from './discogs.service'; import { CoverStorageService } from './cover-storage.service'; import { EnrichmentService } from './enrichment.service'; +import { CoversController } from './covers.controller'; @Module({ + controllers: [CoversController], providers: [DiscogsService, CoverStorageService, EnrichmentService], exports: [EnrichmentService], }) diff --git a/src/enrich/enrichment.service.ts b/src/enrich/enrichment.service.ts index 04a7f9b..a62de11 100644 --- a/src/enrich/enrichment.service.ts +++ b/src/enrich/enrichment.service.ts @@ -156,6 +156,58 @@ export class EnrichmentService { return null; } + // ===== Клиентский сабмит обложки ===== + // Клиент (со своего IP) делает iTunes-поиск (наш серверный IP забанен Apple) + // и присылает ССЫЛКУ на арт. Сервер качает её (CDN из РФ доступен) и кладёт + // WebP к себе. SSRF-защита: только доверенные CDN. Идемпотентно (first-write-wins). + private static readonly COVER_HOST_ALLOW = ['mzstatic.com', 'dzcdn.net']; + private submitInFlight = 0; + private readonly submitMaxInFlight = 6; + + async submitCover( + artist: string, + song: string, + artworkUrl: string, + ): Promise<{ coverUrl: string | null }> { + const a = (artist ?? '').trim(); + const s = (song ?? '').trim(); + if (!a || !s || !artworkUrl) return { coverUrl: null }; + + let host = ''; + try { + host = new URL(artworkUrl).hostname.toLowerCase(); + } catch { + return { coverUrl: null }; + } + const allowed = EnrichmentService.COVER_HOST_ALLOW.some( + (h) => host === h || host.endsWith('.' + h), + ); + if (!allowed) return { coverUrl: null }; + + const normKey = this.buildNormKey(a, s); + // Уже есть — отдаём существующую (не качаем повторно, защита от перезаписи). + const existing = await this.prisma.track.findUnique({ + where: { normKey }, + select: { coverUrl: true }, + }); + if (existing?.coverUrl) return { coverUrl: existing.coverUrl }; + + if (this.submitInFlight >= this.submitMaxInFlight) return { coverUrl: null }; + this.submitInFlight++; + try { + const stored = await this.covers.store(artworkUrl, normKey); + if (!stored) return { coverUrl: null }; + await this.prisma.track.upsert({ + where: { normKey }, + create: { normKey, artist: a, song: s, coverUrl: stored }, + update: { coverUrl: stored }, + }); + return { coverUrl: stored }; + } finally { + this.submitInFlight--; + } + } + // Нормализованный ключ — как в ChartsService.recordPlay private buildNormKey(artist: string, song: string): string { return (