feat(covers): POST /covers/submit — приём обложки, найденной клиентом
Клиент (со своего IP, не забанен Apple) ищет арт в iTunes и шлёт ссылку. Сервер качает (CDN из РФ доступен), конвертит в WebP (CoverStorageService), кладёт к себе, апсертит Track по normKey. Защита: host-whitelist (mzstatic/dzcdn, против SSRF), идемпотентность, кап одновременных загрузок. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
32
src/enrich/covers.controller.ts
Normal file
32
src/enrich/covers.controller.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { DiscogsService } from './discogs.service';
|
import { DiscogsService } from './discogs.service';
|
||||||
import { CoverStorageService } from './cover-storage.service';
|
import { CoverStorageService } from './cover-storage.service';
|
||||||
import { EnrichmentService } from './enrichment.service';
|
import { EnrichmentService } from './enrichment.service';
|
||||||
|
import { CoversController } from './covers.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [CoversController],
|
||||||
providers: [DiscogsService, CoverStorageService, EnrichmentService],
|
providers: [DiscogsService, CoverStorageService, EnrichmentService],
|
||||||
exports: [EnrichmentService],
|
exports: [EnrichmentService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -156,6 +156,58 @@ export class EnrichmentService {
|
|||||||
return null;
|
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
|
// Нормализованный ключ — как в ChartsService.recordPlay
|
||||||
private buildNormKey(artist: string, song: string): string {
|
private buildNormKey(artist: string, song: string): string {
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user