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 { 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 } }>;
|
||||||
|
|||||||
Reference in New Issue
Block a user