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 { CoverStorageService } from './cover-storage.service';
|
||||
import { EnrichmentService } from './enrichment.service';
|
||||
import { CoversController } from './covers.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [CoversController],
|
||||
providers: [DiscogsService, CoverStorageService, EnrichmentService],
|
||||
exports: [EnrichmentService],
|
||||
})
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user