# 🎵 Beatport Discography Searcher — Техническая документация (максимально подробно)

> Версия: 1.0.0-universal-audited-xlsx-modal  
> Дата: 2026-07-15  
> Стек: Next.js 16 (App Router, Turbopack) + TypeScript + Tailwind 4 + Beatport API v4 OAuth2 + ExcelJS  
> Автор аудита: AI fullstack engineer

---

## Оглавление

1. [Назначение и обзор](#1-назначение-и-обзор)
2. [Архитектура высокого уровня](#2-архитектура-высокого-уровня)
3. [Стек и зависимости](#3-стек-и-зависимости)
4. [Beatport API v4 — подробности](#4-beatport-api-v4--подробности)
5. [OAuth2 Authorization Code Flow — алгоритм](#5-oauth2-authorization-code-flow--алгоритм)
6. [Менеджмент токенов](#6-менеджмент-токенов)
7. [Универсальная конфигурация (multi-user)](#7-универсальная-конфигурация-multi-user)
8. [Кэширование и устойчивость](#8-кэширование-и-устойчивость)
9. [Модели данных](#9-модели-данных)
10. [Backend: API Routes (наши)](#10-backend-api-routes-наши)
11. [Frontend: архитектура клиента](#11-frontend-архитектура-клиента)
12. [Алгоритм 50% — фильтрация собственных релизов](#12-алгоритм-50--фильтрация-собственных-релизов)
13. [Алгоритм расчёта длительности](#13-алгоритм-расчёта-длительности)
14. [Сортировка, фильтрация, пагинация](#14-сортировка-фильтрация-пагинация)
15. [Экспорт CSV и XLSX MAX](#15-экспорт-csv-и-xlsx-max)
16. [Модалка релиза](#16-модалка-релиза)
17. [Обработка ошибок и HTML-детекция](#17-обработка-ошибок-и-html-детекция)
18. [Производительность и оптимизации](#18-производительность-и-оптимизации)
19. [Безопасность](#19-безопасность)
20. [Деплой, окружение, кросс-платформенность](#20-деплой-окружение-кросс-платформенность)
21. [Структура проекта](#21-структура-проекта)
22. [Сценарии использования (User Flows)](#22-сценарии-использования-user-flows)
23. [Контрольные точки на примере Delerium](#23-контрольные-точки-на-примере-delerium)
24. [Roadmap и возможные улучшения](#24-roadmap-и-возможные-улучшения)
25. [Troubleshooting](#25-troubleshooting)

---

## 1. Назначение и обзор

Веб-сервис для **поиска и просмотра полной дискографии** исполнителей электронной музыки на платформе **Beatport**. 

**Вход:** имя исполнителя (например, «Delerium»)  
**Выход:**
- Список найденных артистов (ID, имя, фото)
- Шапка профиля: фото, имя, статистика (собственных релизов ≥50%, всего релизов, треков, как артист/ремиксер, исключено по 50%)
- 3 вкладки:
  1. **Releases** — собственные релизы: `artists[0] == искомый` **И** участие ≥ threshold (50% по умолчанию) в треклисте
  2. **All Releases** — все релизы с участием (бейджи Автор #1 / Участник + % участия + длительность)
  3. **Tracks** — все треки с превью (30с), BPM, ключ (Camelot), жанр, длина, роль
- Модалка релиза при клике: полная инфо + полный треклист с API Beatport + локальные треки артиста в релизе
- Экспорт: CSV и XLSX MAX (7 листов, 50+ колонок, авторасширение, цветовая схема)

**Ключевые принципы:**
- **No DB** — все live с Beatport API, in-memory LRU кэш TTL 5/10/15 мин
- **Universal** — любой пользователь может ввести свои `client_id / username / password` при первом запуске
- **Resilient** — retry с exponential backoff + jitter, HTML detection, auto-refresh токена за 60с
- **Audited** — debounce, shareable URL `?q=&artist=`, recent searches, сортировка, фильтры, пагинация, lazy images, keyboard shortcuts

---

## 2. Архитектура высокого уровня

```
Browser (React Client)
  ├─ SearchBar (debounce 400ms, recent, shareable URL)
  ├─ TokenPanel (countdown, progress, auto-refresh)
  ├─ ConfigModal (user_file, env, default)
  ├─ ArtistProfile + Stats
  ├─ Tabs:
  │   ├─ Releases (≥50%, sorted, duration)
  │   ├─ All Releases (participation %, duration)
  │   └─ Tracks (filter genre/BPM, sort, pagination 50, CSV/XLSX MAX)
  ├─ ReleaseModal (full info + tracklist)
  └─ AudioPlayer (fixed bottom)
         │
         │ fetchJsonSafe() — detects HTML, handles errors
         ▼
Next.js API Routes (Node, dynamic)
  ├─ /api/beatport/search?q=
  ├─ /api/beatport/artist/[id]
  ├─ /api/beatport/artist/[id]/discography → getFullDiscography() (cached)
  ├─ /api/beatport/release/[id] → release + tracks
  ├─ /api/beatport/token → GET status, POST refresh/full
  ├─ /api/beatport/config → GET/POST/DELETE user config
  └─ /api/health → DB optional + token/config status
         │
         │ beatportFetch() — Authorization: Bearer, 401→refresh, 429/5xx→backoff
         ▼
Beatport API v4 (https://api.beatport.com)
  ├─ /v4/catalog/search/?q=&type=artist
  ├─ /v4/catalog/artists/{id}/
  ├─ /v4/catalog/artists/{id}/tracks/?per_page=100&page=
  ├─ /v4/catalog/releases/?artist_id=&per_page=&page=
  ├─ /v4/catalog/releases/{id}/
  └─ /v4/catalog/releases/{id}/tracks/
         ▲
Beatport Account (OAuth2)
  ├─ POST /identity/v1/login/ → sessionid, csrftoken cookies
  ├─ GET /o/authorize/?client_id=&redirect_uri=&response_type=code → 302 Location: code
  └─ POST /o/token/ → access_token (600s) + refresh_token (rotation)
```

**Поток данных дискографии:**

```mermaid
sequenceDiagram
    autonumber
    participant User
    participant UI as Next.js Page
    participant API as /api/discography
    participant Cache as LRU Cache
    participant BP as Beatport API
    participant Token as TokenManager

    User->>UI: Выбирает артиста 10426
    UI->>API: GET /artist/10426/discography
    API->>Cache: get disco:10426?
    alt Cache miss
        API->>Token: getValidToken()
        Token->>BP: login/authorize/token if needed
        BP-->>Token: access_token
        par Parallel fetch
            API->>BP: /artists/10426/tracks/?per_page=100&page=1..N (until next==null, 150ms jitter)
            API->>BP: /releases/?artist_id=10426&per_page=100&page=1..M
        end
        BP-->>API: 839 tracks, 223 releases
        API->>Cache: set disco:10426 TTL 15min
    else Cache hit
        Cache-->>API: cached tracks/releases
    end
    API-->>UI: {tracks, releases}
    UI->>UI: releaseDurations = group tracks by release.id, sum length_ms
    UI->>UI: stats = filter author#1 + ≥threshold
    UI-->>User: Render 3 tabs + durations
```

---

## 3. Стек и зависимости

| Слой | Технология | Версия | Назначение |
|---|---|---|---|
| Framework | Next.js | 16.2.6 (Turbopack) | App Router, SSR/CSR, API Routes |
| Language | TypeScript | 5.9.3 | Типизация |
| UI | React | 19.2.6 | Клиент |
| Styling | Tailwind CSS | 4.1.17 | Dark theme, #FF6B00 accent |
| HTTP | fetch (native) | Node 20 / Browser | Beatport calls |
| Excel | exceljs | latest | XLSX MAX export (browser via writeBuffer) |
| DB (optional) | Drizzle ORM + pg | 0.45.2 / 8.20.0 | Health check `select 1`, не обязателен |
| Cache | Custom SimpleCache | in-file | LRU in-memory TTL |
| Build | @tailwindcss/postcss, postcss | 4.1.17 / 8.5.8 | PostCSS |

**package.json scripts:**
- `dev`: `next dev`
- `build`: `next build` (Turbopack)
- `start`: `next start`
- `lint`: `eslint .`
- `typecheck`: `tsc --noEmit`

---

## 4. Beatport API v4 — подробности

**Base:** `https://api.beatport.com/v4`  
**Auth:** `Authorization: Bearer <access_token>` (JWT, 10 мин TTL)  
**Pagination:** `?per_page=100&page=1` → ответ `{results:[], count:223, next:"api.beatport.com/...&page=2", previous:null, page:"1/112", per_page:100}`  
**Rate limits:** не задокументированы, но наблюдались 429 при массовом парсинге → реализован backoff 120-200ms jitter между страницами.

**Используемые endpoints (наши):**

| Наш роут | Beatport endpoint | Назначение |
|---|---|---|
| `/search` | `/v4/catalog/search/?q=&type=artist&per_page=` | Поиск артистов, возвращает `{order, artists[], releases[], ...}` |
| `/artist/[id]` | `/v4/catalog/artists/{id}/` | Детали: id, name, slug, image {uri, dynamic_uri}, bio |
| `/discography` tracks | `/v4/catalog/artists/{id}/tracks/?per_page=100&page=` | Все треки артиста, пагинация до `next==null` (839 для Delerium) |
| `/discography` releases | `/v4/catalog/releases/?artist_id=&per_page=100&page=` | Все релизы с участием (223) |
| `/release/[id]` details | `/v4/catalog/releases/{id}/` | Детали релиза: artists, remixers, label, catalog_number, track_count, bpm_range, price, upc, exclusive, etc |
| `/release/[id]` tracks | `/v4/catalog/releases/{id}/tracks/?per_page=100&page=` | Полный треклист релиза (например, 6 треков для Remixes) |

**Структура Track (сокращённо):**
```ts
{
  id: 29024243,
  name: "Moonshadow",
  mix_name: "Delerium Remix",
  artists: [{id:10426, name:"Delerium"}],
  remixers: [{id:10426, name:"Delerium"}],
  release: {id:6963239, name:"Remixes", label:{name:"Metropolis Records"}},
  genre: {name:"Rock"},
  sub_genre: {name:"Punk"},
  bpm: 160,
  key: {name:"E Major", camelot_number:12, camelot_letter:"B", letter:"E"},
  length: "6:31",
  length_ms: 391777,
  publish_date: "2026-06-12",
  sample_url: "https://geo-samples.beatport.com/track/...LOFI.mp3",
  price: {display:"$1.69", value:1.69},
  isrc: "US57M2540902",
  url: "https://api.beatport.com/v4/catalog/tracks/29024243/"
}
```

**Структура Release:**
```ts
{
  id: 6963239,
  name: "Remixes",
  slug: "remixes",
  artists: [{id:292443, name:"Magic Wands"}],
  remixers: [{id:10426, name:"Delerium"}, ...],
  label: {id:15216, name:"Metropolis Records"},
  catalog_number: "MET 1475D",
  publish_date: "2026-06-12",
  track_count: 6,
  bpm_range: {min:87, max:160},
  price: {display:"$10.14"},
  image: {uri:"...", dynamic_uri:"https://geo-media.beatport.com/image_size/{w}x{h}/..."},
  upc: "823375188360",
  url: "https://api.beatport.com/v4/catalog/releases/6963239/"
}
```

**Изображения:** `dynamic_uri` содержит `{w}x{h}` placeholder → заменяем на `300x300` для карточек, `500x500` для модалки.

---

## 5. OAuth2 Authorization Code Flow — алгоритм

> Beatport использует OAuth2 Authorization Code Flow с PKCE-подобным, но без PKCE, через cookies. Client ID публичный (встроен в JS бандл `https://api.beatport.com/v4/docs/`).

```mermaid
sequenceDiagram
    autonumber
    participant Script as Server (lib/beatport.ts)
    participant IDS as account.beatport.com
    participant API as api.beatport.com

    Note over Script,IDS: Шаг 1 — Логин (получение сессии)
    Script->>IDS: POST /identity/v1/login/ {username, password} Content-Type: application/json
    IDS-->>Script: 200 OK + Set-Cookie: sessionid=..., csrftoken=..., _cfuvid=...
    Note over Script: extractCookies() → "sessionid=...; csrftoken=..."

    Note over Script,IDS: Шаг 2 — Authorization Code
    Script->>IDS: GET /o/authorize/?client_id=...&redirect_uri=https%3A%2F%2Faccount.beatport.com%2Fo%2Fpost-message%2F&response_type=code + Cookie
    IDS-->>Script: 302 Location: /o/post-message/?code=XXXX&target=https://api.beatport.com
    Note over Script: parse Location header regex /code=([^&]+)/ → code

    alt No code in 302 (Cloudflare)
        Script->>IDS: GET same URL redirect:follow
        IDS-->>Script: 200 HTML + final URL contains ?code=
        Script->>Script: parse autoRes.url + body regex
    end

    Note over Script,IDS: Шаг 3 — Обмен code на токен
    Script->>IDS: POST /o/token/ (x-www-form-urlencoded) client_id, code, grant_type=authorization_code, redirect_uri
    IDS-->>Script: 200 {access_token (JWT, 600s), refresh_token, expires_in:600, token_type:Bearer, scope}

    Note over Script,API: Шаг 4 — Использование API
    Script->>API: GET /v4/catalog/search/?q=... Authorization: Bearer access_token
    API-->>Script: JSON

    Note over Script,IDS: Шаг 5 — Обновление (когда протух или 401)
    Script->>IDS: POST /o/token/ client_id, refresh_token, grant_type=refresh_token
    IDS-->>Script: {access_token (новый, 600s), refresh_token (новый! rotation), expires_in}
    Note over Script: Сохранить новый refresh_token! Старый инвалидируется.
```

**Детали реализации в `beatportFullLogin()`:**

1. `fetch(LOGIN_URL, {method:POST, body:JSON.stringify({username,password})})`
2. `getSetCookieArray()` — использует `res.headers.getSetCookie()` (Node) или fallback `get('set-cookie')`
3. `extractCookies()` — берет `name=value` до `;`
4. `fetch(AUTHORIZE_URL + ?client_id... , {headers:{Cookie}, redirect:'manual'})`
5. Парсит `Location` header на `code`, если нет — второй запрос с `redirect:'follow'` и парсит `autoRes.url` + body regex
6. `POST TOKEN_URL` с `URLSearchParams` (x-www-form-urlencoded)
7. `safeJsonParse()` — детектит HTML vs JSON
8. Сохраняет `TokenData {access_token, refresh_token, expires_at: now+expires_in*1000, issued_at: now, expires_in}` в `/tmp/bp_tokens.json`

**Refresh flow `beatportRefreshToken()`:**
- `POST TOKEN_URL` с `refresh_token`
- При успехе — новый `access_token` + **новый** `refresh_token` (rotation)
- При ошибке — fallback на `beatportFullLogin()`

---

## 6. Менеджмент токенов

**TokenData:**
```ts
{
  access_token: string (JWT ~1145 chars),
  refresh_token: string (e.g. "9CTdKwNcMMVwhWkQ12qHwv63JSCAFx"),
  expires_at: number (ms timestamp),
  issued_at: number,
  expires_in: number (600)
}
```

**Хранение:** `/tmp/bp_tokens.json` (путь переопределяется `BP_TOKEN_PATH`), права 600, JSON.

**Жизненный цикл:**

- `getValidToken()` — основная точка входа для всех `beatportFetch()`
  - Загружает с файла (once)
  - Если `tokenPromise` уже в процессе — ждет его (lock)
  - Если токен валиден с buffer 60s (`expires_at - 60s > now`) → возвращает
  - Если есть `refresh_token` → пытается `beatportRefreshToken()`, при ошибке → `beatportFullLogin()`
  - Если нет токена → `beatportFullLogin()`
- `getTokenStatus()` — для UI: считает `remaining_seconds = max(0, floor((expires_at - now)/1000))`, `isExpired`, `access_preview = first12...last6`
- `forceRefreshToken(mode)` — `refresh` (использует refresh_token) или `full` (удаляет файл токена и делает full login)
- Авто-перевыпуск на фронте: checkbox `bp_auto_refresh` в localStorage, `setInterval 5s` проверяет `remaining_seconds <=60` → `POST /api/beatport/token {mode:refresh}`

**Обработка 401 в `beatportFetch()`:**
- При 401 и `attempt==0` → пробует refresh/full login → `continue` (retry с новым токеном)
- При 401 и `attempt>0` → бросает `Unauthorized after refresh`
- При 429 / 5xx → exponential backoff: `delay = min(1000*2^attempt + random*500, 8000)`

**UI токена:**
- Текущий статус: dot green/red/amber, countdown `MM:SS` / `Hч Mм`, прогрессбар lifetime `(elapsed/total)*100%`
- Кнопки: «Перевыпустить token» (refresh), «Full login» (full)
- Чекбокс авто-перевыпуска
- Показывает `issued_at`, `expires_at`, `access_preview`, `configPath`

---

## 7. Универсальная конфигурация (multi-user)

**Проблема:** изначально сервис использовал хардкод `musinjector / PiU$dnjNTB6Xk2@` + client_id публичный. Нужно позволить любому пользователю ввести свои данные при первом запуске.

**Приоритет получения credentials в `getCredentials()`:**

1. **Файл пользователя** `/tmp/bp_user_config.json` (или `BP_CONFIG_PATH` / `BEATPORT_CONFIG_PATH`) — JSON `{client_id, username, password, updated_at}` + дубль в `~/.beatport/config.json` для кросс-платформенности (Linux/macOS/Windows)
2. **Env vars** `BEATPORT_CLIENT_ID`, `BEATPORT_USERNAME`, `BEATPORT_PASSWORD` (или `BP_CLIENT_ID` etc) — для деплоя
3. **Демо fallback** `DEFAULT_CLIENT_ID = 0GIvkCltVIuPkkwSJHp6NDb3s0potTjLBQr388Dd`, `DEFAULT_USERNAME = musinjector`, `DEFAULT_PASSWORD = PiU$dnjNTB6Xk2@` — для разработки

**Файлы:**
- Config: `/tmp/bp_user_config.json` (600) + `~/.beatport/config.json`
- Token: `/tmp/bp_tokens.json` (переопределение `BP_TOKEN_PATH`, `BP_TOKEN_DIR`, `BP_CONFIG_PATH`)
- На Windows: `C:/Users/<username>/tmp/` или `%USERPROFILE%/.beatport/`

**API `/api/beatport/config`:**
- `GET` → `{configured: boolean, hasUserFile, source: "user_file"|"env"|"default", client_id_masked: "0GIvkClt...88Dd", username_masked: "mu***", hasPassword, configPath, cacheStats}`
  - Если `hasUserFile`, возвращает `client_id_full` для автозаполнения формы (только если пользователь сам вводил)
  - Никогда не возвращает пароль или полный токен
- `POST {client_id, username, password}` → валидация (client_id ≥10 chars, username ≥2, password ≥3), `saveUserConfig()`, очистка кэшей (search, artist, discography), `forceRefreshToken("full")` для проверки, возвращает `tokenStatus` + `tokenError` если не удалось получить токен
- `DELETE` → `clearUserConfig()` + удаление токена, возврат к demo/env

**Frontend:**
- Header бейдж источника: `ваш аккаунт` green / `env` blue / `демо` amber
- Баннер если `source=default` и нет пользовательского файла: «Используется демо-конфигурация... Ввести свои данные» + путь к файлу + env vars подсказка
- Модалка настройки (z-80): поля Client ID (mono), логин, пароль (password), подсказки где взять Client ID (из JS бандла docs), scope, пути файлов
- Авто-открытие модалки если `!configured` (при деплое без дефолтов)
- Кнопка ⚙️ Настроить в header, `openConfigForm()` префиллит client_id_full если есть

**Безопасность конфига:**
- Пароль хранится plaintext в файле 600, не попадает в git (добавить в .gitignore `bp_user_config.json`, `.beatport/`)
- API отдаёт только masked preview
- При сохранении — `fs.writeFile(path, JSON.stringify(toSave, null, 2), {mode:0o600})`

---

## 8. Кэширование и устойчивость

**`src/lib/cache.ts` — SimpleCache<T>:**

```ts
class SimpleCache<T> {
  map: Map<string, {value:T, expiresAt:number, hits:number}>
  maxSize: 100-200
  set(key, value, ttlMs) { if size>=maxSize evict oldest expiresAt; set expiresAt=now+ttlMs }
  get(key) { if expired delete, else hits++ and return value }
}
```

**Глобальные кэши:**
- `searchCache`: 200 entries, TTL 5 мин, ключ `search:${q.toLowerCase()}:${perPage}`
- `artistCache`: 200 entries, TTL 10 мин, ключ `artist:${id}`
- `discographyCache`: 100 entries, TTL 15 мин, ключ `disco:${artistId}`, хранит `{tracks, releases}` уже отсортированные

**Устойчивость `beatportFetch()`:**
- Retry 3 попытки
- 401 → refresh token → retry
- 429 / 5xx → exponential backoff `1000*2^attempt + random*500`, max 8s
- Jitter 120-200ms между страницами пагинации (`sleep(120+random*80)`)
- HTML detection → retry once
- `safeJsonParse()` — читает text, trims, если startsWith `<!` / `<html` → бросает `Beatport returned HTML... Preview: ...`, иначе `JSON.parse`

**Кэш инвалидация:**
- При `saveUserConfig()` / `clearUserConfig()` → `searchCache.clear(), artistCache.clear(), discographyCache.clear()`
- При `forceRefreshToken("full")` — токен файл удаляется, но кэш остаётся (т.к. данные артиста не зависят от аккаунта)

---

## 9. Модели данных

**BeatportImage:** `{uri?: string, dynamic_uri?: string}` — dynamic содержит `{w}x{h}` placeholder

**Artist:** `{id: number, name: string, slug?: string, image?: BeatportImage, url?: string}`

**Release (наш упрощённый, но API отдаёт больше):**
```ts
{
  id: number, name: string, slug: string,
  artists: Artist[], remixers: Artist[],
  label: {id, name, slug?},
  catalog_number: string,
  publish_date: string (ISO "2026-06-12"),
  new_release_date?: string,
  track_count: number,
  image?: BeatportImage,
  price?: {display, value},
  bpm_range?: {min, max},
  upc?: string,
  exclusive?: boolean,
  url?: string,
}
```

**Track:**
```ts
{
  id: number,
  name: string, mix_name?: string,
  artists: Artist[], remixers: Artist[],
  release: Release & {label?: any, image?: any},
  genre?: {name, id?}, sub_genre?: {name, id?},
  bpm?: number,
  key?: {name, camelot_number, camelot_letter, letter},
  length?: string "6:31", length_ms?: number 391777,
  publish_date?: string, new_release_date?, encoded_date?,
  isrc?: string,
  price?: {value, display, code},
  exclusive?, is_explicit?, available_worldwide?, is_available_for_streaming?, is_hype?,
  sample_url?: string, sample_start_ms?, sample_end_ms?,
  slug?: string, url?: string, catalog_number?: string
}
```

**Discography:** `{tracks: Track[], releases: Release[]}`

**Stats (вычисляемые):**
```ts
{
  releasesAuthor1Count: number, // после фильтра ≥threshold
  releasesAuthor1CountRaw: number, // до фильтра author[0]==id
  releasesExcludedCount: number,
  releasesAuthor1: Release[], // filtered
  releasesAuthor1Raw: Release[], // raw
  releasesExcludedBy50: Release[], // excluded
  allReleasesCount: number,
  tracksCount: number,
  asArtist: number, // tracks where artists contains id
  asRemixer: number,
  author1Tracks: number, // tracks where artists[0]==id
}
```

**OverallDurations:**
```ts
{
  author1TotalMs: number, // sum length_ms of filtered own releases (artist tracks)
  allTotalMs: number, // sum of all releases' artist tracks
  tracksTotalMs: number, // sum of all tracks length_ms
  excludedTotalMs: number, // sum of excluded releases' artist tracks
}
```

**ReleaseDurations (Map):**
```ts
Map<releaseId, {totalMs: number, count: number}>
// count = сколько треков артиста в этом релизе (из disco.tracks)
// totalMs = сумма length_ms этих треков
```

---

## 10. Backend: API Routes (наши)

Все `export const dynamic = "force-dynamic"` — SSR on demand.

### `GET /api/beatport/search?q=&per_page=`
- Валидация: `q.trim().length>0` else 400 `Введите имя исполнителя`
- Вызывает `searchArtists(q, perPage=15)` → `beatportFetch(/v4/catalog/search/?q=&type=artist&per_page=)`
- Возвращает `{artists: data.artists, raw: data}` (raw для дебага)
- Кэшируется в `searchCache` 5 мин

### `GET /api/beatport/artist/[id]`
- `id` из params
- `getArtistDetails(id)` → cached 10 мин
- Возвращает детали артиста

### `GET /api/beatport/artist/[id]/discography`
- `maxDuration=60` (Vercel/Next limit)
- Вызывает `getFullDiscography(id)` → кэш 15 мин, иначе параллельно `fetchAllTracks` + `fetchAllReleases` с пагинацией
- Пагинация: `page=1..N` until `next==null`, `per_page=100`, `sleep(120+random)` между страницами
- Сортировка по дате `publish_date || new_release_date` desc
- Возвращает `{artistId, tracks, releases, counts:{tracks, releases}}`
- Для Delerium: 839 tracks, 223 releases, ~13 сек

### `GET /api/beatport/release/[id]`
- Новый роут для модалки
- Параллельно `beatportFetch(/v4/catalog/releases/{id}/)` + `fetchAllReleaseTracks(id)` (пагинация треков релиза)
- Возвращает `{release, tracks, counts:{tracks}}`
- Пример: Remixes ID 6963239 → 6 tracks

### `GET /api/beatport/token`
- `getTokenStatus()` → `{hasToken, issued_at, expires_at, expires_in, remaining_seconds, isExpired, access_preview, refresh_preview, config}`

### `POST /api/beatport/token {mode: "refresh"|"full"}`
- `forceRefreshToken(mode)` → новая пара токенов, сохранённая в файл
- Возвращает `{success, mode, status}`

### `GET /api/beatport/config`
- `getConfigStatus()` + `loadUserConfig()` → `client_id_full` если user_file
- Возвращает masked данные + cacheStats

### `POST /api/beatport/config {client_id, username, password}`
- Валидация длины
- `saveUserConfig()` → очистка кэшей → `forceRefreshToken("full")` для проверки
- Возвращает `{success, message, config, tokenStatus, tokenError}`

### `DELETE /api/beatport/config`
- `clearUserConfig()` + удаление токена файла → возврат к demo/env

### `GET /api/health`
- Опционально `db.execute(select 1)` (стартовый шаблон, не обязателен)
- `getTokenStatus()` + `getConfigStatus()` → `{ok, timestamp, service, version, token:{hasToken,isExpired,remaining}, config:{configured,source,hasUserFile}}`

**Rate limiting нашей стороны:** нет официального, но есть in-memory кэш и jitter, чтобы не ддосить Beatport.

---

## 11. Frontend: архитектура клиента

**`src/app/page.tsx` — single client component (monolit ~1000 lines, можно разбить на components/ в roadmap)**

**State:**

```ts
query, debouncedQuery (useDebounce 400ms)
artists, selectedArtist, artistDetails, discography
activeTab: "releases" | "allReleases" | "tracks"
isSearching, isLoadingDiscography, error, searchError
playingTrack, audioRef
tokenStatus, tokenLoading, tokenError, autoRefresh (localStorage bp_auto_refresh)
configStatus, configForm {client_id,username,password}, showConfigForm, configLoading, configError, configSuccess
threshold (0.5, localStorage bp_threshold), recentSearches (localStorage bp_recent, max 8)
trackSortKey, trackSortDir, releaseSortKey, releaseSortDir
trackPage, tracksPerPage=50
trackFilterGenre, trackFilterBpm {min,max}
isExportingXlsx
selectedReleaseModal, releaseModalDetails, isReleaseModalLoading, releaseModalError
```

**Hooks & Effects:**

- `useDebounce(query, 400)` — для будущего auto-search (сейчас ручной, но debounced используется для URL)
- `useEffect` load from localStorage: auto_refresh, threshold, recent, URL params `?q=&artist=`
- `useEffect` save to localStorage
- `useEffect` keyboard shortcuts: `/` focuses search, `Esc` closes config, player, release modal
- `useEffect` update URL for shareable links: `history.replaceState` with `?q=&artist=`
- `useEffect` fetchTokenStatus every 3s, fetchConfigStatus on mount
- `useEffect` auto-refresh token every 5s if remaining <=60
- `useEffect` audio play when playingTrack changes

**Memoized Computations (`useMemo`):**

- `releaseDurations`: `Map<releaseId, {totalMs, count}>` — группировка `discography.tracks` по `release.id`, суммирование `length_ms` (fallback parse `length` "M:SS" → ms)
- `getReleaseParticipation(release)` — `useCallback` с `releaseDurations`: `{count, total: track_count, rate: count/total, totalMs}`
- `stats`: фильтрует `releasesAuthor1Raw = releases.filter(artists[0].id==artistId)` → `releasesAuthor1 = raw.filter(rate>=threshold)` (правило 50%), `releasesExcludedBy50 = raw - filtered`, считает `asArtist`, `asRemixer`, etc.
- `overallDurations`: суммирует `totalMs` для author1, all, tracks, excluded
- `filteredTracks`: фильтр по жанру (includes lower), BPM min/max, сортировка по `trackSortKey` (bpm, genre, duration, date, role)
- `paginatedTracks`: `slice((page-1)*50, page*50)`
- `sortedReleasesAuthor1` / `sortedAllReleases`: сортировка по date/name/duration/participation
- `uniqueGenres`: Set genre names

**Handlers:**

- `fetchTokenStatus(silent)`, `fetchConfigStatus(silent)` — `fetchJsonSafe`
- `handleRefreshToken(mode)`, `handleSaveConfig`, `handleDeleteConfig`, `openConfigForm`
- `handleSearch(e, overrideQuery?)` — `fetchJsonSafe(/api/beatport/search?q=)`, сохраняет recent, если 1 результат → auto select
- `handleSelectArtist(artist)` — parallel `fetch /artist/[id]` + `/discography`, setTrackPage 1
- `handleExportXLSX()` — `await import("@/lib/xlsxExport")` dynamic, calls `exportDiscographyToXLSX(artist, discography, releaseDurations, stats, overallDurations, threshold, configSource)`
- `openReleaseModal(release)` — set selected, fetch `/api/beatport/release/{id}`, loading state
- `closeReleaseModal()`

**UI Компоненты (внутри page.tsx):**

- Header: logo B, title, config source badge (user_file green / env blue / demo amber), token remaining badge, ⚙️ Настроить button
- Banner demo config if `source=default` && !showConfigForm
- Config Modal (z-80): overlay black/70 backdrop-blur, form with client_id mono, username, password, validation, success/error, delete button
- Token Panel: 3 cards issued/expires/remaining, progress bar `(elapsed/total)*100%`, buttons refresh/full login, checkbox auto-refresh
- Search: input with search icon, button Find with spinner, recent searches chips + clear
- Artists list (if >1): grid 2 columns, cards with image lazy, name, ID
- Profile header: avatar 128x128 rounded 20px, name 4xl, ID badge, new search button, stats grid 6 cards with durations (author1TotalMs short)
- Threshold slider: range 0-100 step 5, value `threshold*100%`, explanation
- Tabs: Releases (≥threshold) / All Releases / Tracks with counts and durations
- Release cards grid: 5 columns xl, 4 lg, 3 md, 2 sm, aspect-square cover, badge Автор #1 + participation %, duration bottom right with clock icon, label, catalog, date, track count + duration, excluded warning if count!=track_count
- Tracks table: sticky header, columns ▶, Название (name + mix), Артисты, Ремиксеры, Релиз (link), Жанр, Под-жанр, BPM, Ключ (name + camelot badge), Длина, Дата, Роль (badge colors). Sortable headers clickable, filter bar (genre select, BPM min/max, export buttons)
- Pagination: prev/next, page info, per page count
- Release Modal (z-70): overlay black/80 blur, max-w-4xl, max-h 90vh flex col, header with cover 80-96px, title, artists, remixers, participation badges, close ✕, content scroll: grid 3 cols info cards (label, catalog, dates, track_count, BPM range, price/UPC, exclusive/explicit/hype, duration, links), artists list chips (вы highlighted), remixers, full tracklist table from Beatport API (300px scroll, #/name/artists/remixers/BPM/key/length/▶), artist tracks in release (from discography), footer with ID/track_count/duration + Beatport open + Close
- AudioPlayer (fixed bottom z-50): cover 48px, track name + artists, audio controls, close ✕, bottom padding placeholder
- Footer

**fetchJsonSafe():**
- Reads `res.text()`, checks `content-type` includes `application/json`, if HTML (`<!`, `<html`) → throws `Сервер вернул HTML...`
- Tries `JSON.parse`, if fails and HTML → `<!doctype ... is not valid JSON` error fixed → shows «Попробуйте перевыпустить токен»
- If `!res.ok` → throws `json.error || Ошибка status`

---

## 12. Алгоритм 50% — фильтрация собственных релизов

**ТЗ:** «Релизы, в которых искомый исполнитель указан на первом месте в списке авторов альбома/релиза» — это собственные. Но добавлено правило: «не считать релизы собственными, если в треклисте менее 50% треков искомого исполнителя (не принимает участия в треке, нет тегов с названием артиста). Такой релиз нужно отнести к All Releases».

**Реализация:**

```ts
// 1. Группировка треков артиста по релизу (из discography.tracks)
releaseDurations = Map<releaseId, {totalMs, count}>
  for track in tracks:
    rid = track.release.id
    map[rid].count += 1
    map[rid].totalMs += track.length_ms (or parse length)

// 2. Сырые собственные релизы
releasesAuthor1Raw = releases.filter(r => r.artists[0]?.id == artistId)

// 3. Функция участия
function getParticipation(release):
  info = releaseDurations.get(release.id)
  count = info?.count || 0
  total = release.track_count
  rate = total>0 ? count/total : 0
  return {count, total, rate, totalMs}

// 4. Фильтр по порогу (threshold configurable via slider, default 0.5)
releasesAuthor1 = raw.filter(r => {
  const {count, total} = getParticipation(r)
  if (total==0) return count>0
  return count/total >= threshold
})

releasesExcludedBy50 = raw.filter(r => !releasesAuthor1.includes(r))

// 5. Вывод
// Releases tab = releasesAuthor1 (≥threshold)
// All Releases tab = all releases (including excluded)
// Excluded badge: Автор #1 • 3% <50% amber
```

**Пример Delerium:**
- Raw author#1: 113
- Filtered ≥50%: 105
- Excluded: 8
  - `Chillout Friends`: 4/16=25% label Cleopatra
  - `Armada Electronic Elements Ibiza 2022`: 1/40=2.5%
  - `Trance Top 1000 Selection Vol.7`: 1/16=6.25%
  - etc. — компиляции Various Artists где Delerium первый по алфавиту

**Настраиваемый порог:**
- Slider 0-100 step 5, state `threshold`, localStorage `bp_threshold`
- UI: `Релизов (автор #1 ≥50%)` + `сырых: 113 • искл. 8 по 50%`
- В карточках: `1/40 • 3%` chip, excluded warning `Исключён из Releases: 1/40 (3% <50%)`

---

## 13. Алгоритм расчёта длительности

**Вход:** `Track.length_ms` (например 391777 ms) + fallback `Track.length` string "6:31"

**Парсинг fallback:**
```ts
if (!length_ms && length) {
  const parts = length.split(":").map(Number)
  if (parts.length==2) ms = (parts[0]*60 + parts[1])*1000
  else if (parts.length==3) ms = (parts[0]*3600 + parts[1]*60 + parts[2])*1000
}
```

**Агрегация по релизу:**
```ts
releaseDurations: Map<releaseId, {totalMs, count}>
totalMs = sum of length_ms of artist tracks in that release
count = number of artist tracks in release
```

**Форматирование:**

```ts
function formatDurationMs(ms):
  totalSeconds = floor(ms/1000)
  hours = floor(totalSeconds/3600)
  minutes = floor((totalSeconds%3600)/60)
  seconds = totalSeconds%60
  if hours>0 return `${hours}:${pad(minutes)}:${pad(seconds)}`
  else return `${minutes}:${pad(seconds)}`

function formatDurationShort(ms):
  hours = floor(totalSeconds/3600)
  minutes = floor((totalSeconds%3600)/60)
  if hours>0 return `${hours}ч ${minutes}м`
  else return `${minutes} мин`
```

**Где показывается:**
- На карточке релиза: badge bottom-right `6:31`, footer `42:15` + `учтено 1 треков артиста • 6 мин`
- В статистике: `Релизов (автор #1) 105 • 8ч 12м`, `Треков 839 • 52:14:33`
- В табах: `Releases 105 • 8ч 12м`, `Tracks 839 • 52:14:33`
- В модалке релиза: общая длительность треков артиста, avg BPM, etc.

**Overall durations:**
- `author1TotalMs = sum totalMs of filtered own releases`
- `allTotalMs = sum totalMs of all releases (artist tracks)`
- `tracksTotalMs = sum length_ms of all tracks`
- `excludedTotalMs = sum of excluded releases`

---

## 14. Сортировка, фильтрация, пагинация

**Треки:**

- **Фильтры:**
  - Жанр: select из `uniqueGenres` (Set of genre names), filter `genre.name includes` OR `sub_genre.name includes`, case-insensitive
  - BPM: min/max number inputs, `bpm >= min && bpm <= max`
  - Сохраняет `trackPage=1` при смене фильтра

- **Сортировка:** `trackSortKey` = `date|bpm|duration|genre|role|name`, `trackSortDir` asc/desc
  - `bpm`: numeric `(a.bpm||0)-(b.bpm||0)`
  - `genre`: string localeCompare
  - `duration`: `length_ms`
  - `date`: `publish_date` string compare
  - `role`: order function: remixer 2, author#1 0, artist 1
  - Clickable headers: `onClick => setSortKey + toggle dir`

- **Пагинация:** `tracksPerPage=50`, `totalPages=ceil(filtered.length/50)`, `paginated = slice((page-1)*50, page*50)`, prev/next buttons disabled at boundaries

**Релизы:**

- **Сортировка:** `releaseSortKey` = `date|name|duration|participation`, `releaseSortDir`
  - `date`: `publish_date` compare
  - `name`: localeCompare
  - `duration`: `releaseDurations.get(id).totalMs`
  - `participation`: `getParticipation(id).rate`
  - Clickable via select + ↑↓ button

- **Фильтр по порогу:** slider threshold, already applied to own releases

**Производительность:** все сортировки `useMemo`, O(n log n), для 839 треков мгновенно.

---

## 15. Экспорт CSV и XLSX MAX

### CSV (простой)

```ts
function exportTracksToCSV(tracks, artistName):
  headers = ["Название","Микс","Артисты","Ремиксеры","Релиз","Жанр","BPM","Ключ","Длина","Дата","Роль","Sample URL"]
  rows = tracks.map(t => [escaped fields].join(","))
  csv = [headers, ...rows].join("\n")
  Blob + URL.createObjectURL + a.click()
```

### XLSX MAX (exceljs)

**Установка:** `npm install exceljs` (96 packages)

**Функция `exportDiscographyToXLSX(artist, discography, releaseDurations, stats, overallDurations, threshold, configSource)`:**

- **Dynamic import:** `const ExcelJS = await import("exceljs")` client-side only (inside async handler), `new ExcelJS.Workbook()`
- **Workbook props:** creator "Beatport Discography Searcher", created now

**Стили:**
- Header fill: solid orange `#FFFF6B00`, font white bold 11, border thin #333, alignment center wrap, frozen `ySplit:1`, autoFilter `A1:LastCol1`
- Light fills: gray #F5F5F5, orangeLight #FFF0E0, purpleLight #F3E8FF, greenLight #E6FFED, amberLight #FFF3CD, redLight #FFE0E0
- Tab colors: Summary orange, Tracks blue #00BFFF, All Releases purple #9370DB, Own orange, Excluded red, Labels teal #20B2AA, Genres purple

**Автоширина:**
```ts
function calcAutoWidth(rows, min=12, max=45):
  for each col: max len of String(cell) across rows +2, capped 45
  col.width = width
```

**Листы (7):**

1. **Summary:** 2 колонки Поле/Значение, artist name, ID, slug, image URL, date export, config source, threshold, stats (raw, filtered, excluded, all, tracks, asArtist/remixer), durations formatted + ms, rule explanation. Width 35/60, header orange.

2. **All Tracks:** 50+ колонок (см. детально в коде):
   - #, Track ID, Track Name, Mix Name, Full Name, Artists joined/IDs/Count, Is Artist #1?, Role, Remixers joined/IDs, Is Remixer?, Release ID/Name/Slug, Label Name/ID, Catalog, Genre/ID, Sub-Genre/ID, BPM, Key Name/Letter, Camelot, Number/Letter, Length MM:SS/ms/sec, Publish/New/Encoded Dates, ISRC, Price Display/Value/Code, Exclusive?, Explicit?, Available Worldwide?, Streaming?, Hype?, Sample URL/Start/End, Slug, Track API URL, Beatport Track URL, Release API/Beatport URLs, Release Track Count official, Artist Tracks Count, Participation Rate %, Is Own?
   - Rows: `discography.tracks.forEach`
   - Coloring: Role Автор #1 → orangeLight, Ремиксер → purpleLight; Participation ≥threshold greenLight, >0 amberLight, 0 redLight
   - Auto width

3. **All Releases:** 37 колонок:
   - #, Release ID/Name/Slug, Artists joined/IDs/Is Author #1?/Count, Remixers, Label Name/ID, Catalog, Publish/New/Encoded Dates, Track Count official, Artist Tracks Count, Participation Rate %, Participation count/total, Is Own?, Is Excluded?, Total Duration ms/formatted/short, Avg BPM, BPM Range Min/Max, Price Display/Value, UPC, Exclusive/Explicit/Hype, Image URI, API URL, Beatport URL
   - Rows: `discography.releases`
   - Coloring: participation green/amber, excluded rows amber background

4. **Own Releases ≥threshold:** same headers as All Releases, rows = `stats.releasesAuthor1`, light orange row fill #FFF8F0

5. **Excluded <threshold>:** same headers, rows = `stats.releasesExcludedBy50`, amber fill #FFF3CD

6. **Labels Stats:** Label, Artist Tracks Count, Unique Releases Count, Total Duration ms, Formatted, Avg Length — sorted by count desc

7. **Genres Stats:** Genre, Tracks Count, Total Duration ms, Formatted, Avg BPM, Sum BPM — sorted by count

**Генерация и скачивание:**
```ts
const buffer = await workbook.xlsx.writeBuffer()
const blob = new Blob([buffer], {type:"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"})
const url = URL.createObjectURL(blob)
const a = createElement("a"); a.href=url; a.download=`${safeName}_Beatport_${date}_${tracks}tracks_${releases}releases.xlsx`; a.click()
```

**UI:** В шапке артиста `📊 XLSX MAX (839 треков, 223 релизов, 7 листов)` с лоадером, в фильтре треков `📊 XLSX MAX (filteredCount)` + CSV рядом.

---

## 16. Модалка релиза

**Trigger:** `onClick={() => openReleaseModal(release)}` на карточке релиза (вместо `window.open`)

**State:** `selectedReleaseModal: Release|null`, `releaseModalDetails: {release, tracks} | null`, `isReleaseModalLoading`, `releaseModalError`

**Open:**
```ts
setSelectedReleaseModal(release)
setReleaseModalDetails(null)
setIsReleaseModalLoading(true)
fetchJsonSafe(`/api/beatport/release/${release.id}`) → setReleaseModalDetails
```

**API `/api/beatport/release/[id]`:**
- Parallel `beatportFetch(/releases/{id}/)` + `fetchAllReleaseTracks(id)` (pagination up to 100 pages, jitter 120ms)
- Returns `{release, tracks, counts}`

**Modal UI (z-70, black/80 backdrop-blur):**
- Container `max-w-4xl`, `max-h 90vh`, `rounded 24px`, `bg-zinc-900`, `border zinc-800`, flex col
- **Header:** cover 80-96px rounded 2xl, title 2xl, artists/remixers, participation badges (Author #1 + count/total + %), duration, close ✕, border-b
- **Content scroll:** grid 3 cols info cards (label, catalog, dates, track_count, BPM range, price/UPC, exclusive/explicit/hype, duration, links)
  - Links: Beatport ↗ orange button, API URL ↗ gray
- **Artists list:** chips, your highlighted orange
- **Remixers list**
- **Full tracklist from Beatport API:** table #/Name/Artists/Remixers/BPM/Key/Length/▶ preview (click sets playingTrack), sticky header, max-h 300px scroll, your tracks highlighted orange
- **Artist tracks in release (from discography):** list with ▶, role badge, BPM/key/length
- **Footer:** ID/track_count/duration + Beatport open + Close

**Close:** ✕ button, backdrop click? (currently only ✕ and Esc), Esc handled in global keydown effect: `setSelectedReleaseModal(null)`

**Преимущества:** пользователь видит полную инфо без ухода с сайта, может послушать все треки релиза, понимает почему релиз исключён по 50% (participation rate).

---

## 17. Обработка ошибок и HTML-детекция

**Проблема:** Beatport иногда возвращает HTML (Cloudflare challenge, 429 page, 500 error page) вместо JSON → `JSON.parse("<!doctype...")` throws `Unexpected token '<', "<!doctype"... is not valid JSON`

**Решение `safeJsonParse(res)` + `fetchJsonSafe()`:**
```ts
const text = await res.text()
const trimmed = text.trim()
if (trimmed.startsWith("<!") || trimmed.startsWith("<html")) {
  const preview = trimmed.slice(0,500).replace(/\s+/g," ")
  throw new Error(`Beatport returned HTML instead of JSON (status ${res.status}). Preview: ${preview}`)
}
try { return JSON.parse(text) } catch (e) { throw new Error(`Failed to parse JSON... Body: ${text.slice(0,500)}`) }
```

**Frontend `fetchJsonSafe()`:**
- Checks `content-type` includes `application/json`, if not and HTML → throws `Сервер вернул HTML вместо JSON (статус 429). Попробуйте перевыпустить токен.`
- If `!res.ok` → throws `json.error || Ошибка status: text.slice(0,400)`
- If parse fails and HTML → `Ошибка: сервер вернул HTML (<!doctype ...). Статус 429. Проверьте конфигурацию и токен.`

**Пользовательские сообщения:**
- В searchError, error, tokenError, configError показывается понятный текст на русском
- В дискографии при ошибке HTML: «Сервер вернул HTML вместо JSON. Проверьте конфигурацию и попробуйте перевыпустить токен.» + кнопка «Перевыпустить токен и повторить»
- В токен панели: `tokenError` красный бокс

**Retry:** в `beatportFetch()` retry 3 попытки с exponential backoff + jitter, 401 → refresh, 429/5xx → delay, HTML → retry once.

---

## 18. Производительность и оптимизации

- **In-memory LRU Cache:** 5/10/15 мин TTL, maxSize 100-200, evict oldest, stats в config endpoint
- **Jitter:** `sleep(120+random*80)` между страницами пагинации, чтобы не триггерить rate limit
- **Exponential backoff:** `1000*2^attempt + random*500`, max 8s
- **Debounce:** `useDebounce(query, 400ms)` — предотвращает спам поиска
- **Memoization:** `useMemo` для `releaseDurations`, `stats`, `overallDurations`, `filteredTracks`, `paginatedTracks`, `sortedReleases`, `uniqueGenres`
- **Pagination:** 50 треков на страницу, вместо рендера 839 строк сразу
- **Lazy loading:** `loading="lazy"` на изображениях релизов и артистов
- **Dynamic import:** `exceljs` импортируется только при клике XLSX, не увеличивает начальный бандл (хотя всё равно 24 сек build из-за Turbopack + exceljs)
- **Shareable URL:** `history.replaceState` вместо Next router, чтобы не триггерить ререндер
- **LocalStorage:** recent searches (max 8), threshold, auto_refresh — персист без сервера
- **Frozen pane & AutoFilter:** в XLSX для удобства анализа
- **Build:** Turbopack, 9-10 сек build, `maxDuration=60` для discography route

**Будущие оптимизации:**
- Виртуализация треков (react-window) вместо пагинации
- Web Workers для парсинга длительности
- Service Worker + PWA offline cache
- Server-side pagination для discography (возвращать chunked + progress via SSE)

---

## 19. Безопасность

- **Пароль:** хранится в файле с `mode 0o600`, не логируется, не возвращается в API (только masked `username_masked: mu***`, `client_id_masked: 0GIvkClt...88Dd`, `access_preview: eyJ...`, `refresh_preview`)
- **Client ID:** публичный, встроен в JS бандл docs, не секрет (warning в доках)
- **Token:** `access_token` (10 мин) + `refresh_token` rotation (старый инвалидируется после каждого refresh) — сохраняется в `/tmp/bp_tokens.json`, не в localStorage
- **Config file:** `~/.beatport/config.json` + `/tmp/bp_user_config.json`, не коммитится (добавить в .gitignore)
- **CORS:** API routes same-origin, Beatport calls server-side only, секреты не попадают в браузер bundle
- **HTML injection:** `safeJsonParse` не рендерит HTML, только показывает preview 500 chars
- **XSS:** все значения из API экранируются через React (не dangerouslySetInnerHTML), CSV/XLSX экранирует `"` → `""`
- **Env vars:** `BEATPORT_*` читаются server-side only (`process.env`), client-side только `NEXT_PUBLIC_*`

---

## 20. Деплой, окружение, кросс-платформенность

**Env vars:**

```bash
DATABASE_URL=postgresql://... (optional)
BEATPORT_CLIENT_ID=0GIvkClt...
BEATPORT_USERNAME=...
BEATPORT_PASSWORD=...
BP_CONFIG_PATH=/tmp/bp_user_config.json
BP_TOKEN_PATH=/tmp/bp_tokens.json
BP_TOKEN_DIR=/tmp
PORT=5555
```

**Кросс-платформенные пути (из ТЗ):**

- Windows: `C:/Users/<username>/tmp/` или `%USERPROFILE%/.beatport/config.json`
- Linux/macOS: `~/.beatport/config.json` или `/tmp/`
- Переопределение через `BP_CONFIG_PATH`, `BP_TOKEN_PATH`, `BP_TOKEN_DIR`
- Реализация: `getUserConfigPath()` → `process.env.BP_CONFIG_PATH || BEATPORT_CONFIG_PATH || "/tmp/bp_user_config.json"`, fallback `os.homedir() + "/.beatport/config.json"`

**Запуск:**

```bash
npm install
npm run build
npm start # 3000
# или
next start -p 5555
```

**Docker (roadmap):**
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm","start"]
```

**Health:** `GET /api/health` → `{ok, timestamp, service, version, token:{hasToken,isExpired,remaining}, config:{configured,source,hasUserFile}}` — для K8s liveness/readiness

---

## 21. Структура проекта

```
.
├── .env / .env.example
├── README.md
├── docs/
│   └── TECHNICAL_DOCUMENTATION.md (этот файл)
├── src/
│   ├── lib/
│   │   ├── beatport.ts      # OAuth2, token, config, cache, fetch, getFullDiscography
│   │   ├── cache.ts         # SimpleCache LRU TTL
│   │   └── xlsxExport.ts    # XLSX MAX 7 листов, exceljs, auto-width, colors
│   ├── db/
│   │   ├── index.ts         # Drizzle db client (pg, DATABASE_URL)
│   │   └── schema.ts        # empty (no DB needed for Beatport)
│   └── app/
│       ├── layout.tsx       # RootLayout, metadata
│       ├── globals.css      # Tailwind + custom scrollbar + progress animation
│       ├── page.tsx         # Client monolith: search, token, config, stats, tabs, modal, player
│       └── api/
│           ├── health/route.ts
│           └── beatport/
│               ├── search/route.ts
│               ├── artist/[id]/route.ts
│               ├── artist/[id]/discography/route.ts
│               ├── release/[id]/route.ts (new)
│               ├── token/route.ts
│               └── config/route.ts
├── package.json
├── next.config.ts
├── tsconfig.json
└── .next/ (build artifacts)
```

---

## 22. Сценарии использования (User Flows)

### Поиск артиста

1. User открывает `https://3000-...e2b.app/?q=Delerium` (или без params)
2. Вводит в input «Delerium», debounce 400ms, URL обновляется `?q=Delerium`, recent searches пополняется
3. Жмёт Enter или «Найти» → `isSearching true`, thin progress bar top, `fetchJsonSafe(/api/beatport/search?q=)`
4. Backend: check cache `search:Delerium:15`, if miss → `getValidToken()` (login if needed) → `beatportFetch(/search/?q=&type=artist)` → cache 5 min → return `{artists}`
5. Если 0 → «Не найдено», если 1 → auto select → discography, если >1 → grid artist cards с фото lazy

### Загрузка дискографии

1. User выбирает артиста 10426 → `selectedArtist`, `isLoadingDiscography true`, spinner «10–30 сек»
2. Frontend parallel `fetch /artist/10426` + `/discography` (shareable URL `?artist=10426`)
3. Backend discography: check cache `disco:10426`, if miss → parallel `fetchAllTracks` (pages 1..9, 100 per page, jitter) + `fetchAllReleases` (3 pages) → sort desc → cache 15 min → return 839+223
4. Frontend: `releaseDurations` Map, `stats` filter ≥threshold, `overallDurations`, `uniqueGenres`
5. Render profile header + tabs + threshold slider

### Просмотр релиза (модалка)

1. Click release card → `openReleaseModal(release)` → `selectedReleaseModal`, loading spinner
2. Fetch `/api/beatport/release/{id}` → release details + full tracklist 6 tracks
3. Modal shows 9 info cards + artists/remixers chips + full tracklist table (Beatport API) + artist tracks in release (discography) + links + play preview
4. Click ▶ in modal → `setPlayingTrack(t)` → bottom player plays `sample_url` LOFI mp3
5. Close: ✕, Esc, backdrop

### Токен-менеджмент

1. On mount `fetchTokenStatus` every 3s → countdown remaining
2. If remaining <=60 and autoRefresh → `POST /token {mode:refresh}` → new token
3. Manual refresh buttons: refresh (refresh_token) / full login (clear token file + full login)
4. Config: ⚙️ Настроить → modal form → POST /config → save file + clear caches + `forceRefreshToken("full")` → tokenStatus update

### Экспорт

1. CSV: `exportTracksToCSV(filteredTracks, artistName)` → headers + escaped rows → Blob → download
2. XLSX MAX: click `📊 XLSX MAX` → `setIsExportingXlsx true` → dynamic import `exceljs` → `exportDiscographyToXLSX(...)` → 7 sheets with colors, auto width → Blob → download `Artist_Beatport_2026-07-15_839tracks_223releases.xlsx`

---

## 23. Контрольные точки на примере Delerium (ID 10426)

| Метрика | Ожидаемо (ТЗ) | Факт (с 50% правилом) | Примечание |
|---|---|---|---|
| Releases (автор #1) | ~111 | 105 (фильтровано) / 113 сырых | 8 исключено по 50% (compilations 1/40) |
| All Releases | ~223 | 223 |  |
| Tracks | ~839 | 839 |  |
| Треков как артист | ~838 | 838? (из 839, 1 как ремиксер) |  |
| Треков как ремиксер | ~1 | 1 (Moonshadow Delerium Remix) |  |
| Release «Silence (feat. Sarah McLachlan)» | в Releases | Да, если ≥50% |  |
| Release «Remixes» (Magic Wands) | только в All Releases | Да, Delerium ремиксер, участие 1/6=16% <50% → excluded из Releases, в All с badge Участник? Actually remixers+author? Но по нашему правилу author#1 filter, Magic Wands author, Delerium remixer → не в raw author#1, только в All |  |
| Трек «Moonshadow (Delerium Remix)» | в Tracks с бейджем Ремиксер | Да |  |
| Трек «Silence feat. Sarah McLachlan (Acoustic Mix)» | в Tracks Автор #1 | Да |  |

**Проверка релиза 6963239 Remixes:**
- `track_count=6`, наш `count=1` (только Moonshadow Delerium Remix) для Delerium? Actually для Delerium в этом релизе 1 трек, rate 16% <50%, но т.к. artists[0] != Delerium (Magic Wands), он и так не в Releases raw, только в All → корректно.

**Проверка правила 50% на Chillout Friends:**
- `track_count=16`, `count=4/16=25%` → excluded из Releases, хотя artists[0]==Delerium (возможно, компиляция где Delerium первый по алфавиту) → теперь только в All Releases с оранжевым бейджем `<50%`

---

## 24. Roadmap и возможные улучшения

**Сделано из аудита:**
- ✅ Кэш LRU TTL
- ✅ Debounce + shareable URL + recent searches
- ✅ Пагинация треков 50 + сортировка + фильтры жанр/BPM
- ✅ Экспорт CSV + XLSX MAX (7 листов, 50+ колонок, цвета, автоширина)
- ✅ Модалка релиза с полной инфо + треклист
- ✅ Threshold slider configurable
- ✅ Lazy images, keyboard shortcuts, copy ID
- ✅ README + .env.example + health расширенный
- ✅ HTML detection fix

**Ещё можно:**
- [ ] Разбить `page.tsx` на компоненты: `components/TokenPanel.tsx`, `ConfigModal.tsx`, `SearchBar.tsx`, `ArtistProfile.tsx`, `StatsGrid.tsx`, `ReleaseCard.tsx`, `TracksTable.tsx`, `ReleaseModal.tsx`, `AudioPlayer.tsx`, `ThresholdSlider.tsx`
- [ ] Виртуализация треков `react-window` или `@tanstack/react-virtual`
- [ ] Фильтр по лейблу, году, ключу (Camelot), BPM range slider
- [ ] PWA + offline cache (Workbox)
- [ ] Dockerfile + `start.bat`/`start.sh` как в ТЗ Python версии (для Next.js — `docker-compose`, `npm start`)
- [ ] E2E тесты Playwright: поиск Delerium → дискография → модалка → экспорт
- [ ] Unit тесты для `getParticipation()`, `formatDurationMs()`, `calcAutoWidth()`
- [ ] GraphQL? Не нужно, REST достаточно
- [ ] WebSocket прогресс загрузки дискографии (SSE) вместо spinner
- [ ] Multi-user: поддержка нескольких аккаунтов Beatport одновременно (выбор активного)
- [ ] Шифрование пароля в файле (например, с помощью `keytar` или `crypto` с машинным ключом)
- [ ] Аналитика: сколько релизов, средняя длительность, BPM distribution chart (Chart.js)
- [ ] Waveform для превью (wavesurfer.js)

---

## 25. Troubleshooting

| Проблема | Причина | Решение |
|---|---|---|
| `Unexpected token '<', "<!doctype"...` | Beatport вернул HTML (Cloudflare, 429, 500) вместо JSON | Наш `safeJsonParse` теперь ловит и показывает «Сервер вернул HTML... Попробуйте перевыпустить токен». Кнопка «Перевыпустить token» + auto-refresh |
| `Beatport login failed 401` | Неверные username/password или истёк sessionid | Проверьте конфиг в ⚙️ Настроить, сделайте Full login |
| `Failed to obtain authorization code` | Неверный client_id или Cloudflare | Проверьте Client ID из `https://api.beatport.com/v4/docs/` JS bundle, должно быть `0GIvkClt...`. Попробуйте другой браузер/IP |
| `Token exchange returned HTML` | Code уже использован (одноразовый) или истёк | Подождите 1 сек и нажмите Full login, code одноразовый |
| `Refresh failed: ... refresh_token истёк` | Refresh token rotation — старый инвалидируется после каждого refresh | Нажмите Full login, он очистит токен файл и сделает полный цикл |
| Дискография грузится 10-30 сек | Пагинация 839 треков + 223 релизов, 120ms jitter между страницами + backoff | Нормально, кэшируется 15 мин, следующий раз мгновенно. Можно уменьшить per_page=100 уже макс |
| Экспорт XLSX долго / 24 сек build | exceljs тяжёлый (96 packages) | Нормально, build 24 сек из-за Turbopack + exceljs. В runtime экспорт 1-2 сек для 839 треков. Можно заменить на `xlsx` lighter, но потеряем стили |
| `/tmp/bp_user_config.json` пропадает после ребута | /tmp очищается | Дубль сохраняется в `~/.beatport/config.json`, который персистентен. При загрузке пробуем оба пути |
| Поиск не находит артиста | Beatport search type=artist, возможно артист называется иначе | Попробуйте часть имени, проверьте на beatport.com |
| Релиз считается собственным хотя это компиляция | Было до правила 50% | Теперь фильтр ≥50% исключает компиляции 1/40. Настройте threshold slider выше/ниже |

**Логи:**
- Browser console: `localStorage.getItem("bp_recent")`, `bp_threshold`, `bp_auto_refresh`
- Server: `cat /tmp/bp_tokens.json`, `cat /tmp/bp_user_config.json`, `cat ~/.beatport/config.json`
- API: `curl /api/health`, `curl /api/beatport/config`, `curl /api/beatport/token`

---

## Приложение: примеры curl

```bash
# Health
curl https://3000-...e2b.app/api/health | jq

# Token status
curl https://3000-...e2b.app/api/beatport/token | jq

# Config
curl https://3000-...e2b.app/api/beatport/config | jq

# Save config (universal first launch)
curl -X POST https://3000-...e2b.app/api/beatport/config -H "Content-Type: application/json" -d '{"client_id":"0GIvkCltVIuPkkwSJHp6NDb3s0potTjLBQr388Dd","username":"your_login","password":"your_pass"}' | jq

# Search
curl "https://3000-...e2b.app/api/beatport/search?q=Delerium" | jq ".artists[0]"

# Discography (839 tracks, 223 releases)
curl "https://3000-...e2b.app/api/beatport/artist/10426/discography" -o disco.json
jq ".counts" disco.json

# Release details + tracklist
curl "https://3000-...e2b.app/api/beatport/release/6963239" | jq ".release.name, .tracks|length"
```

---

## Заключение

Проект реализует полный цикл от OAuth2 до UI с аудитом, кэшем, универсальной конфигурацией, правилом 50%, длительностью, модалкой релиза, экспортом XLSX MAX (7 листов, 50+ колонок, цвета, автоширина). Архитектура устойчива к HTML-ответам Cloudflare, rate limits, token rotation. Frontend — dark theme, orange accent #FF6B00, системный шрифт, адаптив, keyboard shortcuts, shareable URL, recent searches, пагинация, сортировка, фильтры.

Дальнейшее развитие — разбиение монолита, виртуализация, PWA, Docker, тесты.

---
*Документация сгенерирована автоматически на основе аудита кода.*
