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:
@@ -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<void> = 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<void> {
|
||||
const prev = this.itunesGate;
|
||||
let release!: () => void;
|
||||
this.itunesGate = new Promise<void>((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 } }>;
|
||||
|
||||
Reference in New Issue
Block a user