fix(enrich): iTunes/Deezer через DE-прокси + троттлинг (RU-IP забанен)

Корневая причина пропавших обложек: 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 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 16:30:06 +03:00
parent 59aa23ff77
commit 28487a7911

View File

@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
import { ProxyAgent } from 'undici';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { DiscogsService } from './discogs.service'; import { DiscogsService } from './discogs.service';
import { CoverStorageService } from './cover-storage.service'; import { CoverStorageService } from './cover-storage.service';
@@ -22,6 +23,17 @@ export class EnrichmentService {
private readonly throttleMs = 150; private readonly throttleMs = 150;
private readonly concurrency = 12; 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<void> = Promise.resolve();
private readonly itunesMinIntervalMs = 3500;
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly discogs: DiscogsService, private readonly discogs: DiscogsService,
@@ -131,22 +143,23 @@ export class EnrichmentService {
} }
} }
/** Только обложка: один iTunes (очищенный) → Deezer. Не бросает. */ /** Только обложка для быстрого now-playing-прохода. Deezer первичен (высокий
* лимит, через DE-прокси отдаёт каталог), iTunes — фолбэк (жёстко троттлится). */
private async fetchCover( private async fetchCover(
artist: string, artist: string,
song: string, song: string,
): Promise<{ coverUrl: string | null; genre: string | null; album: string | null } | null> { ): Promise<{ coverUrl: string | null; genre: string | null; album: string | null } | null> {
const dz = await this.fetchDeezerCover(artist, song);
if (dz) return { coverUrl: dz, genre: null, album: null };
try {
const cleaned = const cleaned =
`${this.stripNoise(artist)} ${this.stripNoise(song)}`.replace(/\s+/g, ' ').trim() || `${this.stripNoise(artist)} ${this.stripNoise(song)}`.replace(/\s+/g, ' ').trim() ||
`${artist} ${song}`; `${artist} ${song}`;
try {
const r = await this.itunesSearch(cleaned); const r = await this.itunesSearch(cleaned);
if (r?.coverUrl) return { coverUrl: r.coverUrl, genre: r.genre, album: r.album }; if (r?.coverUrl) return { coverUrl: r.coverUrl, genre: r.genre, album: r.album };
} catch { } catch {
// iTunes 429/сеть — попробуем Deezer // iTunes 429/сеть — добёрём позже
} }
const dz = await this.fetchDeezerCover(artist, song);
if (dz) return { coverUrl: dz, genre: null, album: null };
return null; return null;
} }
@@ -302,6 +315,15 @@ export class EnrichmentService {
.trim(); .trim();
} }
/** Сериализует запросы к iTunes с минимальным интервалом (защита от 429/бана). */
private async itunesThrottle(): Promise<void> {
const prev = this.itunesGate;
let release!: () => void;
this.itunesGate = new Promise<void>((r) => (release = r));
await prev;
setTimeout(release, this.itunesMinIntervalMs);
}
/** Один поиск в iTunes по уже собранному запросу. Бросает при сбое сети/HTTP /** Один поиск в iTunes по уже собранному запросу. Бросает при сбое сети/HTTP
* (отличаем сбой от чистого «не найдено» → null). */ * (отличаем сбой от чистого «не найдено» → null). */
private async itunesSearch(rawTerm: string): Promise<{ private async itunesSearch(rawTerm: string): Promise<{
@@ -318,9 +340,12 @@ export class EnrichmentService {
if (!clean) return null; if (!clean) return null;
const term = encodeURIComponent(clean); const term = encodeURIComponent(clean);
const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`; 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' }, 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}`); if (!res.ok) throw new Error(`iTunes ${res.status}`);
const data = (await res.json()) as { const data = (await res.json()) as {
results?: Array<{ results?: Array<{
@@ -359,9 +384,11 @@ export class EnrichmentService {
.trim(); .trim();
if (!q) return null; if (!q) return null;
const url = `https://api.deezer.com/search?limit=1&q=${encodeURIComponent(q)}`; 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' }, 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; if (!res.ok) return null;
const data = (await res.json()) as { const data = (await res.json()) as {
data?: Array<{ album?: { cover_xl?: string; cover_big?: string } }>; data?: Array<{ album?: { cover_xl?: string; cover_big?: string } }>;