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:
@@ -23,6 +23,7 @@ services:
|
|||||||
# Обогащение треков
|
# Обогащение треков
|
||||||
- DISCOGS_TOKEN=${DISCOGS_TOKEN}
|
- DISCOGS_TOKEN=${DISCOGS_TOKEN}
|
||||||
- DISCOGS_TOKEN2=${DISCOGS_TOKEN2}
|
- DISCOGS_TOKEN2=${DISCOGS_TOKEN2}
|
||||||
|
- DISCOGS_PROXY=${DISCOGS_PROXY}
|
||||||
- COVERS_DIR=/data/covers
|
- COVERS_DIR=/data/covers
|
||||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000}
|
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000}
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -30,7 +30,8 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5",
|
||||||
|
"undici": "^7.27.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -11338,6 +11339,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.27.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
|
||||||
|
"integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.16.0",
|
"version": "7.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
|||||||
@@ -45,7 +45,8 @@
|
|||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"sharp": "^0.33.5"
|
"sharp": "^0.33.5",
|
||||||
|
"undici": "^7.27.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ProxyAgent } from 'undici';
|
||||||
|
|
||||||
// Результат обогащения из Discogs
|
// Результат обогащения из Discogs
|
||||||
export interface DiscogsResult {
|
export interface DiscogsResult {
|
||||||
@@ -38,11 +39,13 @@ export class DiscogsService {
|
|||||||
process.env.DISCOGS_TOKEN ?? '',
|
process.env.DISCOGS_TOKEN ?? '',
|
||||||
process.env.DISCOGS_TOKEN2 ?? '',
|
process.env.DISCOGS_TOKEN2 ?? '',
|
||||||
].filter((t) => t.length > 0);
|
].filter((t) => t.length > 0);
|
||||||
// ВАЖНО: Discogs троттлит по IP (не по токену), поэтому с одного сервера
|
// Discogs троттлит по IP. Маршруты: [0] token1 напрямую (IP RU),
|
||||||
// общий лимит ~54/мин независимо от числа токенов. Токены ротируем вхолостую
|
// [1] token2 через DE-прокси (IP DE) — два разных IP, у каждого свой слот
|
||||||
// (выгода от 2-го токена — только на ВТОРОМ IP, напр. DE-воркер).
|
// ~54/мин → суммарно ~108/мин. Без прокси используем только маршрут 0.
|
||||||
private nextSlot = 0;
|
private readonly proxyAgent = process.env.DISCOGS_PROXY
|
||||||
private rr = 0;
|
? new ProxyAgent(process.env.DISCOGS_PROXY)
|
||||||
|
: null;
|
||||||
|
private readonly slots: number[] = this.tokens.map(() => 0);
|
||||||
private readonly minIntervalMs = 1100;
|
private readonly minIntervalMs = 1100;
|
||||||
|
|
||||||
// Без токена обогащение жанрами не работает (поиск требует авторизации)
|
// Без токена обогащение жанрами не работает (поиск требует авторизации)
|
||||||
@@ -50,20 +53,29 @@ export class DiscogsService {
|
|||||||
return this.tokens.length > 0;
|
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 now = Date.now();
|
||||||
const start = Math.max(now, this.nextSlot);
|
const start = Math.max(now, this.slots[idx]);
|
||||||
this.nextSlot = start + this.minIntervalMs; // глобальный интервал (IP-лимит)
|
this.slots[idx] = start + this.minIntervalMs;
|
||||||
const wait = start - now;
|
if (start > now) await new Promise((res) => setTimeout(res, start - now));
|
||||||
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
return {
|
||||||
const token = this.tokens[this.rr % this.tokens.length];
|
token: this.tokens[idx],
|
||||||
this.rr++;
|
dispatcher: idx === 1 ? (this.proxyAgent ?? undefined) : undefined,
|
||||||
return token;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async lookup(artist: string, song: string): Promise<DiscogsResult | null> {
|
async lookup(artist: string, song: string): Promise<DiscogsResult | null> {
|
||||||
if (!this.enabled) return null;
|
if (!this.enabled) return null;
|
||||||
const token = await this.pickToken();
|
const { token, dispatcher } = await this.pickRoute();
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
artist,
|
artist,
|
||||||
@@ -74,9 +86,11 @@ export class DiscogsService {
|
|||||||
});
|
});
|
||||||
const url = `https://api.discogs.com/database/search?${params.toString()}`;
|
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' },
|
headers: { 'User-Agent': this.userAgent, Accept: 'application/json' },
|
||||||
});
|
};
|
||||||
|
if (dispatcher) init.dispatcher = dispatcher;
|
||||||
|
const res = await fetch(url, init);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
this.logger.debug(`Discogs ${res.status} для "${artist} — ${song}"`);
|
this.logger.debug(`Discogs ${res.status} для "${artist} — ${song}"`);
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
Reference in New Issue
Block a user