From 28487a7911f1edee577cf25f2c907dda0ca98435 Mon Sep 17 00:00:00 2001 From: nk Date: Thu, 4 Jun 2026 16:30:06 +0300 Subject: [PATCH] =?UTF-8?q?fix(enrich):=20iTunes/Deezer=20=D1=87=D0=B5?= =?UTF-8?q?=D1=80=D0=B5=D0=B7=20DE-=D0=BF=D1=80=D0=BE=D0=BA=D1=81=D0=B8=20?= =?UTF-8?q?+=20=D1=82=D1=80=D0=BE=D1=82=D1=82=D0=BB=D0=B8=D0=BD=D0=B3=20(R?= =?UTF-8?q?U-IP=20=D0=B7=D0=B0=D0=B1=D0=B0=D0=BD=D0=B5=D0=BD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Корневая причина пропавших обложек: RU-IP сервера ЗАБАНЕН Apple (iTunes search → 429 «Rate limit ... 121.127.37.212»), а Deezer из РФ отдаёт ПУСТОЙ каталог. Оба источника с сервера не работали. Теперь iTunes/Deezer-поиск ходит через тот же DE-прокси, что и Discogs (DISCOGS_PROXY): с DE-IP iTunes доступен, Deezer отдаёт каталог. Deezer сделан первичным (высокий лимит), iTunes — фолбэк с сериализацией (3.5с интервал), чтобы не забанить общий DE-IP. Скачивание самих картинок (mzstatic/dzcdn) — напрямую, они из РФ доступны. Co-Authored-By: Claude Opus 4.8 --- src/enrich/enrichment.service.ts | 49 +++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 11 deletions(-) diff --git a/src/enrich/enrichment.service.ts b/src/enrich/enrichment.service.ts index 2514f78..13ef44c 100644 --- a/src/enrich/enrichment.service.ts +++ b/src/enrich/enrichment.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron, CronExpression } from '@nestjs/schedule'; +import { ProxyAgent } from 'undici'; import { PrismaService } from '../prisma/prisma.service'; import { DiscogsService } from './discogs.service'; import { CoverStorageService } from './cover-storage.service'; @@ -22,6 +23,17 @@ export class EnrichmentService { private readonly throttleMs = 150; private readonly concurrency = 12; + // RU-IP сервера забанен Apple (429) и Deezer из РФ отдаёт пустой каталог — + // поэтому iTunes/Deezer ходят через тот же DE-прокси, что и Discogs. + private readonly proxyDispatcher = process.env.DISCOGS_PROXY + ? new ProxyAgent(process.env.DISCOGS_PROXY) + : undefined; + + // iTunes лимитирует ПО IP (~20/мин) и легко банит общий DE-IP (его делит + // Discogs) — сериализуем запросы к iTunes с интервалом. + private itunesGate: Promise = Promise.resolve(); + private readonly itunesMinIntervalMs = 3500; + constructor( private readonly prisma: PrismaService, private readonly discogs: DiscogsService, @@ -131,22 +143,23 @@ export class EnrichmentService { } } - /** Только обложка: один iTunes (очищенный) → Deezer. Не бросает. */ + /** Только обложка для быстрого now-playing-прохода. Deezer первичен (высокий + * лимит, через DE-прокси отдаёт каталог), iTunes — фолбэк (жёстко троттлится). */ private async fetchCover( artist: string, song: string, ): Promise<{ coverUrl: string | null; genre: string | null; album: string | null } | null> { - const cleaned = - `${this.stripNoise(artist)} ${this.stripNoise(song)}`.replace(/\s+/g, ' ').trim() || - `${artist} ${song}`; + const dz = await this.fetchDeezerCover(artist, song); + if (dz) return { coverUrl: dz, genre: null, album: null }; try { + const cleaned = + `${this.stripNoise(artist)} ${this.stripNoise(song)}`.replace(/\s+/g, ' ').trim() || + `${artist} ${song}`; const r = await this.itunesSearch(cleaned); if (r?.coverUrl) return { coverUrl: r.coverUrl, genre: r.genre, album: r.album }; } catch { - // iTunes 429/сеть — попробуем Deezer + // iTunes 429/сеть — добёрём позже } - const dz = await this.fetchDeezerCover(artist, song); - if (dz) return { coverUrl: dz, genre: null, album: null }; return null; } @@ -302,6 +315,15 @@ export class EnrichmentService { .trim(); } + /** Сериализует запросы к iTunes с минимальным интервалом (защита от 429/бана). */ + private async itunesThrottle(): Promise { + const prev = this.itunesGate; + let release!: () => void; + this.itunesGate = new Promise((r) => (release = r)); + await prev; + setTimeout(release, this.itunesMinIntervalMs); + } + /** Один поиск в iTunes по уже собранному запросу. Бросает при сбое сети/HTTP * (отличаем сбой от чистого «не найдено» → null). */ private async itunesSearch(rawTerm: string): Promise<{ @@ -318,9 +340,12 @@ export class EnrichmentService { if (!clean) return null; const term = encodeURIComponent(clean); const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`; - const res = await fetch(url, { + await this.itunesThrottle(); + const init: RequestInit & { dispatcher?: unknown } = { headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' }, - }); + }; + if (this.proxyDispatcher) init.dispatcher = this.proxyDispatcher; + const res = await fetch(url, init); if (!res.ok) throw new Error(`iTunes ${res.status}`); const data = (await res.json()) as { results?: Array<{ @@ -359,9 +384,11 @@ export class EnrichmentService { .trim(); if (!q) return null; const url = `https://api.deezer.com/search?limit=1&q=${encodeURIComponent(q)}`; - const res = await fetch(url, { + const init: RequestInit & { dispatcher?: unknown } = { headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' }, - }); + }; + if (this.proxyDispatcher) init.dispatcher = this.proxyDispatcher; + const res = await fetch(url, init); if (!res.ok) return null; const data = (await res.json()) as { data?: Array<{ album?: { cover_xl?: string; cover_big?: string } }>;