feat(enrich): 2-й токен Discogs через DE-прокси (2 IP → ~108/мин жанров)

Discogs лимитит по IP. token1 идёт напрямую (IP RU), token2 — через форвард-прокси
на DE (IP DE, tinyproxy, доступ только с RU). Два IP, у каждого свой слот ~54/мин
→ суммарно ~108/мин жанров без 429. undici ProxyAgent. Без DISCOGS_PROXY — только
token1 (54/мин).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 10:01:37 +03:00
parent dfdfb7e4ab
commit e982fde730
4 changed files with 44 additions and 18 deletions

View File

@@ -1,4 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import { ProxyAgent } from 'undici';
// Результат обогащения из Discogs
export interface DiscogsResult {
@@ -38,11 +39,13 @@ export class DiscogsService {
process.env.DISCOGS_TOKEN ?? '',
process.env.DISCOGS_TOKEN2 ?? '',
].filter((t) => t.length > 0);
// ВАЖНО: Discogs троттлит по IP (не по токену), поэтому с одного сервера
// общий лимит ~54/мин независимо от числа токенов. Токены ротируем вхолостую
// (выгода от 2-го токена — только на ВТОРОМ IP, напр. DE-воркер).
private nextSlot = 0;
private rr = 0;
// Discogs троттлит по IP. Маршруты: [0] token1 напрямую (IP RU),
// [1] token2 через DE-прокси (IP DE) — два разных IP, у каждого свой слот
// ~54/мин → суммарно ~108/мин. Без прокси используем только маршрут 0.
private readonly proxyAgent = process.env.DISCOGS_PROXY
? new ProxyAgent(process.env.DISCOGS_PROXY)
: null;
private readonly slots: number[] = this.tokens.map(() => 0);
private readonly minIntervalMs = 1100;
// Без токена обогащение жанрами не работает (поиск требует авторизации)
@@ -50,20 +53,29 @@ export class DiscogsService {
return this.tokens.length > 0;
}
private async pickToken(): Promise<string> {
// Индексы доступных маршрутов (token2 — только если есть прокси/2-й IP)
private routes(): number[] {
return this.tokens.length >= 2 && this.proxyAgent ? [0, 1] : [0];
}
// Резервирует слот наименее загруженного маршрута, возвращает токен + dispatcher
private async pickRoute(): Promise<{ token: string; dispatcher: ProxyAgent | undefined }> {
const routes = this.routes();
let idx = routes[0];
for (const r of routes) if (this.slots[r] < this.slots[idx]) idx = r;
const now = Date.now();
const start = Math.max(now, this.nextSlot);
this.nextSlot = start + this.minIntervalMs; // глобальный интервал (IP-лимит)
const wait = start - now;
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
const token = this.tokens[this.rr % this.tokens.length];
this.rr++;
return token;
const start = Math.max(now, this.slots[idx]);
this.slots[idx] = start + this.minIntervalMs;
if (start > now) await new Promise((res) => setTimeout(res, start - now));
return {
token: this.tokens[idx],
dispatcher: idx === 1 ? (this.proxyAgent ?? undefined) : undefined,
};
}
async lookup(artist: string, song: string): Promise<DiscogsResult | null> {
if (!this.enabled) return null;
const token = await this.pickToken();
const { token, dispatcher } = await this.pickRoute();
const params = new URLSearchParams({
artist,
@@ -74,9 +86,11 @@ export class DiscogsService {
});
const url = `https://api.discogs.com/database/search?${params.toString()}`;
const res = await fetch(url, {
const init: Record<string, unknown> = {
headers: { 'User-Agent': this.userAgent, Accept: 'application/json' },
});
};
if (dispatcher) init.dispatcher = dispatcher;
const res = await fetch(url, init);
if (!res.ok) {
this.logger.debug(`Discogs ${res.status} для "${artist}${song}"`);
return null;