perf(enrich): ротация двух токенов Discogs (~108/мин жанров)

Discogs лимит ~60/мин на токен. Поддержка нескольких токенов (DISCOGS_TOKEN,
DISCOGS_TOKEN2) — у каждого свой слот, берём наименее загруженный → суммарно
вдвое быстрее жанры/стили/лейблы, без 429. Токены — в env (не в гите).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nk
2026-06-04 09:45:35 +03:00
parent ed94bd73d7
commit 94e7f46b39
2 changed files with 20 additions and 10 deletions

View File

@@ -22,6 +22,7 @@ services:
- FRONTEND_URL=${FRONTEND_URL:-https://radiola.app} - FRONTEND_URL=${FRONTEND_URL:-https://radiola.app}
# Обогащение треков # Обогащение треков
- DISCOGS_TOKEN=${DISCOGS_TOKEN} - DISCOGS_TOKEN=${DISCOGS_TOKEN}
- DISCOGS_TOKEN2=${DISCOGS_TOKEN2}
- 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:

View File

@@ -30,37 +30,46 @@ interface DiscogsSearchItem {
@Injectable() @Injectable()
export class DiscogsService { export class DiscogsService {
private readonly logger = new Logger(DiscogsService.name); private readonly logger = new Logger(DiscogsService.name);
private readonly token = process.env.DISCOGS_TOKEN ?? '';
private readonly userAgent = 'radiOLA/1.0 +https://radiola.app'; private readonly userAgent = 'radiOLA/1.0 +https://radiola.app';
// Глобальный rate-limiter: Discogs ~60 запросов/мин. Разносим вызовы ≥1.1с // Несколько токенов Discogs — лимит ~60/мин НА ТОКЕН. У каждого свой слот,
// независимо от параллельности обогащения (иначе 429). // выбираем наименее загруженный → суммарно N×~54/мин без 429.
private nextSlot = 0; private readonly tokens = [
process.env.DISCOGS_TOKEN ?? '',
process.env.DISCOGS_TOKEN2 ?? '',
].filter((t) => t.length > 0);
private readonly slots: number[] = this.tokens.map(() => 0);
private readonly minIntervalMs = 1100; private readonly minIntervalMs = 1100;
// Без токена обогащение жанрами не работает (поиск требует авторизации) // Без токена обогащение жанрами не работает (поиск требует авторизации)
get enabled(): boolean { get enabled(): boolean {
return this.token.length > 0; return this.tokens.length > 0;
} }
private async rateLimit(): Promise<void> { // Резервирует слот наименее загруженного токена, ждёт его и возвращает токен
private async pickToken(): Promise<string> {
let idx = 0;
for (let i = 1; i < this.slots.length; i++) {
if (this.slots[i] < this.slots[idx]) idx = i;
}
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; this.slots[idx] = start + this.minIntervalMs;
const wait = start - now; const wait = start - now;
if (wait > 0) await new Promise((r) => setTimeout(r, wait)); if (wait > 0) await new Promise((r) => setTimeout(r, wait));
return this.tokens[idx];
} }
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;
await this.rateLimit(); const token = await this.pickToken();
const params = new URLSearchParams({ const params = new URLSearchParams({
artist, artist,
track: song, track: song,
type: 'release', type: 'release',
per_page: '5', per_page: '5',
token: this.token, token,
}); });
const url = `https://api.discogs.com/database/search?${params.toString()}`; const url = `https://api.discogs.com/database/search?${params.toString()}`;