From bb156fc6bdafd95b4a6d4acf605eb97f2eb71a8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 17:57:11 +0000 Subject: [PATCH] Fork to Spotify + yt-dlp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Меняется источник данных, вся обвязка (карточка, кэш-канал, диалоги, инлайн, альбомы, эффекты) остаётся. Новое: - src/music/ — Spotify Web API клиент (поиск/треки/альбомы/плейлисты/лайки/ currently-playing/recently-played), заменяет src/yandex/. - src/audio/ytdlp.ts — резолвер аудио через yt-dlp, матчинг по ISRC/длительности, YouTube + SoundCloud фолбэком. - services/auth.ts — OAuth Authorization Code + PKCE. Гибрид: WEBHOOK_HOST задан → callback на /spotify/callback; иначе paste-код в чат. - services/nowPlayingWatcher.ts — polling /me/player/currently-playing вместо ws-Ynison. - HttpClient.downloadAudio поддерживает file:// URL — cache-пайплайн без изменений читает локальные файлы yt-dlp. Удалено: - @dvxch/yandex-music из dependencies. - lossless-путь (Spotify не отдаёт FLAC, yt-dlp тем более). Схема БД: track_id/album_id теперь TEXT (Spotify id — 22 base62-символа, не bigint). Deep-link'и и URL-парсеры перенастроены под open.spotify.com/spotify:. .env.example, README, install.sh, package.json, docker-compose.yml, docs/sql, ecosystem обновлены на nowspot / SPOTIFY_CLIENT_ID. Тесты: удалены yandex-специфичные (ynison, DeviceAuthError, wave-color, lossless), остальные обновлены под string id / Spotify URL. 125/130 проходят локально; 5 упавших — TaggingService, требуют ffmpeg в окружении (нет регрессии кода). Co-Authored-By: Claude Opus 4.7 Claude-Session: https://claude.ai/code/session_01QbH2hSXcggbR2pHy4mwasR --- .env.example | 56 ++- README.md | 365 ++++----------- docker-compose.yml | 4 +- docs/sql/docker-init.sh | 10 +- docs/sql/init.sql | 10 +- ecosystem.config.cjs | 4 +- install.sh | 29 +- package-lock.json | 56 +-- package.json | 7 +- scripts/backup.sh | 12 +- src/audio/ytdlp.ts | 283 ++++++++++++ src/bot/captions.ts | 2 +- src/bot/cardBuilder.ts | 21 +- src/bot/container.ts | 38 +- src/bot/dialogs.ts | 7 +- src/bot/handlers/admin.ts | 6 +- src/bot/handlers/albums.ts | 26 +- src/bot/handlers/common.ts | 24 +- src/bot/handlers/inline.ts | 145 +++--- src/bot/handlers/inlineCard.ts | 18 +- src/bot/handlers/playlists.ts | 11 +- src/bot/markup.ts | 2 +- src/infra/http.ts | 16 +- src/infra/parsers.ts | 36 +- src/main.ts | 59 ++- src/music/client.ts | 711 +++++++++++++++++++++++++++++ src/{yandex => music}/lrc.ts | 8 +- src/music/media.ts | 34 ++ src/{yandex => music}/metadata.ts | 194 +++----- src/music/types.ts | 157 +++++++ src/music/urls.ts | 7 + src/music/watcher.ts | 31 ++ src/services/albums.ts | 103 ++--- src/services/auth.ts | 328 +++++++++----- src/services/cache.ts | 6 +- src/services/card.ts | 2 +- src/services/lyricsVideo.ts | 18 +- src/services/nowPlayingWatcher.ts | 359 +++++++-------- src/services/search.ts | 74 ++- src/services/stale.ts | 18 +- src/settings.ts | 21 +- src/storage/cache.ts | 18 +- src/storage/pool.ts | 2 +- src/storage/settings.ts | 2 +- src/storage/types.ts | 6 +- src/tagging/service.ts | 2 +- src/yandex/client.ts | 724 ------------------------------ src/yandex/media.ts | 34 -- src/yandex/types.ts | 148 ------ src/yandex/urls.ts | 6 - src/yandex/ynison.ts | 116 ----- test/albums.test.ts | 8 +- test/auth.test.ts | 193 -------- test/client.test.ts | 67 --- test/downloadQuality.test.ts | 43 -- test/ensureFileId.test.ts | 16 +- test/inline.test.ts | 8 +- test/inlineCard.test.ts | 21 +- test/lrc.test.ts | 2 +- test/parsers.test.ts | 50 ++- test/playlists.test.ts | 37 +- test/searchPaging.test.ts | 32 +- test/settings.test.ts | 2 +- test/tagging.test.ts | 9 +- test/yandexMetadata.test.ts | 145 ------ test/ynison.test.ts | 192 -------- 66 files changed, 2307 insertions(+), 2894 deletions(-) create mode 100644 src/audio/ytdlp.ts create mode 100644 src/music/client.ts rename src/{yandex => music}/lrc.ts (63%) create mode 100644 src/music/media.ts rename src/{yandex => music}/metadata.ts (55%) create mode 100644 src/music/types.ts create mode 100644 src/music/urls.ts create mode 100644 src/music/watcher.ts delete mode 100644 src/yandex/client.ts delete mode 100644 src/yandex/media.ts delete mode 100644 src/yandex/types.ts delete mode 100644 src/yandex/urls.ts delete mode 100644 src/yandex/ynison.ts delete mode 100644 test/auth.test.ts delete mode 100644 test/client.test.ts delete mode 100644 test/downloadQuality.test.ts delete mode 100644 test/yandexMetadata.test.ts delete mode 100644 test/ynison.test.ts diff --git a/.env.example b/.env.example index fb5bb3c..f2b9239 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,5 @@ # ============================================================ -# Шаблон переменных окружения для nowym. +# Шаблон переменных окружения для nowspot. # Скопируй в .env и заполни значения. НЕ коммить настоящий .env. # ============================================================ @@ -14,24 +14,43 @@ TELEGRAM_CHANNEL_ID= # в личку). Узнать: напиши боту @userinfobot. ADMIN_USER_ID= +# ---- Spotify ------------------------------------------------ +# Client ID приложения Spotify. Создать на developer.spotify.com/dashboard → +# Create app. Redirect URI зависит от того, как ты подключаешь бота: +# • webhook-режим (задан WEBHOOK_HOST ниже): используй +# https://<твой-домен>/spotify/callback — бот сам ловит callback. +# • long-polling / paste-режим: используй http://127.0.0.1:8888/callback, +# юзер после авторизации пришлёт боту значение параметра code= из +# адресной строки. Локальный сервер поднимать не нужно — Spotify просто +# редиректит браузер, ошибка соединения ожидаема, важно взять `code=`. +# Client Secret Spotify для PKCE не нужен (используется code_challenge/verifier). +SPOTIFY_CLIENT_ID= + +# Интервал polling'а /me/player/currently-playing в мс. Меньше = быстрее +# обновляется карточка «сейчас играет», больше нагрузка на Spotify API. +# SPOTIFY_NP_POLL_MS=3000 +# Интервал между произвольными запросами к API (сек) и число ретраев на 429. +# SPOTIFY_RATE_LIMIT_INTERVAL=0.2 +# SPOTIFY_MAX_RETRIES=3 + # ---- Postgres ---------------------------------------------- # Две изолированные БД: users (токены, encrypted) и cache (file_id треков). # install.sh накатывает роли и прописывает эти DSN сам. Руками — создание # см. в README, формат: -# POSTGRES_USERS_DSN=postgresql://nowym_users_app:PASSWORD@127.0.0.1:5432/nowym_users -# POSTGRES_CACHE_DSN=postgresql://nowym_cache_app:PASSWORD@127.0.0.1:5432/nowym_cache +# POSTGRES_USERS_DSN=postgresql://nowspot_users_app:PASSWORD@127.0.0.1:5432/nowspot_users +# POSTGRES_CACHE_DSN=postgresql://nowspot_cache_app:PASSWORD@127.0.0.1:5432/nowspot_cache POSTGRES_USERS_DSN= POSTGRES_CACHE_DSN= DB_POOL_MIN=1 DB_POOL_MAX=5 # Только для docker-compose: пароли, которыми docs/sql/docker-init.sh создаёт -# роли nowym_users_app/nowym_cache_app при первом старте postgres-контейнера. +# роли nowspot_users_app/nowspot_cache_app при первом старте postgres-контейнера. # Должны совпадать с паролями внутри POSTGRES_USERS_DSN/POSTGRES_CACHE_DSN # выше (там host будет `postgres`, не 127.0.0.1). При bare-metal через # install.sh — не нужны, не трогай. -NOWYM_USERS_PASSWORD= -NOWYM_CACHE_PASSWORD= +NOWSPOT_USERS_PASSWORD= +NOWSPOT_CACHE_PASSWORD= # ---- Redis --------------------------------------------------- # Пусто = ioredis-дефолт (127.0.0.1:6379) — то, что нужно для install.sh. @@ -63,18 +82,8 @@ MAX_SEARCH_RESULTS=5 HTTP_CONNECTION_LIMIT=100 HTTP_LIMIT_PER_HOST=30 -# ---- Яндекс.Музыка ----------------------------------------- -# Интервал между запросами к API (секунды). Меньше — агрессивнее, выше шанс 429. -YANDEX_RATE_LIMIT_INTERVAL=1.0 -YANDEX_MAX_RETRIES=3 - -# OAuth Device Flow. Дефолты те же, что использует библиотека yandex-music — -# переопредели только если Яндекс отзовёт ключ или нужны свои. -# YANDEX_OAUTH_CLIENT_ID=23cabbbdc6cd418abb4b39c32c41195d -# YANDEX_OAUTH_CLIENT_SECRET=53bc75238f0c4d08a118e51fe9203300 - # ---- Параллелизм скачивания ------------------------------- -# Сколько треков качается параллельно суммарно +# Сколько треков (yt-dlp) качается параллельно суммарно DOWNLOAD_QUEUE_CONCURRENCY=6 # Сколько треков альбома готовятся параллельно для одного пользователя ALBUM_PARALLEL_TRACKS=4 @@ -99,8 +108,10 @@ BOT_API_BASE_URL= # ---- Webhook (нужен ТОЛЬКО если не задан BOT_API_BASE_URL выше) -------- # Публичный URL домена, на который Telegram шлёт апдейты. Пример: # WEBHOOK_HOST=https://your-domain.example +# Если задан — Spotify OAuth-callback ждём тут же (/spotify/callback), без +# paste-кода в чат. WEBHOOK_HOST= -WEBHOOK_PATH=/webhook/nowym +WEBHOOK_PATH=/webhook/nowspot # На каком интерфейсе/порту слушает встроенный http(s)-сервер. Telegram # разрешает вебхуки на портах 443/80/88/8443. WEBAPP_HOST=0.0.0.0 @@ -117,9 +128,8 @@ WEBHOOK_SSL_KEY= WEBHOOK_SECRET= # ---- Лирика -------------------------------------------------- -# Ключ Genius (genius.com/api-clients) — точнее находит текст по фразе, -# чем фолбэк на Yandex. Пусто — лирика всё равно работает, просто только -# через Yandex (покрытие андеграунда/рэпа заметно хуже). +# Ключ Genius (genius.com/api-clients) — единственный источник лирики +# (Spotify API текста песен не отдаёт). Пусто = лирики не будет вовсе. GENIUS_TOKEN= # ---- Now-playing endpoint ---------------------------------- @@ -127,8 +137,8 @@ GENIUS_TOKEN= # install.sh генерит сам. Руками: # node -e 'console.log(require("crypto").randomBytes(32).toString("base64url"))' NOW_PLAYING_TOKEN= -# user_id владельца, под чьим Yandex-токеном держим ws с Ynison. -# По умолчанию = ADMIN_USER_ID. Переопредели если хочешь другого юзера. +# user_id владельца, под чьим Spotify-токеном идёт polling +# /me/player/currently-playing. По умолчанию = ADMIN_USER_ID. # OWNER_ID= # ---- Иконки кнопок ------------------------------------------ diff --git a/README.md b/README.md index 76b2de7..b7fe98c 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,86 @@

- nowym — yandex music × telegram, gramio edition + nowspot — spotify × telegram, форк nowym

node typescript gramio - stable license - private

-Бот для Яндекс Музыки в Telegram. Инлайн-поиск, карточка «сейчас играет», -выгрузка альбомов, лирика, свой кэш-канал вместо повторных закачек. На -[GramIO](https://gramio.dev/) 0.12. - -## Ништяки - -- 🔎 инлайн-поиск треков — `@bot название`, ссылка на трек/альбом, «недавнее» - (история прослушиваний, с пагинацией и бесконечным скроллом), или вообще - пусто (тогда отдаёт текущий трек). Опечатки подправляет Yandex suggest; -- 🖼 карточка «сейчас играет» — ambient-рендер на `@napi-rs/canvas`, рендер - уходит в пул воркеров, чтобы не блокировать event loop; -- ⚡ аудио-эффекты прямо в инлайне — `@bot ncore название` / - `@bot speed название` / `@bot slow название` (nightcore / ускорение / - замедление+реверб), реальный DSP через ffmpeg, результат кэшируется - отдельно от оригинала; -- ❤️ лайк/анлайк трека кнопкой под треком; -- 🎚 выбор качества звука — `best` / `economy` / `lossless` (для lossless — - расшифровка CDN-ссылок на лету); -- 📦 выгрузка альбома пачками (`sendMediaGroup`) — с прогрессом, отменой, - повтором упавших треков, возобновлением после рестарта бота, без OOM даже - на здоровенных аудиокнигах; -- 💾 свой кэш-канал в Telegram вместо повторных закачек с Яндекса — - пустышка → реальная заливка, stale-refresh по расписанию; -- 📝 лирика строкой-цитатой — Genius (по фразе, потом по автору/треку), - фолбэк на Yandex, если не нашлось; -- 🔐 device-flow логин, токены шифруются at-rest (Fernet), ws-наблюдатель - за плеером владельца (Ynison); -- `/start`, `/help`, `/np`, `/settings`, `/login`, `/logout`, админские - `/health`, `/broadcast` (с подтверждением и остановкой на лету). +Форк [nowym](https://github.com/pmdw/nowym) под Spotify. Всё, что можно, — +через официальный Spotify Web API (метаданные, поиск, лайки, «сейчас +играет»), аудио — через `yt-dlp` (YouTube + SoundCloud фолбэком, матчинг по +ISRC + длительности). Карточка «сейчас играет», инлайн-поиск, кэш-канал, +выгрузка альбомов, аудио-эффекты — как в оригинале. + +## Что работает / что нет + +**Работает:** +- 🔎 инлайн-поиск (`@bot название`, ссылка на трек/альбом/плейлист Spotify, + «недавнее» из `/me/player/recently-played`, пусто → текущий трек); +- 🖼 карточка «сейчас играет» — рендер тот же, ambient-cover в фон, прогресс, + 16:9 / 9:16 (см. `/settings`); +- ⚡ аудио-эффекты `ncore` / `speed` / `slow` (nightcore / ускорение / + замедление+реверб) — через ffmpeg поверх скачанного трека; +- ❤️ лайк/анлайк через `PUT/DELETE /me/tracks`; +- 📦 выгрузка альбомов/плейлистов пачками (10 треков в `sendMediaGroup`), + с прогрессом, отменой, повтором упавших, возобновлением после рестарта; +- 💾 кэш-канал в Telegram: один раз скачал → лежит там → все следующие + вызовы того же трека берут `file_id` без похода в yt-dlp; +- 📝 лирика через Genius (Spotify API текста не отдаёт); +- 🔐 OAuth Authorization Code + PKCE, токены шифруются at-rest (Fernet). + +**Не работает по ограничениям Spotify:** +- lossless / выбор кодека выше того, что даёт yt-dlp (обычно opus 128–160 + kbps c YouTube Music / Topic-каналов); +- «зачесть прослушивание» в статистику Spotify через бота (нет API); +- любой контент только в Spotify (эксклюзивы, подкасты без YouTube-версии). ## ⚡ Быстрый старт -Порядок важен: два шага в BotFather — самая частая причина «бот молчит», -без них бот заведётся и будет выглядеть рабочим, но инлайн не заработает. - 1. **Бот в [@BotFather](https://t.me/BotFather):** `/newbot` → токен; `/setinline` → любая заглушка подсказки (например `ищу трек...`) — - **обязательно**, без этого инлайн (`@bot запрос`) не работает вообще; - `/setinlinefeedback` → выбери бота → `100%` — **обязательно**, иначе - Telegram присылает `chosen_inline_result` (сигнал «юзер выбрал трек, - качай») только для доли запросов, и большинство выборов зависают на - плейсхолдере навсегда. -2. **Канал-кэш:** создай приватный канал, добавь бота админом (права на - публикацию и удаление сообщений). Узнать numeric id: перешли любое - сообщение ИЗ канала боту [@userinfobot](https://t.me/userinfobot) — - формат `-100xxxxxxxxxx`. Свой telegram id (для `ADMIN_USER_ID`) там же. -3. **Клонируй и поставь:** + **обязательно**; `/setinlinefeedback` → выбери бота → `100%` — + **обязательно**, иначе Telegram шлёт `chosen_inline_result` только для + доли запросов и большинство выборов зависают на плейсхолдере. +2. **Spotify-приложение:** [developer.spotify.com/dashboard](https://developer.spotify.com/dashboard) → + Create app. Redirect URI: + - **webhook-режим** (есть домен, `WEBHOOK_HOST`): `https://<домен>/spotify/callback` + - **long-polling / без домена**: `http://127.0.0.1:8888/callback` — юзер + после авторизации скопирует `code=...` из адресной строки и пришлёт + боту. Локальный сервер поднимать не нужно, важно взять код. + Из dashboard'а скопируй **Client ID** в `SPOTIFY_CLIENT_ID` (secret не + нужен — используем PKCE). +3. **Канал-кэш:** создай приватный канал, добавь бота админом (публикация + + удаление сообщений). Numeric id узнать: перешли любое сообщение ИЗ канала + боту [@userinfobot](https://t.me/userinfobot) — формат `-100xxxxxxxxxx`. + Свой Telegram id (для `ADMIN_USER_ID`) там же. +4. **Клонируй и поставь:** ```bash - git clone nowym && cd nowym + git clone nowspot && cd nowspot ./install.sh ``` - Спросит токен/id канала/твой id (Enter — пропустить и дозаполнить в - `.env` вручную), сам сгенерит секреты, при постгресе под рукой — - накатит роли и пропишет DSN сам. -4. **Подключение к Telegram** — выбери один способ: - - **self-hosted Bot API сервер** (рекомендуется — без домена и TLS, - поднимается локально за пару минут): + Спросит токен/id канала/твой id/Spotify Client ID (Enter — пропустить и + дозаполнить в `.env` вручную), сам сгенерит секреты, при постгресе под + рукой — накатит роли и пропишет DSN сам. +5. **Подключение к Telegram** — выбери один способ: + - **self-hosted Bot API сервер** (рекомендуется — без домена и TLS): ```bash - # api_id/api_hash — на my.telegram.org → API development tools (создать приложение) + # api_id/api_hash — на my.telegram.org → API development tools docker run -d --name telegram-bot-api --restart always \ -e TELEGRAM_API_ID= -e TELEGRAM_API_HASH= \ -p 127.0.0.1:8081:8081 aiogram/telegram-bot-api:latest ``` - В `.env`: `BOT_API_BASE_URL=http://127.0.0.1:8081/bot` (именно с `/bot` - на конце — токен и метод бот доклеивает сам). - - **облачный webhook** — если уже есть домен с TLS-сертификатом (certbot - и т.п.): заполни `WEBHOOK_HOST`/`WEBHOOK_SSL_CERT`/`WEBHOOK_SSL_KEY` в - `.env`, `BOT_API_BASE_URL` оставь пустым. -5. **Запуск:** `npm run start` (или `pm2 start ecosystem.config.cjs` — - `install.sh` уже подогнал в нём пути под эту машину). - -Если что-то не так — бот скажет прямо на старте (например явно укажет, -какая переменная в `.env` не задана), падать молча со временем не должен. - -## Как выглядит карточка - -Ambient-рендер «сейчас играет» — обложка, размытая в фон, прогресс-бар, -мета-строка (альбом/тип/год/лейбл/номер трека). Два формата: `16:9` под -фото-сообщение, `9:16` под сторис/шортсы. - -

- карточка 16:9 -

-

- карточка 9:16 -

- -Есть и лирик-режим — вместо прогресс-бара лента строк текста вокруг активной -(Genius → Yandex fallback): - -

- карточка с лирикой 16:9 -

-

- карточка с лирикой 9:16 -

+ В `.env`: `BOT_API_BASE_URL=http://127.0.0.1:8081/bot`. + - **облачный webhook** — если уже есть домен с TLS (certbot и т.п.): + заполни `WEBHOOK_HOST`/`WEBHOOK_SSL_CERT`/`WEBHOOK_SSL_KEY` в `.env`, + `BOT_API_BASE_URL` оставь пустым. +6. **Запуск:** `npm run start` (или `pm2 start ecosystem.config.cjs`). ## Аудио-эффекты -Не настройка, а префикс инлайн-запроса — результат летит через ffmpeg и -кэшируется отдельно от оригинального трека: - ``` @bot ncore название трека — nightcore (ускорение + питч вверх) @bot speed название трека — ускорение без питч-шифта @@ -121,218 +89,79 @@ Ambient-рендер «сейчас играет» — обложка, разм ## Настройки (`/settings`) -Качество звука (`best`/`economy`/`lossless`), формат ответа (кнопка/текст/ -всё/ничего — отдельно для трека и для карточки), какие поля показывать на -карточке (альбом/тип/год/лейбл/номер трека/аватар), стиль прогресс-бара -(`wavy`/`bar`), соотношение сторон карточки (`16:9`/`9:16`). +Качество звука (`best` / `economy` — что берёт yt-dlp), формат ответа +(кнопка/текст/всё/ничего — отдельно для трека и карточки), какие поля +показывать на карточке, стиль прогресс-бара (`wavy`/`bar`), соотношение +сторон (`16:9`/`9:16`). ## Стек -[GramIO](https://gramio.dev/) 0.12 + `@gramio/dialogs` (меню/настройки) + -`@gramio/format` + `@gramio/storage-redis` + `@napi-rs/canvas` (рендер -карточки) + `undici`/`node-wreq`. +- [GramIO](https://gramio.dev/) 0.12 + `@gramio/dialogs` + `@gramio/format` + + `@gramio/storage-redis` + `@napi-rs/canvas` (рендер карточки) + + `undici` (HTTP пул) + `ws` (не используется, но осталось от Ynison + и не мешает — уберётся в следующем чистом релизе); +- собственный клиент Spotify Web API (`src/music/`); +- `yt-dlp` как внешний процесс для аудио (`src/audio/ytdlp.ts`); +- Genius для лирики. ### Системные зависимости -Кроме Node.js нужны на машине (не npm-пакеты): - -- **PostgreSQL** — две базы (users/cache), см. ниже; +- **Node.js ≥ 22** +- **PostgreSQL** — две БД (users/cache), схема создаётся автоматически; - **Redis** — для `@gramio/dialogs`; -- **ffmpeg** — только для аудио-эффектов (`ncore`/`speed`/`slow`, см. ниже); - без него всё остальное работает, просто эта фича упадёт с понятной ошибкой; -- **Docker** — опционально. Либо только для self-hosted Bot API сервера - (см. «Быстрый старт» — без домена/TLS через него проще всего), либо для - всей установки целиком через `docker-compose.yml` (см. «Через Docker» - ниже) — тогда Postgres/Redis тоже не нужно ставить на хост руками. +- **yt-dlp** — обязательно, иначе скачивание не работает вообще + (`sudo apt install yt-dlp` или через pip); +- **ffmpeg** — для тегирования и аудио-эффектов; +- **Docker** — опционально (для self-hosted Bot API сервера или через + `docker-compose.yml`). Debian/Ubuntu: - ```bash sudo apt install postgresql redis-server ffmpeg +sudo apt install yt-dlp # или: pipx install yt-dlp ``` -## Как поставить - -`./install.sh` из «Быстрого старта» выше делает всё это за один проход: -проверит node, поставит зависимости, подготовит `.env` (секреты сгенерит -сам; спросит токен/id канала/твой id и впишет их же), при доступном -постгресе — накатит роли и пропишет `POSTGRES_*_DSN` сам (пароли -сгенерены, руками синхронизировать не с чем), проверит redis/ffmpeg, -подгонит `ecosystem.config.cjs` под текущую машину, прогонит typecheck -как smoke-тест. - -Руками — то же самое по шагам: - -```bash -npm install -cp .env.example .env -``` - -### Через Docker - -Альтернатива `install.sh` — `docker-compose.yml` поднимает бота вместе с -Postgres и Redis, без ручной установки чего-либо на хост-машину кроме -Docker: - -```bash -cp .env.example .env -# заполни .env: TELEGRAM_BOT_TOKEN/TELEGRAM_CHANNEL_ID/ADMIN_USER_ID, -# POSTGRES_USERS_DSN/POSTGRES_CACHE_DSN (host — `postgres`, не 127.0.0.1), -# NOWYM_USERS_PASSWORD/NOWYM_CACHE_PASSWORD (те же пароли, что в DSN), -# REDIS_HOST=redis, TOKEN_ENCRYPTION_KEY/WEBHOOK_SECRET/NOW_PLAYING_TOKEN -# (сгенерить: node -e 'console.log(require("crypto").randomBytes(32).toString("base64url"))') -docker compose up -d --build -``` - -Postgres-роли (`nowym_users_app`/`nowym_cache_app`) создаются автоматически -при первом старте контейнера (`docs/sql/docker-init.sh`), схему таблиц -внутри баз бот накатывает сам, как и везде. Подключение к Telegram — тот же -выбор `BOT_API_BASE_URL`/`WEBHOOK_HOST` из «Быстрого старта» выше (self-hosted -Bot API сервер удобнее поднять отдельным контейнером рядом, см. пример там). - ### Переменные `.env` -Обязательные (без них бот не стартует — упадёт с явной ошибкой, какой -переменной не хватает): +Обязательные (без них бот не стартует): | Переменная | Назначение | |---|---| | `TELEGRAM_BOT_TOKEN` | токен бота от @BotFather | | `TELEGRAM_CHANNEL_ID` | id служебного канала-кэша (бот — админ канала) | -| `TOKEN_ENCRYPTION_KEY` | ключ шифрования токенов в БД (`install.sh` генерит сам) | +| `SPOTIFY_CLIENT_ID` | Client ID приложения Spotify (dashboard) | +| `TOKEN_ENCRYPTION_KEY` | ключ шифрования токенов в БД (`install.sh` генерит) | | `POSTGRES_USERS_DSN` | DSN базы пользователей | | `POSTGRES_CACHE_DSN` | DSN базы кэша | -| `BOT_API_BASE_URL` **или** `WEBHOOK_HOST` | один из двух обязателен — способ получать апдейты от Telegram, см. «Быстрый старт» | - -Функционально обязательна, хоть и не проверяется на старте: -`ADMIN_USER_ID` — без него `/health`/`/broadcast` недоступны никому -(гейт fail-closed) и алерты об ошибках в личку админу не шлются. - -Опционально: `GENIUS_TOKEN` (лирика точнее — ключ на genius.com/api-clients), -`OWNER_ID` (кто в Ynison-наблюдателе, по умолчанию = `ADMIN_USER_ID`), -`WEBHOOK_PATH`/`WEBHOOK_SECRET`/`WEBHOOK_SSL_CERT`/`WEBHOOK_SSL_KEY` -(детали облачного webhook-режима), `NOW_PLAYING_TOKEN` (без него `GET -/now-playing` всегда 401), `ICON_SET_NAME` (Telegram custom-emoji пак для -иконок кнопок — дефолт `tgiosicons` публичный, работает без настройки; -свой пак — через @Stickers), лимиты и размеры кэшей — дефолты и полный -список в `src/settings.ts`. - -### База данных - -Две изолированные Postgres-базы (users/cache). `install.sh` накатывает -роли автоматически (пароли генерит сам, тут же прописывает DSN в `.env`). -Руками — один раз: +| `BOT_API_BASE_URL` **или** `WEBHOOK_HOST` | один из двух обязателен | -```bash -sudo -u postgres psql -f docs/sql/init.sql # свои пароли вместо CHANGE_ME_* -``` - -и те же пароли — в `POSTGRES_*_DSN` в `.env`. Схему таблиц внутри баз бот -создаёт сам при первом старте, руками накатывать не нужно. - -### Redis - -Нужен для стека диалогов (`@gramio/dialogs`). Дефолт — `localhost:6379`, без -пароля. `sudo apt install redis-server && sudo systemctl enable --now redis-server`. +Полный список — в `.env.example` и `src/settings.ts`. ## Запуск ```bash -npm run start # tsx src/main.ts — webhook (облако) или long-polling (self-hosted API) -npm run dev # то же самое, но tsx watch — авто-рестарт на изменениях +npm run start # tsx src/main.ts +npm run dev # tsx watch — авто-рестарт на изменениях npm run typecheck # tsc --noEmit -npm run lint # biome check . (npm run lint:fix — почить автофиксом) -npm test # node --import tsx --test, ~154 теста, секунд 12 +npm run lint # biome check . +npm test # node --import tsx --test ``` -Режим выбирается сам: задан `BOT_API_BASE_URL` → self-hosted Bot API сервер + +Режим выбирается сам: `BOT_API_BASE_URL` задан → self-hosted Bot API + long-polling; пусто → облако + webhook. В webhook-режиме поднимается один -http(s)-сокет с роутами: `WEBHOOK_PATH` (апдейты Telegram, опционально -проверяется `X-Telegram-Bot-Api-Secret-Token`), `GET /now-playing` (JSON о -текущем треке владельца + недавние (`recent`, до 20 последних, из живых -трекчейнджей вотчера, без похода в Yandex API), `Authorization: Bearer -NOW_PLAYING_TOKEN`), `GET /health` и `GET /metrics` (Prometheus text — -статус пулов Postgres, hit/miss in-memory кэшей, статус Ynison-вотчера, -счётчики ошибок/rate-limit-реджектов; без авторизации, как и `/health`). -TLS — если заданы `WEBHOOK_SSL_CERT`/`WEBHOOK_SSL_KEY`. - -### Прод (pm2) - -В репе есть готовый `ecosystem.config.cjs` (единственный fork-инстанс, -autorestart, `--expose-gc`). Полностью портативный — `cwd`/`interpreter` -вычисляются на лету (`__dirname`/`process.execPath`), патчить или -адаптировать под сервер не нужно, работает сразу после клонирования куда -угодно. - -```bash -pm2 start ecosystem.config.cjs -pm2 save # чтобы пережило ребут (после pm2 startup) -pm2 logs nowym-ts -``` - -### Бэкапы - -`nowym_users` хранит зашифрованные OAuth-токены — потеря базы означает, -что все переавторизуются заново. `scripts/backup.sh` дампит обе базы -(`pg_dump -Fc`), подчищает дампы старше 14 дней (`NOWYM_BACKUP_KEEP_DAYS`) -и шлёт оба файла `ADMIN_USER_ID` в личку (`TELEGRAM_BOT_TOKEN`/ -`BOT_API_BASE_URL` из `.env`; на self-hosted Bot API сервере лимит на файл -2ГБ, не 50МБ как в облаке). Отправка best-effort — сбой Telegram не роняет -сам бэкап, дампы уже на диске. - -```bash -./scripts/backup.sh # разово, в /var/backups/nowym -crontab -e # раз в сутки: -# 0 4 * * * /path/to/nowym/scripts/backup.sh >> /var/log/nowym-backup.log 2>&1 - -# восстановление: -sudo -u postgres pg_restore -d nowym_users --clean --if-exists /var/backups/nowym/nowym_users-*.dump -``` - -## Структура - -| Путь | Назначение | -|---|---| -| `src/bot/` | хендлеры, диалоги-меню (`@gramio/dialogs`), сборка карточки, DI-контейнер | -| `src/bot/iconSet.ts` | резолвер премиум-эмодзи (`icon_custom_emoji_id`) под кнопки | -| `src/services/` | рендер карточки (+ пул воркеров `cardRenderPool.ts`), поиск, кэш, альбомы, рассылка. `lyricsVideo.ts` — закомментированная заготовка, не живой код | -| `src/yandex/` | клиент Yandex Music API, метаданные, лирика, Ynison | -| `src/infra/` | http-клиент, шифрование, логирование, rate-limit, ретраи 429/5xx (`floodRetry.ts`), алертер об ошибках админу (`adminAlerter.ts`) | -| `src/storage/` | пулы Postgres, схемы users/cache/settings | -| `src/tagging/` | тегирование аудио (TagLib/ffmpeg) | -| `test/` | `node:test`, ~123 штуки | -| `install.sh` | установка в одну команду (см. выше) | -| `docs/sql/init.sql` | создание Postgres-ролей и баз (bare-metal) | -| `docs/sql/docker-init.sh` | то же самое, но для первого старта postgres-контейнера | -| `Dockerfile`, `docker-compose.yml` | альтернатива `install.sh` — бот+Postgres+Redis в контейнерах | -| `scripts/backup.sh` | pg_dump обеих баз + ротация, крон-скрипт | -| `biome.json` | конфиг линтера/форматтера (`npm run lint`) | - -## Если что-то сломалось - -1. **Бот запускается, но `@bot запрос` ничего не отдаёт** — почти всегда - забытый `/setinline` в BotFather. Проверить: `/setinline` без аргумента - в диалоге с BotFather покажет текущий статус. -2. **Инлайн-выдача показывается, но выбор трека зависает на плейсхолдере - («гружу...») навсегда** — забытый `/setinlinefeedback` → `100%` в - BotFather. Без него Telegram шлёт `chosen_inline_result` не на каждый - выбор, а бот именно по этому сигналу качает и подменяет плейсхолдер на - реальное аудио. -3. **Бот не стартует вовсе** — `.env` неполный, ошибка при старте называет - переменную явно (`X не задан в .env`). Если ругается на «ни - BOT_API_BASE_URL, ни WEBHOOK_HOST» — см. «Быстрый старт», шаг 4, нужен - ровно один из двух способов получать апдейты. -4. `npm run typecheck` и `npm test` — если не про конфиг, а про сам код. -5. `pm2 logs nowym-ts` и `GET /health` — если это прод. Свежий рестарт - подхватывает `.env` и код с диска, а не то, что было закешировано - node-процессом в памяти. +http(s)-сокет с роутами: `WEBHOOK_PATH` (апдейты Telegram), `/spotify/callback` +(OAuth), `GET /now-playing` + `GET /now-playing/stream` (Bearer +`NOW_PLAYING_TOKEN`), `/health`, `/metrics`. TLS — если заданы +`WEBHOOK_SSL_CERT`/`WEBHOOK_SSL_KEY`. -## Контрибьютинг +## Про юридическое -PR приветствуются — см. [CONTRIBUTING.md](CONTRIBUTING.md): как гонять -typecheck/lint/тесты локально и чего ждать от PR. +`yt-dlp` качает с YouTube/SoundCloud — это нарушает их ToS и Spotify ToS. +Для приватного бота «для себя и друзей» на своём сервере этим годами +пользуются тысячи людей. Публиковать в открытый доступ или продавать +подписки — путь к DMCA. Оригинальный nowym в этом смысле в такой же зоне. ## Лицензия -[MIT](LICENSE) — используй как хочешь, в коммерции и не только, но -сохраняй указание оригинального авторства. +[MIT](LICENSE). Форк nowym: сохраняй указание оригинального авторства +(`pmdw/nowym`). diff --git a/docker-compose.yml b/docker-compose.yml index f7e60ba..4f3db16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,8 +25,8 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres POSTGRES_DB: postgres - NOWYM_USERS_PASSWORD: ${NOWYM_USERS_PASSWORD:?задай в .env — см. .env.example} - NOWYM_CACHE_PASSWORD: ${NOWYM_CACHE_PASSWORD:?задай в .env — см. .env.example} + NOWSPOT_USERS_PASSWORD: ${NOWSPOT_USERS_PASSWORD:?задай в .env — см. .env.example} + NOWSPOT_CACHE_PASSWORD: ${NOWSPOT_CACHE_PASSWORD:?задай в .env — см. .env.example} volumes: - postgres-data:/var/lib/postgresql/data - ./docs/sql/docker-init.sh:/docker-entrypoint-initdb.d/init.sh:ro diff --git a/docs/sql/docker-init.sh b/docs/sql/docker-init.sh index ac93cf2..419c2bc 100755 --- a/docs/sql/docker-init.sh +++ b/docs/sql/docker-init.sh @@ -1,13 +1,13 @@ #!/bin/bash # запускается postgres-образом автоматически (docker-entrypoint-initdb.d) при # первом старте контейнера с пустым data-dir. Пароли — из env самого -# postgres-сервиса в docker-compose.yml (NOWYM_USERS_PASSWORD/NOWYM_CACHE_PASSWORD), +# postgres-сервиса в docker-compose.yml (NOWSPOT_USERS_PASSWORD/NOWSPOT_CACHE_PASSWORD), # те же значения должны быть у POSTGRES_USERS_DSN/POSTGRES_CACHE_DSN в .env бота. set -euo pipefail psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL - CREATE USER nowym_users_app WITH PASSWORD '${NOWYM_USERS_PASSWORD}'; - CREATE USER nowym_cache_app WITH PASSWORD '${NOWYM_CACHE_PASSWORD}'; - CREATE DATABASE nowym_users OWNER nowym_users_app; - CREATE DATABASE nowym_cache OWNER nowym_cache_app; + CREATE USER nowspot_users_app WITH PASSWORD '${NOWSPOT_USERS_PASSWORD}'; + CREATE USER nowspot_cache_app WITH PASSWORD '${NOWSPOT_CACHE_PASSWORD}'; + CREATE DATABASE nowspot_users OWNER nowspot_users_app; + CREATE DATABASE nowspot_cache OWNER nowspot_cache_app; EOSQL diff --git a/docs/sql/init.sql b/docs/sql/init.sql index 25a350e..db1882d 100644 --- a/docs/sql/init.sql +++ b/docs/sql/init.sql @@ -1,12 +1,12 @@ --- Создание двух изолированных БД для nowym. +-- Создание двух изолированных БД для nowspot. -- ПЕРЕД ЗАПУСКОМ: подставь свои пароли вместо CHANGE_ME_USERS / CHANGE_ME_CACHE. -- Запуск: sudo -u postgres psql -f docs/sql/init.sql -CREATE USER nowym_users_app WITH PASSWORD 'CHANGE_ME_USERS'; -CREATE USER nowym_cache_app WITH PASSWORD 'CHANGE_ME_CACHE'; +CREATE USER nowspot_users_app WITH PASSWORD 'CHANGE_ME_USERS'; +CREATE USER nowspot_cache_app WITH PASSWORD 'CHANGE_ME_CACHE'; -CREATE DATABASE nowym_users OWNER nowym_users_app; -CREATE DATABASE nowym_cache OWNER nowym_cache_app; +CREATE DATABASE nowspot_users OWNER nowspot_users_app; +CREATE DATABASE nowspot_cache OWNER nowspot_cache_app; -- Схема таблиц создаётся автоматически при первом старте бота -- (см. src/storage/users.ts::initSchema и src/storage/cache.ts::initSchema). diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs index c7d53c4..2bdc4df 100644 --- a/ecosystem.config.cjs +++ b/ecosystem.config.cjs @@ -1,5 +1,5 @@ /** - * pm2-конфиг автозапуска nowym-ts. + * pm2-конфиг автозапуска nowspot. * * Полностью портативный — ничего не хардкожено под конкретную машину, * ничего не нужно патчить после клонирования: @@ -34,7 +34,7 @@ const path = require("node:path"); module.exports = { apps: [ { - name: "nowym-ts", + name: "nowspot", script: "src/main.ts", cwd: __dirname, interpreter: process.execPath, diff --git a/install.sh b/install.sh index ef3de7d..1525132 100755 --- a/install.sh +++ b/install.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# установка nowym-ts (gramio edition) в одну команду. +# установка nowspot (форк nowym под Spotify + yt-dlp) в одну команду. # # делает: проверяет node, ставит зависимости, готовит .env (секреты и # постгресовые пароли генерит сам, при подтверждении сразу накатывает @@ -100,6 +100,8 @@ if [ "$ENV_IS_NEW" -eq 1 ]; then [ -n "$CHANNEL_ID" ] && set_env "TELEGRAM_CHANNEL_ID" "$CHANNEL_ID" ADMIN_ID="$(ask "твой telegram id (напиши @userinfobot)")" [ -n "$ADMIN_ID" ] && set_env "ADMIN_USER_ID" "$ADMIN_ID" + SPOTIFY_ID="$(ask "Spotify Client ID (developer.spotify.com/dashboard → Create app)")" + [ -n "$SPOTIFY_ID" ] && set_env "SPOTIFY_CLIENT_ID" "$SPOTIFY_ID" fi step "postgres" @@ -115,7 +117,7 @@ fi DB_READY_IN_ENV=0 if [ "$PG_READY" -eq 1 ] && [ "$ENV_IS_NEW" -eq 1 ]; then - echo " накатить роли/базы (nowym_users, nowym_cache) прямо сейчас? Пароли сгенерю" + echo " накатить роли/базы (nowspot_users, nowspot_cache) прямо сейчас? Пароли сгенерю" echo " сам и сразу пропишу в .env — синхронизировать руками ничего не придётся." if confirm "накатить postgres-роли автоматически"; then PW_USERS="$(gen_pw)"; PW_CACHE="$(gen_pw)" @@ -126,13 +128,13 @@ if [ "$PG_READY" -eq 1 ] && [ "$ENV_IS_NEW" -eq 1 ]; then -e "s/CHANGE_ME_USERS/${PW_USERS}/" \ -e "s/CHANGE_ME_CACHE/${PW_CACHE}/" \ docs/sql/init.sql)" - if sudo -u postgres psql -v ON_ERROR_STOP=1 >/tmp/nowym-install-psql.log 2>&1 <<< "$SQL_FILLED"; then - set_env "POSTGRES_USERS_DSN" "postgresql://nowym_users_app:${PW_USERS}@127.0.0.1:5432/nowym_users" - set_env "POSTGRES_CACHE_DSN" "postgresql://nowym_cache_app:${PW_CACHE}@127.0.0.1:5432/nowym_cache" + if sudo -u postgres psql -v ON_ERROR_STOP=1 >/tmp/nowspot-install-psql.log 2>&1 <<< "$SQL_FILLED"; then + set_env "POSTGRES_USERS_DSN" "postgresql://nowspot_users_app:${PW_USERS}@127.0.0.1:5432/nowspot_users" + set_env "POSTGRES_CACHE_DSN" "postgresql://nowspot_cache_app:${PW_CACHE}@127.0.0.1:5432/nowspot_cache" DB_READY_IN_ENV=1 ok "роли и базы созданы, DSN прописаны в .env" else - err "psql упал — лог в /tmp/nowym-install-psql.log (частая причина: роли уже существуют — тогда" + err "psql упал — лог в /tmp/nowspot-install-psql.log (частая причина: роли уже существуют — тогда" err "просто пропиши POSTGRES_*_DSN в .env вручную с уже имеющимися паролями)." fi fi @@ -151,12 +153,21 @@ else warn "sudo apt install redis-server && sudo systemctl enable --now redis-server" fi +step "yt-dlp" +if command -v yt-dlp >/dev/null 2>&1; then + ok "yt-dlp найден ($(yt-dlp --version 2>/dev/null || echo '?'))" +else + warn "yt-dlp не найден — без него скачивание не работает вообще (Spotify API аудио" + warn "не отдаёт, всё качается через yt-dlp с YouTube/SoundCloud). apt install yt-dlp" + warn "или pipx install yt-dlp (свежее)." +fi + step "ffmpeg" if command -v ffmpeg >/dev/null 2>&1; then ok "ffmpeg найден" else - warn "ffmpeg не найден — нужен только для аудио-эффектов (ncore/speed/slow)," - warn "остальной бот заработает и без него. apt install ffmpeg / brew install ffmpeg." + warn "ffmpeg не найден — нужен для тегирования аудио и аудио-эффектов" + warn "(ncore/speed/slow). apt install ffmpeg / brew install ffmpeg." fi step "подключение к Telegram" @@ -194,4 +205,4 @@ echo " 2. добить .env, если что-то осталось пустым echo " 3. решить, как бот будет получать апдейты — BOT_API_BASE_URL или WEBHOOK_HOST (см. README)" echo " 4. npm run start — или pm2 start ecosystem.config.cjs для прода" echo " 5. бэкапы БД (не обязательно, но советую) — раз в crontab -e:" -echo " 0 4 * * * $SCRIPT_DIR/scripts/backup.sh >> /var/log/nowym-backup.log 2>&1" +echo " 0 4 * * * $SCRIPT_DIR/scripts/backup.sh >> /var/log/nowspot-backup.log 2>&1" diff --git a/package-lock.json b/package-lock.json index 7350599..743ed6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -104,9 +104,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -124,9 +121,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -144,9 +138,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -164,9 +155,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -906,9 +894,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -929,9 +914,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -952,9 +934,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -975,9 +954,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -998,9 +974,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1087,9 +1060,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1103,9 +1073,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1119,9 +1086,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1180,6 +1144,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1196,6 +1161,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1212,6 +1178,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1228,6 +1195,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1244,6 +1212,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1260,6 +1229,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1276,6 +1246,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1292,6 +1263,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1308,6 +1280,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1324,6 +1297,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1340,6 +1314,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1356,6 +1331,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1372,6 +1348,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1388,6 +1365,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1404,6 +1382,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1420,6 +1399,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1436,6 +1416,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1452,6 +1433,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1468,6 +1450,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -1484,6 +1467,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "Apache-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index 21ba576..6be2dd4 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,10 @@ { - "name": "nowym-ts", - "version": "1.7.3", + "name": "nowspot", + "version": "0.1.0", "private": true, "license": "MIT", "type": "module", - "description": "Yandex Music Telegram bot — TypeScript port of nowym", + "description": "Spotify Telegram bot — форк nowym под Spotify + yt-dlp", "engines": { "node": ">=22" }, @@ -17,7 +17,6 @@ "lint:fix": "biome check --write ." }, "dependencies": { - "@dvxch/yandex-music": "^0.2.6", "@gramio/dialogs": "^0.3.1", "@gramio/storage-redis": "^1.0.5", "@napi-rs/canvas": "^1.0.3", diff --git a/scripts/backup.sh b/scripts/backup.sh index c1ca455..18e4307 100755 --- a/scripts/backup.sh +++ b/scripts/backup.sh @@ -1,26 +1,26 @@ #!/usr/bin/env bash -# бэкап nowym_users/nowym_cache — pg_dump (custom format) + ротация по возрасту +# бэкап nowspot_users/nowspot_cache — pg_dump (custom format) + ротация по возрасту # + отправка дампов админу в личку. Крон-скрипт, к процессу бота не привязан # (шлёт напрямую через Bot API, бот может быть в этот момент и не запущен). # # ./scripts/backup.sh # # crontab -e (root, ежедневно): -# 59 20 * * * /home/rage/nowym/scripts/backup.sh >> /var/log/nowym-backup.log 2>&1 +# 59 20 * * * /home/rage/nowspot/scripts/backup.sh >> /var/log/nowspot-backup.log 2>&1 # # восстановление: -# sudo -u postgres pg_restore -d nowym_users --clean --if-exists /var/backups/nowym/nowym_users-*.dump +# sudo -u postgres pg_restore -d nowspot_users --clean --if-exists /var/backups/nowspot/nowspot_users-*.dump set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -BACKUP_DIR="${NOWYM_BACKUP_DIR:-/var/backups/nowym}" -KEEP_DAYS="${NOWYM_BACKUP_KEEP_DAYS:-14}" +BACKUP_DIR="${NOWSPOT_BACKUP_DIR:-/var/backups/nowspot}" +KEEP_DAYS="${NOWSPOT_BACKUP_KEEP_DAYS:-14}" STAMP="$(date +%Y%m%d-%H%M%S)" mkdir -p "$BACKUP_DIR" dumps=() -for db in nowym_users nowym_cache; do +for db in nowspot_users nowspot_cache; do file="$BACKUP_DIR/${db}-${STAMP}.dump" sudo -u postgres pg_dump -Fc "$db" >"$file" dumps+=("$file") diff --git a/src/audio/ytdlp.ts b/src/audio/ytdlp.ts new file mode 100644 index 0000000..bb3146e --- /dev/null +++ b/src/audio/ytdlp.ts @@ -0,0 +1,283 @@ +/** yt-dlp резолвер: (artist + title / ISRC) → локальный аудиофайл. + * + * Стратегия матчинга: + * 1. Если есть ISRC — ytsearch:"", доверяем результату (Topic-каналы + * артистов почти всегда несут ISRC в описании). + * 2. Иначе — ytsearch:" " с фильтром по длительности ±3 сек + * от заявленной, чтобы не подтянуть кавер/live/remix. + * 3. Fallback — SoundCloud поиск. + * + * Битрейт и кодек берём с yt-dlp (обычно opus 128-160 kbps c YouTube). Файл + * сохраняется в temp-директорию процесса; фоновый sweeper удаляет старые. */ + +import { spawn } from "node:child_process"; +import { mkdir, readdir, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { getLogger } from "../infra/logging.ts"; + +const log = getLogger("audio.ytdlp"); + +const TMP_ROOT = join(tmpdir(), "nowspot-audio"); +const FILE_TTL_MS = 15 * 60 * 1000; +const SWEEP_INTERVAL_MS = 5 * 60 * 1000; +const YTDLP_TIMEOUT_MS = 5 * 60 * 1000; +// Максимальная разница между заявленной длительностью и найденной, если +// матчим по названию — reject кавер/live/расширенный ремикс. +const DURATION_TOLERANCE_S = 3; + +export interface ResolveInput { + /** для лога/дедупа. Обычно spotify track id. */ + id: string; + artist: string; + title: string; + /** секунды. Используется для фильтрации по длительности. */ + durationSec?: number | null; + isrc?: string | null; + quality?: "best" | "economy"; +} + +export interface ResolvedAudio { + filePath: string; + /** реальный кодек файла на диске — opus/mp3/m4a. */ + codec: string; + bitrateKbps: number; + /** секунды по данным yt-dlp. */ + durationSec: number; + /** youtube / soundcloud — что оказалось источником. */ + source: "youtube" | "soundcloud"; +} + +interface YtdlpInfo { + id: string; + title?: string; + duration?: number; + ext?: string; + acodec?: string; + abr?: number; + tbr?: number; + webpage_url?: string; + extractor?: string; + filepath?: string; + _filename?: string; + requested_downloads?: Array<{ filepath?: string; ext?: string }>; +} + +let sweeperStarted = false; + +function startSweeper(): void { + if (sweeperStarted) return; + sweeperStarted = true; + const timer = setInterval(() => { + void sweepOldFiles().catch((e) => log.warning(`sweeper: ${e}`)); + }, SWEEP_INTERVAL_MS); + timer.unref(); +} + +async function sweepOldFiles(): Promise<void> { + let entries: string[]; + try { + entries = await readdir(TMP_ROOT); + } catch { + return; + } + const now = Date.now(); + for (const name of entries) { + const full = join(TMP_ROOT, name); + try { + const s = await stat(full); + if (now - s.mtimeMs > FILE_TTL_MS) { + await rm(full, { force: true }); + } + } catch { + // race с параллельной чисткой — игнор + } + } +} + +async function ensureTmpRoot(): Promise<void> { + await mkdir(TMP_ROOT, { recursive: true }); + startSweeper(); +} + +interface RunOpts { + args: string[]; + timeoutMs?: number; +} + +interface RunResult { + stdout: string; + stderr: string; + code: number; +} + +function runYtdlp(opts: RunOpts): Promise<RunResult> { + return new Promise((resolve, reject) => { + const child = spawn("yt-dlp", opts.args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (d) => { + stdout += d; + }); + child.stderr.on("data", (d) => { + stderr += d; + }); + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error(`yt-dlp таймаут (${opts.timeoutMs ?? YTDLP_TIMEOUT_MS}мс)`)); + }, opts.timeoutMs ?? YTDLP_TIMEOUT_MS); + child.on("error", (e) => { + clearTimeout(timeout); + reject(e); + }); + child.on("close", (code) => { + clearTimeout(timeout); + resolve({ stdout, stderr, code: code ?? -1 }); + }); + }); +} + +function parseInfoJson(stdout: string): YtdlpInfo | null { + const trimmed = stdout.trim(); + if (!trimmed) return null; + // --print single-line JSON: одна строка, но на всякий случай берём первую + const line = trimmed.split(/\r?\n/)[0]!; + try { + return JSON.parse(line); + } catch (e) { + log.warning(`не распарсил JSON yt-dlp: ${e}`); + return null; + } +} + +/** Резолвит источник по запросу, без скачивания. Возвращает URL страницы. */ +async function resolveSource(input: ResolveInput): Promise<{ url: string; source: "youtube" | "soundcloud" } | null> { + const queries: Array<{ q: string; source: "youtube" | "soundcloud" }> = []; + if (input.isrc) { + queries.push({ q: `ytsearch1:"${input.isrc}"`, source: "youtube" }); + } + const nameQuery = `${input.artist} ${input.title}`.replace(/["']/g, "").trim(); + queries.push({ q: `ytsearch3:${nameQuery}`, source: "youtube" }); + queries.push({ q: `scsearch3:${nameQuery}`, source: "soundcloud" }); + + for (const { q, source } of queries) { + try { + const res = await runYtdlp({ + args: [ + "--quiet", + "--no-warnings", + "--flat-playlist", + "--print", + "%(.{id,title,duration,webpage_url,extractor})#j", + "--playlist-end", + "3", + q, + ], + timeoutMs: 30_000, + }); + if (res.code !== 0) { + log.debug(`yt-dlp probe (${source}) exit=${res.code}: ${res.stderr.slice(0, 200)}`); + continue; + } + const candidates = res.stdout + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .map((l) => { + try { + return JSON.parse(l) as YtdlpInfo; + } catch { + return null; + } + }) + .filter((c): c is YtdlpInfo => c !== null && Boolean(c.webpage_url)); + + if (candidates.length === 0) continue; + + // ISRC-запрос доверяем сразу: первый кандидат. + if (input.isrc && source === "youtube") { + return { url: candidates[0]!.webpage_url!, source }; + } + + // Матчинг по длительности при наличии durationSec. + if (input.durationSec && input.durationSec > 0) { + const match = candidates.find( + (c) => c.duration && Math.abs(c.duration - input.durationSec!) <= DURATION_TOLERANCE_S, + ); + if (match) return { url: match.webpage_url!, source }; + } + + // Иначе — первый кандидат. + return { url: candidates[0]!.webpage_url!, source }; + } catch (e) { + log.warning(`yt-dlp probe (${source}) упал: ${e}`); + } + } + return null; +} + +/** Скачивает найденный трек в TMP_ROOT. Возвращает описание файла. */ +export async function resolveAndDownload(input: ResolveInput): Promise<ResolvedAudio | null> { + await ensureTmpRoot(); + const src = await resolveSource(input); + if (!src) { + log.warning(`[ytdlp] не нашлось источника для ${input.artist} — ${input.title} (id=${input.id})`); + return null; + } + + // формат — bestaudio для best; для economy берём худшую (>=64kbps), + // отталкиваясь от того, что даёт YouTube (обычно opus 48/128/160). + const format = input.quality === "economy" ? "worstaudio[abr>=64]/worstaudio" : "bestaudio/best"; + const outputTpl = join(TMP_ROOT, `${input.id}-%(id)s.%(ext)s`); + + try { + const res = await runYtdlp({ + args: [ + "--quiet", + "--no-warnings", + "--no-playlist", + "--format", + format, + "--output", + outputTpl, + "--print", + "after_move:%(.{id,title,duration,ext,acodec,abr,tbr,filepath,_filename,requested_downloads})#j", + src.url, + ], + }); + if (res.code !== 0) { + log.warning(`[ytdlp] download exit=${res.code}: ${res.stderr.slice(0, 300)}`); + return null; + } + const info = parseInfoJson(res.stdout); + if (!info) return null; + const filePath = info.requested_downloads?.[0]?.filepath ?? info.filepath ?? info._filename ?? null; + if (!filePath) { + log.warning(`[ytdlp] нет пути к файлу в stdout: ${res.stdout.slice(0, 200)}`); + return null; + } + // проверяем что файл реально на диске + try { + await stat(filePath); + } catch { + log.warning(`[ytdlp] файл не найден на диске: ${filePath}`); + return null; + } + return { + filePath, + codec: info.acodec ?? info.ext ?? "opus", + bitrateKbps: Math.round(info.abr ?? info.tbr ?? 160), + durationSec: Math.round(info.duration ?? 0), + source: src.source, + }; + } catch (e) { + log.warning(`[ytdlp] сбой скачивания: ${e}`); + return null; + } +} + +/** Явная очистка одного файла (после успешной заливки в канал). */ +export async function removeTempFile(filePath: string): Promise<void> { + if (!filePath.startsWith(TMP_ROOT)) return; // защита: не даём удалить что попало + await rm(filePath, { force: true }).catch(() => {}); +} diff --git a/src/bot/captions.ts b/src/bot/captions.ts index 8a470e7..2275ac5 100644 --- a/src/bot/captions.ts +++ b/src/bot/captions.ts @@ -1,6 +1,6 @@ /** Подписи под аудио для inline-результатов. */ import { blockquote, bold, type FormattableString, format, italic, link } from "gramio"; -import { trackUrl } from "../yandex/urls.ts"; +import { trackUrl } from "../music/urls.ts"; export function senderHandleOf(user: { username?: string | null; firstName?: string | null }): string { return user.username ? `@${user.username}` : user.firstName || "user"; diff --git a/src/bot/cardBuilder.ts b/src/bot/cardBuilder.ts index 3515f52..57fc252 100644 --- a/src/bot/cardBuilder.ts +++ b/src/bot/cardBuilder.ts @@ -5,9 +5,8 @@ import type { Bot } from "gramio"; import { getLogger } from "../infra/logging.ts"; import type { LyricsBlock } from "../services/card.ts"; import type { UserSettings } from "../storage/settings.ts"; -import { fetchCover } from "../yandex/media.ts"; -import { enrichCardMetaColor, metaNeedsWaveColor } from "../yandex/metadata.ts"; -import type { CardMeta } from "../yandex/types.ts"; +import { fetchCover } from "../music/media.ts"; +import type { CardMeta } from "../music/types.ts"; import type { Container } from "./container.ts"; const log = getLogger("bot.card_builder"); @@ -48,7 +47,7 @@ export interface CardBuildInput { bot: Bot; container: Container; userId: number; - /** нужен только для wave-цвета (yandex-клиент). null → карточка рендерится без enrich. */ + /** нужен только для wave-цвета (Spotify-клиент). null → карточка рендерится без enrich. */ token: string | null; coverUrl: string; title: string; @@ -65,25 +64,13 @@ export interface CardBuildInput { /** JPEG-байты карточки. Все сетевые операции — параллельно. */ export async function buildCardImage(input: CardBuildInput): Promise<Buffer> { - const { bot, container, userId, token, meta, settings } = input; + const { bot, container, userId, meta, settings } = input; const toggles = settings.card_toggles; const avatarEnabled = Boolean(toggles.avatar); - const needColor = metaNeedsWaveColor(meta) && token !== null; - - // wave-цвет требует яндекс-клиента — поднимаем его только при необходимости - const colorTask: Promise<void> = needColor - ? container - .getYandexService(token!, userId) - .then((yandex) => enrichCardMetaColor(meta, yandex)) - .catch((e) => { - log.debug(`[card] wave-цвет не подтянут: ${e}`); - }) - : Promise.resolve(); const [coverBytes, senderAvatar] = await Promise.all([ input.coverUrl ? fetchCover(input.coverUrl, container.httpClient) : Promise.resolve(null), avatarEnabled ? fetchUserAvatar(bot, userId, container) : Promise.resolve(null), - colorTask, ]); // рендер идёт в worker_threads — canvas.encode() иначе блокирует event loop diff --git a/src/bot/container.ts b/src/bot/container.ts index b11738d..adf8ffb 100644 --- a/src/bot/container.ts +++ b/src/bot/container.ts @@ -1,4 +1,4 @@ -/** DI-контейнер: держит все сервисы и кэши, резолвит per-user YandexClient/ +/** DI-контейнер: держит все сервисы и кэши, резолвит per-user MusicClient/ * SearchService, проактивно обновляет токен. Создаётся в main, стартует с * готовым GramIO-ботом. */ @@ -26,7 +26,7 @@ import { closeAll as closeDbPools, initializeAll } from "../storage/pool.ts"; import { SettingsDb, type UserSettings } from "../storage/settings.ts"; import { UsersDb } from "../storage/users.ts"; import { TaggingService } from "../tagging/service.ts"; -import { type YandexClient, YandexClientFactory } from "../yandex/client.ts"; +import { type MusicClient, MusicClientFactory } from "../music/client.ts"; import { ChannelSender } from "./telegramSender.ts"; const log = getLogger("bot.container"); @@ -66,13 +66,13 @@ export class Container { private bot!: Bot; // TTL, не голый Map: без него оба кэша росли бы весь lifetime процесса — - // на 5000+ лайфтайм-юзерах это гигабайты закэшированных YaTrack/http-клиентов. + // на 5000+ лайфтайм-юзерах это гигабайты закэшированных Track/http-клиентов. // Клиенты дешёвые в пересборке, час простоя не жалко. - private yandexClients!: TTLCache<YandexClient>; + private musicClients!: TTLCache<MusicClient>; private searchServices!: TTLCache<SearchService>; // дедуп конкурентных refreshToken на одного юзера — иначе resolveToken() // (проактивный путь) и makeRefreshCallback (реактивный, на 401 внутри - // YandexClient) могли одновременно слать один и тот же refresh_token, + // MusicClient) могли одновременно слать один и тот же refresh_token, // и параллельные usersDb.save() гонялись бы за тем, чья пара токенов останется. private refreshInFlight = new Map<number, Promise<string | null>>(); @@ -113,8 +113,8 @@ export class Container { // владельцы инлайнов: живут сутки (инлайн-сообщение могут переоткрыть позже) this.inlineOwners = new TTLCache<number>(10000, 24 * 3600, 3600); this.inlineEffects = new TTLCache<string>(10000, 24 * 3600, 3600); - // per-user YandexClient/SearchService — час простоя, пересборка дешёвая - this.yandexClients = new TTLCache<YandexClient>(1000, 3600, 600); + // per-user MusicClient/SearchService — час простоя, пересборка дешёвая + this.musicClients = new TTLCache<MusicClient>(1000, 3600, 600); this.searchServices = new TTLCache<SearchService>(1000, 3600, 600); await initializeAll(); @@ -166,37 +166,37 @@ export class Container { await this.inlineOwners?.close(); await this.inlineEffects?.close(); await this.userSettingsCache?.close(); - await this.yandexClients?.close(); + await this.musicClients?.close(); await this.searchServices?.close(); await this.httpClient?.close(); await closeDbPools(); log.info("контейнер остановлен"); } - async getYandexService(token: string, userId?: number): Promise<YandexClient> { - if (userId === undefined) return YandexClientFactory.create(token); + async getMusicService(token: string, userId?: number): Promise<MusicClient> { + if (userId === undefined) return MusicClientFactory.create(token); const key = String(userId); - const cached = this.yandexClients.get(key); + const cached = this.musicClients.get(key); if (cached !== null) return cached; const cb = this.makeRefreshCallback(userId); - const client = await YandexClientFactory.create(token, cb); - this.yandexClients.set(key, client); + const client = await MusicClientFactory.create(token, cb); + this.musicClients.set(key, client); return client; } async getTrackService(token: string, userId?: number): Promise<SearchService> { if (userId === undefined) { - const yandex = await this.getYandexService(token); - return new SearchService(yandex, this.trackCache, this.cacheService, this.botUsername, null); + const music = await this.getMusicService(token); + return new SearchService(music, this.trackCache, this.cacheService, this.botUsername, null); } const key = String(userId); const cached = this.searchServices.get(key); if (cached !== null) return cached; - const yandex = await this.getYandexService(token, userId); - const svc = new SearchService(yandex, this.trackCache, this.cacheService, this.botUsername, userId); + const music = await this.getMusicService(token, userId); + const svc = new SearchService(music, this.trackCache, this.cacheService, this.botUsername, userId); this.searchServices.set(key, svc); return svc; } @@ -231,7 +231,7 @@ export class Container { if (soonExpired && creds.refresh_token) { const newToken = await this.refreshTokenDeduped(userId, creds.refresh_token); if (newToken) { - // сбрасываем кэш: закэшированный YandexClient держит старый token + // сбрасываем кэш: закэшированный MusicClient держит старый token this.invalidateTokenCacheForUser(userId); return newToken; } @@ -241,7 +241,7 @@ export class Container { } invalidateTokenCacheForUser(userId: number): void { - this.yandexClients.delete(String(userId)); + this.musicClients.delete(String(userId)); this.searchServices.delete(String(userId)); } diff --git a/src/bot/dialogs.ts b/src/bot/dialogs.ts index 211a343..2809d00 100644 --- a/src/bot/dialogs.ts +++ b/src/bot/dialogs.ts @@ -35,7 +35,6 @@ const TRACK_SEND_MODE_LABELS: Record<string, string> = { stub: "пустышка const TRACK_QUALITY_LABELS: Record<string, string> = { best: "оптимальное", economy: "экономичное", - lossless: "превосходное", }; const CARD_PROGRESS_LABELS: Record<string, string> = { wavy: "волна", bar: "полоска" }; const CARD_FIELD_LABELS: Record<string, string> = { @@ -201,7 +200,7 @@ export async function startLogin(dialog: DialogManager, bot: Bot, container: Con export async function startLogout(dialog: DialogManager, container: Container, userId: number): Promise<void> { const deleted = await container.usersDb.delete(userId); - // иначе закэшированный YandexClient/SearchService переживает logout — держит + // иначе закэшированный MusicClient/SearchService переживает logout — держит // расшифрованный токен и историю юзера в памяти до истечения TTL кэша container.invalidateTokenCacheForUser(userId); await dialog.switchTo("logout_done", { @@ -400,7 +399,7 @@ export function buildMenuDialog(bot: Bot, container: Container): Dialog { [ radioSelect({ id: "track_quality", - values: ["best", "economy", "lossless"], + values: ["best", "economy"], label: trackQualityLabel, current: (s) => s.track_quality, save: (u, v) => container.settingsDb.setTrackQuality(u, v), @@ -501,7 +500,7 @@ export function buildMenuDialog(bot: Bot, container: Container): Dialog { [ Url({ ...chrome("➡️", "▸", "продолжить", "success"), - url: (rc) => ld(rc.data).login?.verificationUrl ?? "https://music.yandex", + url: (rc) => ld(rc.data).login?.verificationUrl ?? "https://open.spotify.com", }), actBtn("❌", "✕", "отмена", "cancel_login", onCancelLogin, "danger"), ], diff --git a/src/bot/handlers/admin.ts b/src/bot/handlers/admin.ts index 7cd57cd..3da5eac 100644 --- a/src/bot/handlers/admin.ts +++ b/src/bot/handlers/admin.ts @@ -12,7 +12,7 @@ import { broadcastConfirmMarkup, broadcastSendingMarkup } from "../markup.ts"; const log = getLogger("bot.admin"); const TG_API = "https://api.telegram.org/"; -const YM_API = "https://api.music.yandex.net/"; +const YM_API = "https://api.music.music.net/"; export function isAdmin(userId: number): boolean { const adminId = getSettings().ADMIN_USER_ID; @@ -63,8 +63,8 @@ export function registerAdmin(bot: Bot, container: Container): void { const wOk = Boolean(w?.running && w?.connected); const wLine = w === null - ? format`${flag(false)} ynison-watcher: не запущен (OWNER_ID=0)` - : format`${flag(wOk)} ynison-watcher: ${w.connected ? "подключён" : "нет ws"}, последний кадр ${wAge === null ? "—" : Math.round(wAge / 1000) + "с назад"}`; + ? format`${flag(false)} now-playing watcher: не запущен (OWNER_ID=0)` + : format`${flag(wOk)} now-playing watcher: ${w.connected ? "подключён" : "нет ws"}, последний кадр ${wAge === null ? "—" : Math.round(wAge / 1000) + "с назад"}`; const up = usersPool.stats(); const cp = cachePool.stats(); diff --git a/src/bot/handlers/albums.ts b/src/bot/handlers/albums.ts index 048a875..6501c10 100644 --- a/src/bot/handlers/albums.ts +++ b/src/bot/handlers/albums.ts @@ -3,15 +3,15 @@ import { bold, type FormattableString, format } from "gramio"; import { getLogger } from "../../infra/logging.ts"; import { pluralTracks } from "../../infra/text.ts"; import { ProgressMessage } from "../../services/albums.ts"; -import type { YandexClient } from "../../yandex/client.ts"; -import { albumEmoji, albumTypeRu, LABELED_ALBUM_TYPES } from "../../yandex/metadata.ts"; -import type { AlbumData } from "../../yandex/types.ts"; +import type { MusicClient } from "../../music/client.ts"; +import { albumEmoji, albumTypeRu, LABELED_ALBUM_TYPES } from "../../music/metadata.ts"; +import type { AlbumData } from "../../music/types.ts"; import type { Container } from "../container.ts"; import { cbData } from "../ctxutil.ts"; const log = getLogger("bot.albums"); -const ALBUM_URL_RE = /music\.yandex(?:\.ru)?\/album\/(\d+)/; +const ALBUM_URL_RE = /open\.spotify\.com\/(?:intl-[a-z]+\/)?album\/([A-Za-z0-9]{22})/; function albumHeader(artist: string, title: string, metaLine: string, albumType: string): FormattableString { const head = format`${albumEmoji(albumType)} ${bold(`${artist} — ${title}`)}`; @@ -29,7 +29,7 @@ export async function startUpload( /** им. падеж, он же вин.: «альбом» / «плейлист» — для «⏳ ищу …». */ noun: string, notFoundText: FormattableString, - fetch: (yandex: YandexClient) => Promise<AlbumData | null>, + fetch: (music: MusicClient) => Promise<AlbumData | null>, ): Promise<void> { const [allowed, retryAfter] = container.downloadLimiter.check(userId); if (!allowed) { @@ -59,8 +59,8 @@ export async function startUpload( const progress = await bot.api.sendMessage({ chat_id: chatId, text: format`⏳ ищу ${noun}...` }); const progressMsg = new ProgressMessage(bot, chatId, progress.message_id); - const yandex = await container.getYandexService(token, userId); - const album = await fetch(yandex); + const music = await container.getMusicService(token, userId); + const album = await fetch(music); if (!album) { await progressMsg.edit(notFoundText); @@ -90,7 +90,7 @@ export async function startUpload( userId, albumData: album, progressMsg, - yandex, + music, coverBytes, }); } finally { @@ -103,11 +103,11 @@ export async function startAlbumUpload( bot: Bot, chatId: number, userId: number, - albumId: number, + albumId: string, container: Container, ): Promise<void> { - await startUpload(bot, chatId, userId, container, "альбом", format`❌ альбом не найден или недоступен`, (yandex) => - yandex.getAlbum(albumId), + await startUpload(bot, chatId, userId, container, "альбом", format`❌ альбом не найден или недоступен`, (music) => + music.getAlbum(albumId), ); } @@ -116,7 +116,7 @@ export function registerAlbums(bot: Bot, container: Container): void { if (ctx.chat?.type !== "private") return; const m = ALBUM_URL_RE.exec(ctx.text ?? ""); if (!m) return; - await startAlbumUpload(bot, ctx.chat.id, ctx.from!.id, Number(m[1]), container); + await startAlbumUpload(bot, ctx.chat.id, ctx.from!.id, m[1]!, container); }); bot.callbackQuery("cancel_album", async (ctx) => { @@ -165,7 +165,7 @@ export function registerAlbums(bot: Bot, container: Container): void { tracks: buf.tracks, albumData: buf.albumData, progressMsg, - yandex: buf.yandex, + music: buf.music, }); await ctx.answer(started ? "повторяю..." : { text: "у тебя уже идёт выгрузка", show_alert: true }); }); diff --git a/src/bot/handlers/common.ts b/src/bot/handlers/common.ts index cee60be..0f0a473 100644 --- a/src/bot/handlers/common.ts +++ b/src/bot/handlers/common.ts @@ -2,10 +2,10 @@ import { type DialogManager, StartMode } from "@gramio/dialogs"; import type { Bot } from "gramio"; import { blockquote, bold, code, type FormattableString, format, MediaUpload } from "gramio"; import { getLogger } from "../../infra/logging.ts"; -import { normalizeCoverUrl } from "../../yandex/media.ts"; -import { buildCardMeta, formatTrackBasics } from "../../yandex/metadata.ts"; -import type { YaTrack } from "../../yandex/types.ts"; -import type { CurrentTrack } from "../../yandex/ynison.ts"; +import { normalizeCoverUrl } from "../../music/media.ts"; +import { buildCardMeta, formatTrackBasics } from "../../music/metadata.ts"; +import type { Track } from "../../music/types.ts"; +import type { CurrentTrack } from "../../music/watcher.ts"; import { senderHandleOf } from "../captions.ts"; import { buildCardImage } from "../cardBuilder.ts"; import type { Container } from "../container.ts"; @@ -23,12 +23,12 @@ async function startMenu(ctx: unknown, state: string): Promise<void> { const log = getLogger("bot.common"); -const START_ALBUM_RE = /^album_(\d+)$/; -const START_TRACK_RE = /^track_(\d+)$/; +const START_ALBUM_RE = /^album_([A-Za-z0-9]{22})$/; +const START_TRACK_RE = /^track_([A-Za-z0-9]{22})$/; // owner сам может содержать '_', поэтому kind — хвостовой _\d+ (жадный owner // отдаёт ровно последнюю группу цифр). Точка в owner в payload не пролезает — // телеграм разрешает только [A-Za-z0-9_-] (см. createPlaylistResult). -export const START_PLAYLIST_RE = /^playlist_([\w-]+)_(\d+)$/; +export const START_PLAYLIST_RE = /^playlist_([A-Za-z0-9]{22})$/; export interface TgUser { id: number; @@ -92,8 +92,8 @@ async function currentTrackOf( ): Promise<Pick<CurrentTrack, "track" | "paused" | "duration_ms" | "progress_ms"> | null> { const watcher = container.npWatcher; if (watcher !== null && watcher.ownerId === user.id) return watcher.getCurrentSnapshot(); - const yandex = await container.getYandexService(token, user.id); - return yandex.getCurrentTrack(); + const music = await container.getMusicService(token, user.id); + return music.getCurrentTrack(); } /** лимит — общий с /np. */ @@ -164,7 +164,7 @@ export async function sendCurrentTrack( const token = await checkLimitAndToken(bot, chatId, user, container, statusMsgId); if (!token) return; - let track: YaTrack | null | undefined; + let track: Track | null | undefined; try { track = (await currentTrackOf(container, user, token))?.track; } catch (e) { @@ -204,7 +204,7 @@ export function registerCommon(bot: Bot, container: Container): void { if (args) { const ma = START_ALBUM_RE.exec(args); if (ma) { - await startAlbumUpload(bot, ctx.chat.id, user.id, Number(ma[1]), container); + await startAlbumUpload(bot, ctx.chat.id, user.id, ma[1]!, container); return; } const mt = START_TRACK_RE.exec(args); @@ -214,7 +214,7 @@ export function registerCommon(bot: Bot, container: Container): void { } const mp = START_PLAYLIST_RE.exec(args); if (mp) { - await startPlaylistUpload(bot, ctx.chat.id, user.id, mp[1]!, Number(mp[2]), container); + await startPlaylistUpload(bot, ctx.chat.id, user.id, "", mp[1]!, container); return; } } diff --git a/src/bot/handlers/inline.ts b/src/bot/handlers/inline.ts index 214e0c8..0f32aa0 100644 --- a/src/bot/handlers/inline.ts +++ b/src/bot/handlers/inline.ts @@ -6,7 +6,7 @@ import { parseSearchQuery } from "../../infra/parsers.ts"; import type { InlineResult, SearchService } from "../../services/search.ts"; import { getSettings } from "../../settings.ts"; import type { CachedTrack } from "../../storage/types.ts"; -import { makeEncrawDecrypt, type YandexClient } from "../../yandex/client.ts"; +import type { MusicClient } from "../../music/client.ts"; import { type AudioQuality, buildTrackMetadata, @@ -15,8 +15,8 @@ import { formatDurationHuman, formatTrackBasics, trackIsTooLong, -} from "../../yandex/metadata.ts"; -import type { YaTrack } from "../../yandex/types.ts"; +} from "../../music/metadata.ts"; +import type { Track } from "../../music/types.ts"; import { buildLyricsQuote, buildTrackCaption } from "../captions.ts"; import type { Container } from "../container.ts"; import { cbData } from "../ctxutil.ts"; @@ -68,22 +68,22 @@ function stripParen(s: string): string { return s.replace(/\s*\([^)]*\)\s*$/, "").trim(); } -type ResolveResult = { cached: CachedTrack | null; trackObj: YaTrack | null; yandex: YandexClient }; +type ResolveResult = { cached: CachedTrack | null; trackObj: Track | null; music: MusicClient }; -/** lossless — отдельный тир кэша; переданный lossy-`cached` для него не годится. */ +/** У Spotify нет lossless-варианта — переданный cached годится для любой quality. */ async function resolveCacheEntry( - container: Container, - trackId: string, - quality: AudioQuality, + _container: Container, + _trackId: string, + _quality: AudioQuality, cached: CachedTrack | null, ): Promise<CachedTrack | null> { - return quality === "lossless" ? await container.cacheDb.get(trackId, "lossless") : cached; + return cached; } -/** null, если result_id не сводится к числовому trackId. */ +/** null, если result_id не сводится к валидному Spotify track id (22 base62). */ export function parseTrackResultId(resultId: string): string | null { const id = resultId.startsWith("tm:") ? resultId.slice(3) : resultId; - return /^\d+$/.test(id) ? id : null; + return /^[A-Za-z0-9]{22}$/.test(id) ? id : null; } /** InputMediaAudio для editMessageMedia + reply_markup под выбранный layout. */ @@ -124,110 +124,70 @@ async function resolveTrack( token: string, userId: number, trackId: string, - trackObj: YaTrack | null = null, + trackObj: Track | null = null, ): Promise<ResolveResult> { const trackService = await container.getTrackService(token, userId); - const yandex = await container.getYandexService(token, userId); + const music = await container.getMusicService(token, userId); let obj = trackObj ?? trackService.getTrackObject(trackId); let cached: CachedTrack | null; if (obj !== null) { cached = await container.cacheDb.get(trackId); } else { - [cached, obj] = await Promise.all([container.cacheDb.get(trackId), yandex.getTrackById(Number(trackId))]); + [cached, obj] = await Promise.all([container.cacheDb.get(trackId), music.getTrackById(trackId)]); if (obj) trackService.cacheTrackObject(obj); } - return { cached, trackObj: obj, yandex }; -} - -/** lossless: urls зашифрованы, требуют AES-CTR-расшифровку; кэш под своим тиром. */ -async function downloadFreshTrackLossless( - container: Container, - trackObj: YaTrack, - yandex: YandexClient, - trackId: string, -): Promise<[boolean, string | null, string | null, number | null]> { - const [metadata, info] = await Promise.all([ - enrichMetadataFromAlbum(buildTrackMetadata(trackObj), trackObj, yandex), - yandex.getLosslessDownload(trackObj), - ]); - if (info === null) return [false, null, null, null]; - const meta = { ...metadata, codec: info.codec, bitrate_kbps: info.bitrate }; - // каждая racing-попытка берёт случайный url из списка — естественный спред по CDN. - const urls = info.links(); - const pickUrl = async (): Promise<string | null> => urls[Math.floor(Math.random() * urls.length)] ?? null; - const decrypt = makeEncrawDecrypt(info.key); - const fileId = await container.cacheService.downloadAndCacheTrack( - trackId, - pickUrl, - meta, - info.codec, - decrypt, - "lossless", - ); - return [true, fileId, info.codec ?? null, info.bitrate ?? null]; + return { cached, trackObj: obj, music }; } /** возвращает [infoFound, fileId, codec, bitrateKbps]. */ async function downloadFreshTrackBest( container: Container, - trackObj: YaTrack, - yandex: YandexClient, + trackObj: Track, + music: MusicClient, trackId: string, quality: AudioQuality, ): Promise<[boolean, string | null, string | null, number | null]> { const [metadata, info] = await Promise.all([ - enrichMetadataFromAlbum(buildTrackMetadata(trackObj), trackObj, yandex), - yandex.getBestDownloadInfo(trackObj, quality), + enrichMetadataFromAlbum(buildTrackMetadata(trackObj), trackObj, music), + music.getBestDownloadInfo(trackObj, quality), ]); if (info === null) return [false, null, null, null]; const meta = { ...metadata, codec: info.codec, bitrate_kbps: info.bitrateInKbps }; const fileId = await container.cacheService.downloadAndCacheTrack( trackId, - () => yandex.directLinkFromInfo(info), + () => music.directLinkFromInfo(info), meta, info.codec, ); return [true, fileId, info.codec, info.bitrateInKbps]; } -/** degraded=true — запрошен lossless, но он не отдался, молча отдали lossy. */ +/** degraded зарезервировано для будущих downgrade-путей — сейчас всегда false. */ async function downloadFreshTrack( container: Container, - trackObj: YaTrack, - yandex: YandexClient, + trackObj: Track, + music: MusicClient, trackId: string, quality: AudioQuality = "best", ): Promise<[boolean, string | null, string | null, number | null, boolean]> { - if (quality === "lossless") { - try { - const r = await downloadFreshTrackLossless(container, trackObj, yandex, trackId); - if (r[1]) return [r[0], r[1], r[2], r[3], false]; - log.warning(`[lossless] недоступен track=${trackId} → фолбэк на mp3`); - } catch (e) { - log.warning(`[lossless] сбой track=${trackId}: ${e} → фолбэк на mp3`); - } - // degraded только если фолбэк реально удался - const fb = await downloadFreshTrackBest(container, trackObj, yandex, trackId, "best"); - return [fb[0], fb[1], fb[2], fb[3], fb[1] !== null]; - } - const r = await downloadFreshTrackBest(container, trackObj, yandex, trackId, quality); + const r = await downloadFreshTrackBest(container, trackObj, music, trackId, quality); return [r[0], r[1], r[2], r[3], false]; } /** качаем lossy-исходник, перекодируем ffmpeg-фильтром, кэш — свой тир per-эффект. */ async function buildEffectTrack( container: Container, - trackObj: YaTrack, - yandex: YandexClient, + trackObj: Track, + music: MusicClient, trackId: string, effect: AudioEffect, tier: string, ): Promise<string | null> { // кэш этого тира проверил ensureFileId — сюда доходим только на промахе const [metadata, info] = await Promise.all([ - enrichMetadataFromAlbum(buildTrackMetadata(trackObj), trackObj, yandex), - yandex.getBestDownloadInfo(trackObj, "best"), + enrichMetadataFromAlbum(buildTrackMetadata(trackObj), trackObj, music), + music.getBestDownloadInfo(trackObj, "best"), ]); if (info === null) return null; @@ -240,7 +200,7 @@ async function buildEffectTrack( }; return container.cacheService.downloadAndCacheTrack( trackId, - () => yandex.directLinkFromInfo(info), + () => music.directLinkFromInfo(info), meta, info.codec, undefined, @@ -260,8 +220,8 @@ export type EnsureResult = export async function ensureFileId( container: Container, cached: CachedTrack | null, - trackObj: YaTrack | null, - yandex: YandexClient, + trackObj: Track | null, + music: MusicClient, trackId: string, opts: { quality?: AudioQuality; tier?: string | null; onDownloadStart?: () => Promise<void> } = {}, ): Promise<EnsureResult> { @@ -283,7 +243,7 @@ export async function ensureFileId( if (onDownloadStart) await onDownloadStart(); if (effect) { - const fileId = await buildEffectTrack(container, trackObj, yandex, trackId, effect, tier!); + const fileId = await buildEffectTrack(container, trackObj, music, trackId, effect, tier!); return fileId ? { ok: true, fileId, codec: "mp3", bitrateKbps: EFFECT_BITRATE, degraded: false } : { ok: false, kind: "failed" }; @@ -292,7 +252,7 @@ export async function ensureFileId( const [infoFound, fileId, codec, bitrateKbps, degraded] = await downloadFreshTrack( container, trackObj, - yandex, + music, trackId, quality, ); @@ -308,7 +268,7 @@ async function buildEmptyResults( opts: { layout: string; cardLayout: string; sendMode: string }, signal?: AbortSignal, ): Promise<{ results: unknown[]; nextOffset: string }> { - let current: { track?: YaTrack | null; progress_ms?: number; duration_ms?: number; paused?: boolean } | null = null; + let current: { track?: Track | null; progress_ms?: number; duration_ms?: number; paused?: boolean } | null = null; const watcher = container.npWatcher; if (watcher !== null && watcher.ownerId === user.id) { const snap = watcher.getCurrentSnapshot(); @@ -318,7 +278,7 @@ async function buildEmptyResults( if (snap?.track) trackService.cacheTrackObject(snap.track); } else { try { - current = await trackService.yandexService.getCurrentTrack(); + current = await trackService.musicService.getCurrentTrack(); } catch (e) { log.warning(`[inline] не получил текущий трек: ${e}`); return { results: [], nextOffset: "" }; @@ -363,7 +323,7 @@ export async function sendTrackToChat(args: { userId: number; container: Container; trackId: string; - trackObj?: YaTrack | null; + trackObj?: Track | null; token?: string | null; statusMsgId?: number | null; checkLimit?: boolean; @@ -397,10 +357,10 @@ export async function sendTrackToChat(args: { } try { - const { cached, trackObj, yandex } = await resolveTrack(container, token, userId, trackId, args.trackObj ?? null); + const { cached, trackObj, music } = await resolveTrack(container, token, userId, trackId, args.trackObj ?? null); const quality = (await container.getUserSettings(userId)).track_quality as AudioQuality; - const res = await ensureFileId(container, cached, trackObj, yandex, trackId, { + const res = await ensureFileId(container, cached, trackObj, music, trackId, { quality, onDownloadStart: async () => { if (statusMsgId === null) return; @@ -433,7 +393,7 @@ export async function sendTrackToChat(args: { audio: res.fileId, ...(res.degraded ? { caption: format`⚠️ превосходное недоступно — отдаю оптимальное` } : {}), }); - if (trackObj) void yandex.reportPlayback(trackObj); + if (trackObj) void music.reportPlayback(trackObj); if (statusMsgId !== null) { try { await bot.api.deleteMessage({ chat_id: chatId, message_id: statusMsgId }); @@ -479,9 +439,9 @@ async function fetchLyricsForQuery( if (!raw) { try { const svc = await container.getTrackService(token, userId); - raw = await withTimeout(svc.yandexService.getTrackLyrics(trackId), 6000); + raw = await withTimeout(svc.musicService.getTrackLyrics(trackId), 6000); } catch (e) { - log.warning(`[lyrics/yandex] track=${trackId}: ${e}`); + log.warning(`[lyrics/music] track=${trackId}: ${e}`); } } @@ -587,7 +547,7 @@ async function applySuggestCorrection( }, current: { results: unknown[]; nextOffset: string }, ): Promise<{ results: unknown[]; nextOffset: string }> { - const corrected = await trackService.yandexService.suggestCorrection(opts.searchText).catch(() => null); + const corrected = await trackService.musicService.suggestCorrection(opts.searchText).catch(() => null); if (!corrected || corrected.toLowerCase() === opts.searchText.toLowerCase()) return current; log.info(`[inline/suggest] ${JSON.stringify(opts.searchText)} → ${JSON.stringify(corrected)}`); @@ -736,7 +696,7 @@ async function onInlineQuery( log.warning(`[inline] яндекс-клиент не создан user=${userId}: ${e}`); await ctx.answer([], { cache_time: 3, - button: { text: "⏳ Яндекс не отвечает, попробуй ещё раз", start_parameter: "start" }, + button: { text: "⏳ Spotify не отвечает, попробуй ещё раз", start_parameter: "start" }, }); return; } @@ -904,7 +864,7 @@ async function onChosenResult( searchPhrase = queryText.replace(/^["«]+/, "").trim(); } - const { cached, trackObj, yandex } = await resolveTrack(container, token, userId, trackId); + const { cached, trackObj, music } = await resolveTrack(container, token, userId, trackId); const settings = await container.getUserSettings(userId); const quality = settings.track_quality as AudioQuality; @@ -936,17 +896,16 @@ async function onChosenResult( let res: EnsureResult; if (searchPhrase) { const [ensure, lq] = await Promise.all([ - ensureFileId(container, cached, trackObj, yandex, trackId, ensureOpts), + ensureFileId(container, cached, trackObj, music, trackId, ensureOpts), fetchLyricsForQuery(container, token, userId, trackId, searchPhrase, artist, title), ]); res = ensure; lyricsQuote = lq; } else { - res = await ensureFileId(container, cached, trackObj, yandex, trackId, ensureOpts); + res = await ensureFileId(container, cached, trackObj, music, trackId, ensureOpts); } - // для lossless mp3-кэш не считается «уже на месте» — нужен своп на FLAC - if (quality !== "lossless" && !effect && cached && cached.is_cached && !isTextMedia && !lyricsQuote) { + if (!effect && cached && cached.is_cached && !isTextMedia && !lyricsQuote) { log.info(`[chosen] трек ${trackId} уже в кэше user=${userId}`); // реальное аудио уже в сообщении (не текст-плейсхолдер) — просто чиним // кнопку, если тикер успел подменить её на «качаю...»/«отправляю...» @@ -1029,13 +988,13 @@ async function onLoadCallback( } try { - const { cached, trackObj, yandex } = await resolveTrack(container, token, userId, trackId); + const { cached, trackObj, music } = await resolveTrack(container, token, userId, trackId); const settings = await container.getUserSettings(userId); const quality = settings.track_quality as AudioQuality; // эффект берём из запроса, запомненного на chosen_result: в callback_data его нет, // а «качаю...»/«загрузить снова» обязаны отдать тот же вариант, что просили. const tier = container.inlineEffects.get(inlineMsgId); - const res = await ensureFileId(container, cached, trackObj, yandex, trackId, { quality, tier }); + const res = await ensureFileId(container, cached, trackObj, music, trackId, { quality, tier }); if (!res.ok) { const text = res.kind === "not_found" @@ -1088,9 +1047,9 @@ async function onLikeCallback( } try { - const yandex = await container.getYandexService(token, userId); - const liked = await yandex.isTrackLiked(trackId); - const ok = await yandex.setTrackLiked(trackId, !liked); + const music = await container.getMusicService(token, userId); + const liked = await music.isTrackLiked(trackId); + const ok = await music.setTrackLiked(trackId, !liked); if (!ok) { await safeAnswerCb(ctx, { text: "не вышло, попробуй ещё раз", show_alert: true }); return; diff --git a/src/bot/handlers/inlineCard.ts b/src/bot/handlers/inlineCard.ts index 468ffa5..d4b8773 100644 --- a/src/bot/handlers/inlineCard.ts +++ b/src/bot/handlers/inlineCard.ts @@ -4,10 +4,10 @@ import { floodRetry } from "../../infra/floodRetry.ts"; import { getLogger } from "../../infra/logging.ts"; import { LRUMap } from "../../infra/lruMap.ts"; import type { LyricsBlock } from "../../services/card.ts"; -import { normalizeCoverUrl } from "../../yandex/media.ts"; -import { bestLyricsLineIndex, buildCardMeta, formatTrackBasics, splitLyricsLines } from "../../yandex/metadata.ts"; -import type { CardMeta, YaTrack } from "../../yandex/types.ts"; -import { trackUrl } from "../../yandex/urls.ts"; +import { normalizeCoverUrl } from "../../music/media.ts"; +import { bestLyricsLineIndex, buildCardMeta, formatTrackBasics, splitLyricsLines } from "../../music/metadata.ts"; +import type { CardMeta, Track } from "../../music/types.ts"; +import { trackUrl } from "../../music/urls.ts"; import { senderHandleOf } from "../captions.ts"; import { buildCardImage } from "../cardBuilder.ts"; import type { Container } from "../container.ts"; @@ -51,7 +51,7 @@ export function isCardResultId(resultId: string): boolean { /** artist/title/обложки(две размерности)/handle — общий набор для обоих билдеров карточек. */ export function trackCardBasics( - track: YaTrack, + track: Track, user: { username?: string | null; firstName?: string | null }, ): { artist: string; title: string; coverUrl: string; thumb: string; senderHandle: string } { const [artist, title] = formatTrackBasics(track, ""); @@ -90,7 +90,7 @@ function buildCardArticle(args: { /** лёгкий Article + стэш метаданных. без сетевых вызовов. */ export function buildNowPlayingCardResult( container: Container, - track: YaTrack, + track: Track, current: { progress_ms?: number; duration_ms?: number; paused?: boolean }, user: { id: number; username?: string | null; firstName?: string | null }, cardLayout = "button", @@ -136,7 +136,7 @@ export function buildNowPlayingCardResult( * Текст тянем лениво на chosen (как обложку) — здесь только стэшим фразу. */ export function buildLyricsCardResult( container: Container, - track: YaTrack, + track: Track, lyricPhrase: string, user: { id: number; username?: string | null; firstName?: string | null }, ): ReturnType<typeof InlineQueryResult.article> | null { @@ -227,8 +227,8 @@ async function fetchLyricsWithFallback( } if (token) { try { - const yandex = await container.getYandexService(token, userId); - const raw = await yandex.getTrackLyrics(trackId); + const music = await container.getMusicService(token, userId); + const raw = await music.getTrackLyrics(trackId); if (raw) { log.info(`[chosen] лирика для карточки взята из Яндекса track=${trackId}`); return raw; diff --git a/src/bot/handlers/playlists.ts b/src/bot/handlers/playlists.ts index 1818d3a..409cccb 100644 --- a/src/bot/handlers/playlists.ts +++ b/src/bot/handlers/playlists.ts @@ -7,13 +7,14 @@ import { startUpload } from "./albums.ts"; const log = getLogger("bot.playlists"); -/** зовут и из /start playlist_<owner>_<kind>, и из ссылки в личке. */ +/** зовут и из /start playlist_<id>, и из ссылки в личке. У Spotify owner в + * URL нет — оставлен как пустая строка для совместимости сигнатуры. */ export async function startPlaylistUpload( bot: Bot, chatId: number, userId: number, - owner: string, - kind: number, + _owner: string, + kind: string, container: Container, ): Promise<void> { await startUpload( @@ -23,7 +24,7 @@ export async function startPlaylistUpload( container, "плейлист", format`❌ плейлист не найден, недоступен или пуст`, - (yandex) => yandex.getPlaylist(owner, kind), + (music) => music.getPlaylist("", kind), ); } @@ -32,7 +33,7 @@ export function registerPlaylists(bot: Bot, container: Container): void { if (ctx.chat?.type !== "private") return; const m = PLAYLIST_URL_RE.exec(ctx.text ?? ""); if (!m) return; - await startPlaylistUpload(bot, ctx.chat.id, ctx.from!.id, m[1]!, Number(m[2]), container); + await startPlaylistUpload(bot, ctx.chat.id, ctx.from!.id, "", m[1]!, container); }); log.debug("playlist-хендлеры зарегистрированы"); diff --git a/src/bot/markup.ts b/src/bot/markup.ts index a056534..995c973 100644 --- a/src/bot/markup.ts +++ b/src/bot/markup.ts @@ -12,7 +12,7 @@ * • инлайн-кнопки (трек/карточка) — глиф + иконка вместе: глиф виден всегда, * кастом-иконка подхватится при наличии Fragment-username. */ import { InlineKeyboard } from "gramio"; -import { trackUrl } from "../yandex/urls.ts"; +import { trackUrl } from "../music/urls.ts"; import { iconFor } from "./iconSet.ts"; type BtnStyle = "danger" | "primary" | "success"; diff --git a/src/infra/http.ts b/src/infra/http.ts index d2d30e3..1fcb8eb 100644 --- a/src/infra/http.ts +++ b/src/infra/http.ts @@ -4,6 +4,8 @@ * - request() — общий keep-alive пул для обложек, api, retrieve; * - downloadAudio() — свежее TCP-соединение на каждый запрос (без keepalive), * CDN яндекса из EU иногда виснет на keepalive. */ +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import { Agent, type Dispatcher, interceptors, request } from "undici"; import { getSettings } from "../settings.ts"; import { sleep } from "./async.ts"; @@ -64,8 +66,20 @@ export class HttpClient { } /** скачать аудио: свежий agent (force_close) на каждую попытку + ретраи. - * signal — для racing-загрузки: отменяет проигравшие попытки. */ + * signal — для racing-загрузки: отменяет проигравшие попытки. + * + * file:// URL читаются напрямую с диска — используется для yt-dlp резолвера: + * getUrl() возвращает file://<путь_к_локальному_temp_файлу>, downloadAudio + * читает файл в буфер, дальше пайплайн (теги/заливка в канал) работает как + * с обычной HTTP-загрузкой. */ async downloadAudio(url: string, maxRetries = 3, signal?: AbortSignal): Promise<Buffer> { + if (signal?.aborted) throw new Error("aborted"); + if (url.startsWith("file://")) { + const path = fileURLToPath(url); + const data = await readFile(path); + log.debug(`аудио с диска ${(data.length / 1024).toFixed(1)} KB: ${path}`); + return data; + } let lastError: unknown = null; for (let attempt = 1; attempt <= maxRetries; attempt++) { diff --git a/src/infra/parsers.ts b/src/infra/parsers.ts index 6d470fc..9cee6e0 100644 --- a/src/infra/parsers.ts +++ b/src/infra/parsers.ts @@ -1,28 +1,23 @@ -/** парсинг inline-запросов: текст, ссылка на трек или ссылка на альбом. */ +/** парсинг inline-запросов: текст, ссылка на трек, альбом или плейлист Spotify. */ -const TRACK_URL_RE = /(?:music\.yandex(?:\.ru)?\/album\/\d+\/track|yandex\.ru\/music\/album\/\d+\/track)\/(\d+)/; - -// альбомный URL — без /track. (?!\d) не даёт \d+ бэктрекнуться на цифру короче -// (иначе на .../album/123456/tracks lookahead после отката пропускал бы "12345"). -const ALBUM_URL_RE = /(?:music\.yandex(?:\.ru)?\/album|yandex\.ru\/music\/album)\/(\d+)(?!\d)(?!\/track\/\d)/; - -// owner — логин/uid Яндекса, реальный чарсет [A-Za-z0-9._-]; более широкий -// класс ([^/\s]+) пропускал бы ?/#/пробелоподобное внутрь URL, который -// собирается конкатенацией в lib.usersPlaylists() (client.ts.getPlaylist). -export const PLAYLIST_URL_RE = /music\.yandex(?:\.ru)?\/users\/([\w.-]+)\/playlists\/(\d+)/; +// Spotify id — 22 base62-символа. Принимаем как open.spotify.com/track/<id>, так +// и spotify:track:<id> (schemeless URI). +const TRACK_URL_RE = /(?:open\.spotify\.com\/(?:intl-[a-z]+\/)?track\/|spotify:track:)([A-Za-z0-9]{22})/; +const ALBUM_URL_RE = /(?:open\.spotify\.com\/(?:intl-[a-z]+\/)?album\/|spotify:album:)([A-Za-z0-9]{22})/; +/** плейлист: open.spotify.com/playlist/<id> — owner в URL нет (Spotify перенёс + * его в API), но интерфейс parsedQuery остаётся с owner для совместимости. */ +export const PLAYLIST_URL_RE = /(?:open\.spotify\.com\/(?:intl-[a-z]+\/)?playlist\/|spotify:playlist:)([A-Za-z0-9]{22})/; export type ParsedQuery = | { type: "empty" } - | { type: "track_link"; track_id: number } - | { type: "album_link"; album_id: number } - | { type: "playlist_link"; owner: string; kind: number } + | { type: "track_link"; track_id: string } + | { type: "album_link"; album_id: string } + | { type: "playlist_link"; owner: string; kind: string } | { type: "search"; content: string }; -function extractId(query: string, re: RegExp): number | null { +function extractId(query: string, re: RegExp): string | null { const m = re.exec(query); - if (!m || m[1] === undefined) return null; - const id = parseInt(m[1], 10); - return Number.isFinite(id) && id > 0 ? id : null; + return m?.[1] ?? null; } export function parseSearchQuery(query: string): ParsedQuery { @@ -36,10 +31,7 @@ export function parseSearchQuery(query: string): ParsedQuery { if (albumId) return { type: "album_link", album_id: albumId }; const pl = PLAYLIST_URL_RE.exec(q); - if (pl) { - const kind = parseInt(pl[2]!, 10); - if (Number.isFinite(kind) && kind > 0) return { type: "playlist_link", owner: pl[1]!, kind }; - } + if (pl) return { type: "playlist_link", owner: "", kind: pl[1]! }; return { type: "search", content: q }; } diff --git a/src/main.ts b/src/main.ts index f0ca497..1f14be8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -130,7 +130,7 @@ async function main(): Promise<void> { usersDb: container.usersDb, cacheService: container.cacheService, resolveToken: (uid) => container.resolveToken(uid), - getYandexClient: (token, uid) => container.getYandexService(token, uid), + getMusicClient: (token, uid) => container.getMusicService(token, uid), }); staleRefresher.start(); @@ -151,7 +151,7 @@ async function main(): Promise<void> { void container.albumService .resumeInflight({ resolveToken: (uid) => container.resolveToken(uid), - getYandexClient: (token, uid) => container.getYandexService(token, uid), + getMusicClient: (token, uid) => container.getMusicService(token, uid), }) .catch((e) => log.error(`[album_resume] ${e}`)); @@ -219,6 +219,13 @@ async function main(): Promise<void> { return; } + if (req.method === "GET" && url.pathname === "/spotify/callback") { + void handleSpotifyCallback(req, res, url, container).catch((e) => + log.error(`[spotify/callback] ошибка: ${e}`), + ); + return; + } + if (req.method === "GET" && url.pathname === "/health") { void handleHealth(res, container, npWatcher); return; @@ -437,10 +444,10 @@ function handleMetrics(res: ServerResponse, container: Container, watcher: NowPl } const w = watcher?.health() ?? { running: false, connected: false, lastStateAgeMs: null, hasTrack: false }; - gauge("nowym_watcher_running", "ynison watcher: process running (1/0)", w.running ? 1 : 0); - gauge("nowym_watcher_connected", "ynison watcher: ws connected (1/0)", w.connected ? 1 : 0); + gauge("nowym_watcher_running", "now-playing watcher: process running (1/0)", w.running ? 1 : 0); + gauge("nowym_watcher_connected", "now-playing watcher: reachable (1/0)", w.connected ? 1 : 0); if (w.lastStateAgeMs !== null) { - gauge("nowym_watcher_last_state_age_ms", "ynison watcher: ms since last state frame", w.lastStateAgeMs); + gauge("nowym_watcher_last_state_age_ms", "now-playing watcher: ms since last poll", w.lastStateAgeMs); } for (const [name, value] of counterSnapshot()) { @@ -451,6 +458,48 @@ function handleMetrics(res: ServerResponse, container: Container, watcher: NowPl res.end(`${lines.join("\n")}\n`); } +/** Spotify OAuth-callback: /spotify/callback?code=...&state=... + * Ищем активный auth-флоу по state, обмениваем code на токен, отвечаем HTML- + * страницей "готово, возвращайся в Telegram". Юзер видит успех в чате + * благодаря onSuccess-хуку, зарегистрированному в диалоге логина. */ +async function handleSpotifyCallback( + _req: IncomingMessage, + res: ServerResponse, + url: URL, + container: Container, +): Promise<void> { + const err = url.searchParams.get("error"); + if (err) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthPage(`Ошибка: ${err}`, "Возвращайся в Telegram и попробуй ещё раз.")); + return; + } + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + if (!code || !state) { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthPage("Пустой callback", "Не хватает code или state.")); + return; + } + const { ok } = await container.authService.completeAuthByCode(state, code); + if (ok) { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthPage("Готово!", "Возвращайся в Telegram — бот уже видит авторизацию.")); + } else { + res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" }); + res.end(oauthPage("Не сработало", "Флоу авторизации не найден или уже завершился.")); + } +} + +function oauthPage(title: string, sub: string): string { + const t = title.replace(/[<>&]/g, ""); + const s = sub.replace(/[<>&]/g, ""); + return `<!doctype html><meta charset="utf-8"><title>${t} + +

${t}

${s}

`; +} + main().catch((e) => { log.error(`фатальная ошибка: ${e}`); process.exit(1); diff --git a/src/music/client.ts b/src/music/client.ts new file mode 100644 index 0000000..8885c2c --- /dev/null +++ b/src/music/client.ts @@ -0,0 +1,711 @@ +/** Высокоуровневый клиент музыкального источника. Внутри — Spotify Web API + * для метаданных/поиска/лайков/плеера, yt-dlp — для реального аудио. */ +import { pathToFileURL } from "node:url"; +import { performance } from "node:perf_hooks"; +import { removeTempFile, resolveAndDownload } from "../audio/ytdlp.ts"; +import { sleep, withTimeout } from "../infra/async.ts"; +import { HttpClient } from "../infra/http.ts"; +import { getLogger } from "../infra/logging.ts"; +import { LRUMap } from "../infra/lruMap.ts"; +import { AsyncTokenGate } from "../infra/rateLimit.ts"; +import { getSettings } from "../settings.ts"; +import { normalizeCoverUrl } from "./media.ts"; +import { type AudioQuality, bareTrackId, formatArtists } from "./metadata.ts"; +import type { AlbumShortProvider, LikeProvider, WaveColorProvider } from "./metadata.ts"; +import type { Album, AlbumData, Artist, DownloadInfo, Track } from "./types.ts"; +import type { CurrentTrack } from "./watcher.ts"; + +const log = getLogger("music.client"); + +/** async () → новый access_token или null. */ +export type RefreshCallback = () => Promise; + +const SPOTIFY_API = "https://api.spotify.com/v1"; +const NP_CACHE_TTL_MS = 5_000; +const NP_FETCH_TIMEOUT_MS = 4_000; +const LIKES_CACHE_TTL_MS = 30_000; +// сколько API-вызовов подряд можно сделать без rate-задержки (интерактивный burst) +const RATE_BURST = 5; + +/** Оставлено для совместимости — encraw был у Яндекс.Музыки (FLAC-CTR). Для + * Spotify + yt-dlp расшифровка не нужна. Всегда undefined. */ +export function makeEncrawDecrypt(_key: string | null | undefined): ((data: Buffer) => Buffer) | undefined { + return undefined; +} + +class UnauthorizedError extends Error {} +class RateLimitError extends Error { + constructor(public retryAfterMs: number) { + super(`429 (retry after ${retryAfterMs}ms)`); + } +} + +interface SpotifyImage { + url: string; + width?: number; + height?: number; +} + +interface SpotifyArtist { + id: string; + name: string; +} + +interface SpotifyAlbumMini { + id: string; + name: string; + album_type?: string; + release_date?: string; + total_tracks?: number; + images?: SpotifyImage[]; + artists?: SpotifyArtist[]; +} + +interface SpotifyAlbumFull extends SpotifyAlbumMini { + copyrights?: Array<{ text?: string; type?: string }>; + label?: string; + genres?: string[]; + tracks?: { items: SpotifyTrack[]; next?: string | null }; +} + +interface SpotifyTrack { + id: string; + name: string; + duration_ms: number; + explicit: boolean; + artists: SpotifyArtist[]; + album?: SpotifyAlbumMini; + track_number?: number; + disc_number?: number; + external_ids?: { isrc?: string }; + is_playable?: boolean; + available_markets?: string[]; +} + +interface SpotifyPlaylistMeta { + id: string; + name: string; + images?: SpotifyImage[]; + tracks?: { total?: number }; + owner?: { id?: string; display_name?: string }; +} + +interface SpotifySearchResponse { + tracks?: { items: SpotifyTrack[]; total: number; limit: number; offset: number }; + albums?: { items: SpotifyAlbumMini[]; total: number }; +} + +// ── маппинг Spotify → наши доменные типы ────────────────────────────────── + +function pickCover(images: SpotifyImage[] | undefined): string { + if (!images || images.length === 0) return ""; + // Spotify отдаёт по убыванию размера. Средний размер (300) хорош для карточки/тегов. + const mid = images.find((i) => (i.width ?? 0) >= 300 && (i.width ?? 0) <= 640); + return mid?.url || images[0]!.url; +} + +function mapArtists(list: SpotifyArtist[] | undefined): Artist[] { + return (list ?? []).map((a) => ({ id: a.id, name: a.name })); +} + +function yearFromReleaseDate(rd: string | undefined): string { + if (!rd) return ""; + const m = /^(\d{4})/.exec(rd); + return m ? m[1]! : ""; +} + +function mapAlbumMini(a: SpotifyAlbumMini | undefined): Album | null { + if (!a) return null; + return { + id: a.id, + title: a.name, + year: yearFromReleaseDate(a.release_date), + trackCount: a.total_tracks ?? null, + type: a.album_type ?? null, + coverUri: pickCover(a.images), + artists: mapArtists(a.artists), + available: true, + }; +} + +function mapAlbumFull(a: SpotifyAlbumFull): Album { + const mini = mapAlbumMini(a)!; + const labels = a.label ? [a.label] : (a.copyrights ?? []).map((c) => c.text ?? "").filter(Boolean); + const genre = a.genres && a.genres.length > 0 ? a.genres[0]! : null; + return { ...mini, labels, genre }; +} + +function mapTrack(t: SpotifyTrack): Track { + return { + id: t.id, + title: t.name, + available: t.is_playable ?? true, + durationMs: t.duration_ms, + explicit: t.explicit, + contentWarning: t.explicit ? "explicit" : null, + artists: mapArtists(t.artists), + albums: t.album ? [mapAlbumMini(t.album)!] : [], + coverUri: pickCover(t.album?.images), + isrc: t.external_ids?.isrc ?? null, + trackPosition: { + index: t.track_number ?? null, + volume: t.disc_number ?? null, + }, + }; +} + +// ── клиент ──────────────────────────────────────────────────────────────── + +export class MusicClient implements WaveColorProvider, LikeProvider, AlbumShortProvider { + private token: string; + private readonly http = new HttpClient(); + private readonly gate: AsyncTokenGate; + private readonly maxRetries: number; + private readonly refreshCb: RefreshCallback | null; + + // дедуп параллельных get_current_track + private npInflight: Promise | null = null; + private npCache: CurrentTrack | null = null; + private npCacheAt = 0; + // кэш set лайкнутых трек-id с TTL + private likesCache: Set | null = null; + private likesCacheAt = 0; + // кэш готовых yt-dlp DownloadInfo (файл уже на диске) — трек ID → info. + // Освобождается TTL-sweeper'ом ytdlp.ts (файл удаляется через 15 мин). + private downloadInfoCache = new LRUMap(200); + + constructor(token: string, refreshCallback: RefreshCallback | null = null) { + if (!token || typeof token !== "string") throw new Error("токен должен быть непустой строкой"); + this.token = token; + const s = getSettings(); + this.gate = new AsyncTokenGate(RATE_BURST, s.SPOTIFY_RATE_LIMIT_INTERVAL * 1000); + this.maxRetries = s.SPOTIFY_MAX_RETRIES; + this.refreshCb = refreshCallback; + } + + /** явная инициализация — валидация токена через /me. */ + async init(): Promise { + await this.spotifyFetch("/me"); + } + + private async spotifyFetch( + pathOrUrl: string, + opts: { method?: "GET" | "POST" | "PUT" | "DELETE"; body?: unknown } = {}, + ): Promise { + const url = pathOrUrl.startsWith("http") ? pathOrUrl : `${SPOTIFY_API}${pathOrUrl}`; + const res = await this.http.request(url, { + method: opts.method ?? "GET", + headers: { + Authorization: `Bearer ${this.token}`, + "Content-Type": "application/json", + }, + body: opts.body === undefined ? undefined : JSON.stringify(opts.body), + }); + if (res.status === 401) { + await res.body.dump(); + throw new UnauthorizedError("Spotify 401"); + } + if (res.status === 429) { + const retry = parseInt(String(res.headers["retry-after"] ?? "1"), 10); + await res.body.dump(); + throw new RateLimitError(Math.max(retry, 1) * 1000); + } + if (res.status === 204 || res.status === 202) { + await res.body.dump(); + return {} as T; + } + if (res.status >= 400) { + const text = await res.body.text().catch(() => ""); + throw new Error(`Spotify HTTP ${res.status}: ${text.slice(0, 200)}`); + } + if (opts.method === "PUT" || opts.method === "DELETE") { + await res.body.dump(); + return {} as T; + } + return (await res.body.json()) as T; + } + + private async tryRefreshToken(): Promise { + if (this.refreshCb === null) return false; + let newToken: string | null; + try { + newToken = await this.refreshCb(); + } catch (e) { + log.warning(`refresh_callback бросил: ${e}`); + return false; + } + if (!newToken) return false; + this.token = newToken; + log.info("токен обновлён реактивно после 401"); + return true; + } + + private async withRetry(func: () => Promise, baseDelayMs = 2000): Promise { + let lastError: unknown = null; + let refreshTried = false; + + for (let attempt = 0; attempt <= this.maxRetries; attempt++) { + try { + await this.gate.acquire(); + return await func(); + } catch (e) { + if (e instanceof UnauthorizedError && !refreshTried) { + lastError = e; + if (await this.tryRefreshToken()) { + refreshTried = true; + continue; + } + throw e; + } + if (e instanceof RateLimitError && attempt < this.maxRetries) { + const delay = e.retryAfterMs + Math.random() * 500; + log.warning(`rate limit, попытка ${attempt + 1}/${this.maxRetries + 1}, ждём ${(delay / 1000).toFixed(1)}с`); + await sleep(delay); + lastError = e; + } else { + throw e; + } + } + } + throw new Error(`превышено число попыток: ${lastError}`); + } + + private async withApi( + label: string, + fallback: T, + func: () => Promise, + level: "error" | "warning" = "error", + ): Promise { + try { + return await this.withRetry(func); + } catch (e) { + log[level](`ошибка ${label}: ${e}`); + return fallback; + } + } + + async getTrackById(trackId: number | string): Promise { + return this.withApi(`getTrackById(${trackId})`, null, async () => { + const t = await this.spotifyFetch(`/tracks/${bareTrackId(trackId)}`); + return mapTrack(t); + }); + } + + async searchTracks(query: string, limit?: number): Promise { + const lim = limit ?? getSettings().MAX_SEARCH_RESULTS; + return this.withApi(`searchTracks(${JSON.stringify(query)})`, [], async () => { + const r = await this.spotifyFetch( + `/search?q=${encodeURIComponent(query)}&type=track&limit=${lim}`, + ); + return (r.tracks?.items ?? []).map(mapTrack); + }); + } + + /** пагинация: Spotify limit=50 max, offset — сдвиг от начала. */ + async searchTracksPage( + query: string, + page = 0, + ): Promise<{ tracks: Track[]; total: number; perPage: number }> { + const perPage = 20; + const offset = page * perPage; + return this.withApi( + `searchTracksPage(${JSON.stringify(query)}, ${page})`, + { tracks: [], total: 0, perPage: 0 }, + async () => { + const r = await this.spotifyFetch( + `/search?q=${encodeURIComponent(query)}&type=track&limit=${perPage}&offset=${offset}`, + ); + return { + tracks: (r.tracks?.items ?? []).map(mapTrack), + total: r.tracks?.total ?? 0, + perPage, + }; + }, + ); + } + + /** У Spotify нет suggest — всегда null (проверка проглатывается вызывающим). */ + async suggestCorrection(_query: string): Promise { + return null; + } + + async getAlbumShort(albumId: number | string): Promise { + return this.withApi(`getAlbumShort(${albumId})`, null, async () => { + const a = await this.spotifyFetch(`/albums/${bareTrackId(albumId)}`); + return mapAlbumFull(a); + }); + } + + /** Совместимость — у Spotify своего wave-цвета нет. */ + async getAlbumWaveColor(_albumId: number | string): Promise { + return null; + } + + async getLikedTrackIds(): Promise> { + const now = performance.now(); + if (this.likesCache !== null && now - this.likesCacheAt < LIKES_CACHE_TTL_MS) { + return this.likesCache; + } + // Spotify: /me/tracks — постраничный список сохранённых. + // Тянем первые 200 (достаточно для лайк-статуса свежих треков; полная + // библиотека может быть тысячи, но она не нужна для локальной проверки — + // фолбэк через /me/tracks/contains ниже). + try { + const ids = new Set(); + let url: string | null = "/me/tracks?limit=50"; + let pages = 0; + while (url && pages < 4) { + const r: { items?: Array<{ track?: { id?: string } }>; next?: string | null } = + await this.withRetry(() => + this.spotifyFetch<{ items?: Array<{ track?: { id?: string } }>; next?: string | null }>(url!), + ); + for (const item of r.items ?? []) { + if (item.track?.id) ids.add(item.track.id); + } + url = r.next ?? null; + pages++; + } + this.likesCache = ids; + this.likesCacheAt = now; + return ids; + } catch (e) { + log.debug(`usersLikesTracks: ${e}`); + return this.likesCache ?? new Set(); + } + } + + async isTrackLiked(trackId: number | string): Promise { + const id = bareTrackId(trackId); + // Локальная проверка по кэшу — быстро для «свежих» треков. + if (this.likesCache !== null) return this.likesCache.has(id); + // Точечно — /me/tracks/contains?ids=. + try { + const r = await this.withRetry(() => this.spotifyFetch(`/me/tracks/contains?ids=${id}`)); + return r[0] ?? false; + } catch (e) { + log.debug(`isTrackLiked(${id}): ${e}`); + return false; + } + } + + async setTrackLiked(trackId: number | string, liked: boolean): Promise { + const id = bareTrackId(trackId); + try { + await this.withRetry(() => + this.spotifyFetch(`/me/tracks?ids=${id}`, { method: liked ? "PUT" : "DELETE" }), + ); + if (this.likesCache !== null) { + if (liked) this.likesCache.add(id); + else this.likesCache.delete(id); + } + return true; + } catch (e) { + log.warning(`setTrackLiked(${id}, ${liked}): ${e}`); + return false; + } + } + + /** Spotify не даёт api-эндпоинта «зачесть прослушивание» без активного + * устройства. Оставлено no-op — Spotify сам двигает статистику, когда трек + * проигрывается в клиенте, а не в боте. */ + async reportPlayback(_track: Track): Promise { + // no-op + } + + async getRecentTracks(limit = 12): Promise { + return this.withApi(`getRecentTracks(${limit})`, [], async () => { + const r = await this.spotifyFetch<{ items: Array<{ track: SpotifyTrack }> }>( + `/me/player/recently-played?limit=${Math.min(limit, 50)}`, + ); + // Дедуп по id — recently-played часто повторяет один и тот же трек за сессию. + const seen = new Set(); + const out: Track[] = []; + for (const item of r.items ?? []) { + const t = item.track; + if (!t || seen.has(t.id)) continue; + seen.add(t.id); + out.push(mapTrack(t)); + if (out.length >= limit) break; + } + return out; + }); + } + + /** Spotify не даёт api к тексту песни. Genius (в services) остаётся основным + * источником для лирики; тут всегда null — вызывающая сторона знает. */ + async getTrackLyrics(_trackId: number | string): Promise { + return null; + } + + async getTrackLRC(_trackId: number | string): Promise { + return null; + } + + async searchAlbums(query: string, limit = 3): Promise { + return this.withApi(`searchAlbums(${JSON.stringify(query)})`, [], async () => { + const r = await this.spotifyFetch( + `/search?q=${encodeURIComponent(query)}&type=album&limit=${limit}`, + ); + return (r.albums?.items ?? []).map((a) => mapAlbumMini(a)!).filter((a) => a !== null); + }); + } + + async getCurrentTrack(): Promise { + const now = performance.now(); + if (this.npCache !== null && now - this.npCacheAt < NP_CACHE_TTL_MS) return this.npCache; + if (this.npInflight !== null) return this.npInflight; + + const fetch = async (): Promise => { + return withTimeout(this.fetchCurrentlyPlaying(), NP_FETCH_TIMEOUT_MS, "now-playing"); + }; + this.npInflight = fetch(); + try { + const result = await this.npInflight; + this.npCache = result; + this.npCacheAt = performance.now(); + return result; + } finally { + this.npInflight = null; + } + } + + private async fetchCurrentlyPlaying(): Promise { + return this.withRetry(async () => { + const r = await this.spotifyFetch<{ + is_playing?: boolean; + progress_ms?: number; + item?: SpotifyTrack | null; + context?: { uri?: string; type?: string }; + } | null>("/me/player/currently-playing"); + if (!r || !r.item) { + return { + track: null, + paused: true, + duration_ms: 0, + progress_ms: 0, + entity_id: null, + entity_type: null, + }; + } + return { + track: mapTrack(r.item), + paused: !r.is_playing, + duration_ms: r.item.duration_ms, + progress_ms: r.progress_ms ?? 0, + entity_id: r.context?.uri ?? null, + entity_type: r.context?.type ?? null, + }; + }); + } + + /** «Готовит» аудио к отдаче: yt-dlp резолвит трек, скачивает в temp файл. + * Возвращает DownloadInfo, у которого downloadInfoUrl = file://<путь>. */ + async getBestDownloadInfo(track: Track, quality: AudioQuality = "best"): Promise { + if (!track.id) return null; + const cacheKey = `${track.id}:${quality}`; + const cached = this.downloadInfoCache.get(cacheKey); + if (cached) return cached; + + const artist = formatArtists(track.artists); + const title = (track.title ?? "").trim(); + const resolved = await resolveAndDownload({ + id: bareTrackId(track.id), + artist, + title, + durationSec: track.durationMs ? Math.round(track.durationMs / 1000) : null, + isrc: track.isrc ?? null, + quality, + }); + if (!resolved) return null; + const info: DownloadInfo = { + codec: normalizeCodec(resolved.codec), + bitrateInKbps: resolved.bitrateKbps, + downloadInfoUrl: pathToFileURL(resolved.filePath).href, + direct: true, + }; + this.downloadInfoCache.set(cacheKey, info); + return info; + } + + /** Совместимость: раньше lossless шёл отдельным путём (FLAC + CTR). Через + * yt-dlp его нет — фолбэк на best-качество. */ + async getLosslessDownload(_track: Track): Promise { + return null; + } + + async getDownloadUrl(track: Track, quality: AudioQuality = "best"): Promise { + const info = await this.getBestDownloadInfo(track, quality); + return info ? info.downloadInfoUrl : null; + } + + /** Для yt-dlp сcылка не «протухает» — файл уже на диске. Возвращает как есть. */ + async directLinkFromInfo(info: DownloadInfo): Promise { + return info.downloadInfoUrl ?? null; + } + + /** Полный альбом с треками. Проставляет _trackNumber/_discNumber. */ + async getAlbum(albumId: number | string): Promise { + const id = bareTrackId(albumId); + const albumRaw = await this.withApi(`getAlbum(${id})`, null as SpotifyAlbumFull | null, async () => { + return this.spotifyFetch(`/albums/${id}`); + }); + if (!albumRaw) return null; + + // Догружаем оставшиеся страницы треков (limit=50). + const tracks: SpotifyTrack[] = [...(albumRaw.tracks?.items ?? [])]; + let next = albumRaw.tracks?.next ?? null; + while (next) { + try { + const r = await this.spotifyFetch<{ items: SpotifyTrack[]; next?: string | null }>(next); + tracks.push(...r.items); + next = r.next ?? null; + } catch (e) { + log.warning(`догрузка треков альбома ${id}: ${e}`); + break; + } + } + + const album = mapAlbumFull(albumRaw); + const mapped: Track[] = []; + const volumesMap = new Map(); + for (const st of tracks) { + const t = mapTrack({ ...st, album: albumRaw }); + const discIdx = st.disc_number ?? 1; + const trackIdx = st.track_number ?? mapped.length + 1; + t._trackNumber = trackIdx; + t._discNumber = discIdx; + mapped.push(t); + if (!volumesMap.has(discIdx)) volumesMap.set(discIdx, []); + volumesMap.get(discIdx)!.push(t); + } + if (mapped.length === 0) { + log.warning(`альбом ${id}: пустой список треков`); + return null; + } + + const volumes = [...volumesMap.keys()].sort((a, b) => a - b).map((k) => volumesMap.get(k)!); + const artist = formatArtists(album.artists); + const year = album.year ? String(album.year) : ""; + const labelStr = album.labels && album.labels.length > 0 + ? typeof album.labels[0] === "string" + ? album.labels[0]! + : (album.labels[0]!.name ?? "") + : ""; + const genre = album.genre || ""; + const coverUrl = normalizeCoverUrl(album.coverUri || "", "orig"); + + log.info(`альбом ${id}: «${album.title}» ${artist} [${year}], ${mapped.length} треков`); + + return { + id, + title: album.title || "", + artist, + year, + label: labelStr, + cover_url: coverUrl, + genre, + album_type: album.type || "", + tracks: mapped, + volumes, + }; + } + + /** Шапка плейлиста — /playlists/{id} с полями только шапки. */ + async getPlaylistShort( + _owner: string, + kind: string | number, + ): Promise<{ title: string; coverUri: string; trackCount: number } | null> { + const id = bareTrackId(kind); + return this.withApi(`getPlaylistShort(${id})`, null, async () => { + const p = await this.spotifyFetch( + `/playlists/${id}?fields=id,name,images,tracks.total`, + ); + return { + title: p.name || "", + coverUri: pickCover(p.images), + trackCount: p.tracks?.total ?? 0, + }; + }); + } + + /** Плейлист как AlbumData — переиспользует всю инфру AlbumService. */ + async getPlaylist(_owner: string, kind: string | number): Promise { + const id = bareTrackId(kind); + const meta = await this.withApi(`getPlaylist(${id})`, null as SpotifyPlaylistMeta | null, async () => { + return this.spotifyFetch(`/playlists/${id}?fields=id,name,images,tracks.total`); + }); + if (!meta) return null; + + // Тянем треки постранично (100 max за раз). + const tracks: SpotifyTrack[] = []; + let next: string | null = `/playlists/${id}/tracks?limit=100&fields=items(track(id,name,duration_ms,explicit,artists,album(id,name,release_date,images,total_tracks,album_type,artists),track_number,disc_number,external_ids,is_playable)),next`; + while (next) { + try { + const r: { items: Array<{ track: SpotifyTrack | null }>; next?: string | null } = await this.spotifyFetch(next); + for (const item of r.items ?? []) { + if (item.track) tracks.push(item.track); + } + next = r.next ?? null; + } catch (e) { + log.warning(`догрузка треков плейлиста ${id}: ${e}`); + break; + } + } + return buildPlaylistAlbumData(id, meta.name || "", pickCover(meta.images), tracks.map(mapTrack)); + } + + async close(): Promise { + await this.http.close(); + for (const [, info] of this.downloadInfoCache.entries()) { + void removeTempFile(pathFromFileUrl(info.downloadInfoUrl)).catch(() => {}); + } + } +} + +function pathFromFileUrl(url: string): string { + if (!url.startsWith("file://")) return ""; + return decodeURIComponent(url.slice("file://".length)); +} + +function normalizeCodec(raw: string): string { + const c = raw.toLowerCase(); + if (c.includes("opus")) return "opus"; + if (c.includes("mp3")) return "mp3"; + if (c.includes("aac") || c.includes("mp4a")) return "aac"; + if (c.includes("m4a")) return "aac"; + return c || "opus"; +} + +export function buildPlaylistAlbumData( + kind: string, + title: string, + coverUri: string, + tracks: Track[], +): AlbumData | null { + const available = tracks.filter((t) => t && t.available !== false); + if (available.length === 0) return null; + available.forEach((t, i) => { + t._trackNumber = i + 1; + t._discNumber = 1; + }); + return { + id: kind, + title, + artist: "Плейлист", + year: "", + label: "", + cover_url: coverUri ? normalizeCoverUrl(coverUri, "orig") : "", + genre: "", + album_type: "playlist", + tracks: available, + volumes: [available], + }; +} + +export class MusicClientFactory { + static async create(token: string, refreshCallback: RefreshCallback | null = null): Promise { + const client = new MusicClient(token, refreshCallback); + await client.init(); + return client; + } +} diff --git a/src/yandex/lrc.ts b/src/music/lrc.ts similarity index 63% rename from src/yandex/lrc.ts rename to src/music/lrc.ts index fe86434..183f638 100644 --- a/src/yandex/lrc.ts +++ b/src/music/lrc.ts @@ -1,7 +1,7 @@ /** Парсер LRC-формата (time-synced lyrics). * * Поддерживаемые форматы меток времени: - * [MM:SS.cc] — сотые секунды (наиболее распространён в Яндексе) + * [MM:SS.cc] — сотые секунды * [MM:SS.ccc] — миллисекунды * [MM:SS,ccc] — comma-разделитель (SRT-вариант) * Служебные теги ([ti:...], [ar:...], [al:...], ...) игнорируются. */ @@ -13,7 +13,6 @@ export interface LrcLine { const TIME_RE = /^\[(\d{1,2}):(\d{2})[.,](\d{1,3})\]\s*(.*)/; -/** Разбирает LRC-текст в массив строк с таймкодами. */ export function parseLrc(lrcText: string): LrcLine[] { const out: LrcLine[] = []; for (const raw of lrcText.split(/\r?\n/)) { @@ -22,28 +21,23 @@ export function parseLrc(lrcText: string): LrcLine[] { const min = parseInt(m[1]!, 10); const sec = parseInt(m[2]!, 10); const frac = m[3]!; - // нормализуем дробь к миллисекундам (десятые → ×100, сотые → ×10, миллисекунды → ×1) const fracMs = frac.length === 1 ? parseInt(frac, 10) * 100 : frac.length === 2 ? parseInt(frac, 10) * 10 : parseInt(frac, 10); const timeMs = (min * 60 + sec) * 1000 + fracMs; const text = m[4]!.trim(); out.push({ timeMs, text }); } - // сортируем на случай нарушенного порядка out.sort((a, b) => a.timeMs - b.timeMs); return out; } -/** Убирает пустые строки и системные LRC-метаданные (только [...]). */ export function filterLrcLines(lines: LrcLine[]): LrcLine[] { return lines.filter((l) => { const t = l.text; - // пустая строка или строка-пауза (♪, •, -) → пропускаем return t.length > 0 && !/^[♪•\-–—\s]+$/.test(t); }); } -/** Возвращает true, если текст содержит хотя бы один LRC-таймкод. */ export function isLrc(text: string): boolean { return /\[\d{1,2}:\d{2}[.,]\d{1,3}\]/.test(text); } diff --git a/src/music/media.ts b/src/music/media.ts new file mode 100644 index 0000000..fd4ff58 --- /dev/null +++ b/src/music/media.ts @@ -0,0 +1,34 @@ +/** Обложки Spotify — их URL уже полный (i.scdn.co/image/). */ +import type { HttpClient } from "../infra/http.ts"; +import { getLogger } from "../infra/logging.ts"; + +const log = getLogger("music.media"); + +/** cover_uri у Spotify — сразу полный HTTPS URL от i.scdn.co. size остаётся + * как аргумент для совместимости, но игнорируется — Spotify отдаёт + * фиксированные размеры (640/300/64), первый и берём. */ +export function normalizeCoverUrl(uri: string | null | undefined, _size = "400x400"): string { + if (!uri) return ""; + if (uri.startsWith("//")) return `https:${uri}`; + if (!uri.startsWith("http")) return `https://${uri}`; + return uri; +} + +/** Раньше подменяли размер на /orig для вшивания в файл — у Spotify это тот же + * URL (лучший размер уже в 640×640), возвращаем как есть. */ +export function origCoverUrl(url: string): string { + return url; +} + +export async function fetchCover(url: string, http: HttpClient): Promise { + if (!url) return null; + try { + const res = await http.request(url); + if (res.status === 200) return Buffer.from(await res.body.arrayBuffer()); + await res.body.dump(); + log.warning(`обложка HTTP ${res.status}: ${url}`); + } catch (e) { + log.warning(`не вышло скачать обложку ${url}: ${e}`); + } + return null; +} diff --git a/src/yandex/metadata.ts b/src/music/metadata.ts similarity index 55% rename from src/yandex/metadata.ts rename to src/music/metadata.ts index c1eabad..4010e42 100644 --- a/src/yandex/metadata.ts +++ b/src/music/metadata.ts @@ -1,4 +1,4 @@ -/** TrackMetadata / CardMeta из сырых объектов Yandex. +/** TrackMetadata / CardMeta из сырых объектов Track (Spotify). * * приоритет источников: * 1. albumOverride (контекст альбома при выгрузке) @@ -9,50 +9,48 @@ * year/label/genre. */ import { normalizeCoverUrl } from "./media.ts"; import type { + Album, + Artist, CardColors, CardMeta, + Label, + Track, TrackMetadata, - YaAlbum, - YaArtist, - YaLabel, - YaTrack, - YaTrackPosition, + TrackPosition, } from "./types.ts"; // self-hosted Bot API (лимит файла 2гб) → длинные треки допустимы. Потолок 4.5ч. const MAX_TRACK_DURATION_MS = 4.5 * 60 * 60 * 1000; -// потолок размера файла: качаем+тегируем в RAM (двойной буфер) при 7.8гб всего на машине. +// потолок размера файла в RAM: качаем+тегируем в RAM (двойной буфер) при 7.8гб. // >1.2гб → отказ ДО скачивания, иначе OOM-kill процесса (pm2 max_memory_restart). export const MAX_TRACK_BYTES = 1.2 * 1024 ** 3; // >200мб → «тяжёлый»: строго по одному (heavyLock) + без racing-параллелизма (solo-качка). export const HEAVY_TRACK_BYTES = 200 * 1024 ** 2; -const FALLBACK_KBPS: Record = { flac: 1100, "flac-mp4": 1100, aac: 256, mp3: 320 }; +// yt-dlp по умолчанию отдаёт opus 128-160 kbps (bestaudio с YouTube). Для оценки +// размера — 160, а fallback-и на случай других кодеков. +const FALLBACK_KBPS: Record = { opus: 160, mp3: 320, aac: 256, m4a: 256 }; -/** оценка размера файла по длительности+битрейту (до скачивания). 0 при неизвестной длительности. */ export function estimateTrackBytes(durationSec: number, bitrateKbps?: number | null, codec?: string | null): number { if (!durationSec || durationSec <= 0) return 0; - const codecKey = codec ?? "mp3"; + const codecKey = codec ?? "opus"; const fallbackKbps = Object.hasOwn(FALLBACK_KBPS, codecKey) ? FALLBACK_KBPS[codecKey] : undefined; - const kbps = bitrateKbps && bitrateKbps > 0 ? bitrateKbps : (fallbackKbps ?? 320); + const kbps = bitrateKbps && bitrateKbps > 0 ? bitrateKbps : (fallbackKbps ?? 192); return Math.round(durationSec * kbps * 125 * 1.1); // *125 = *1000/8; *1.1 — запас на контейнер } // минимальные интерфейсы клиента — чтобы metadata не зависела от client.ts -export interface WaveColorProvider { - getAlbumWaveColor(albumId: number): Promise; -} export interface LikeProvider { isTrackLiked(trackId: number | string): Promise; } export interface AlbumShortProvider { - getAlbumShort(albumId: number | string): Promise; + getAlbumShort(albumId: number | string): Promise; } -export function trackIsTooLong(track: YaTrack): boolean { +export function trackIsTooLong(track: Track): boolean { const ms = track.durationMs; - if (!ms) return false; // неизвестно — пропускаем, реальный send ответит сам + if (!ms) return false; return ms > MAX_TRACK_DURATION_MS; } @@ -64,35 +62,31 @@ export function formatDurationHuman(durationMs: number | null | undefined): stri return `${Math.floor(s / 60)}:${pad(s % 60)}`; } -/** "123:456" | 123 → "123" (отбрасываем album-часть составного trackId). */ +/** id как есть — у Spotify не бывает составных ":". */ export function bareTrackId(id: number | string | null | undefined): string { return String(id).split(":")[0]!; } -/** артисты → «Имя, Имя». default если список пуст или у всех нет name. */ -export function formatArtists(artists: YaArtist[] | null | undefined, def = "Unknown"): string { +export function formatArtists(artists: Artist[] | null | undefined, def = "Unknown"): string { if (!artists || artists.length === 0) return def; const names = artists.map((a) => a.name).filter((n): n is string => Boolean(n)); return names.length > 0 ? names.join(", ") : def; } -/** (artist, title) из Track-объекта, без enrichment. */ -export function formatTrackBasics(track: YaTrack, defaultTitle = "—"): [string, string] { +export function formatTrackBasics(track: Track, defaultTitle = "—"): [string, string] { const artist = formatArtists(track.artists); const title = (track.title || defaultTitle).trim() || defaultTitle; return [artist, title]; } -/** Album.labels — [{name}] или [string]. берём первый (для TrackMetadata/тегов). */ -function formatLabel(labels: YaLabel[] | null | undefined): string { +function formatLabel(labels: Label[] | null | undefined): string { if (!labels || labels.length === 0) return ""; const first = labels[0]!; if (typeof first === "string") return first; return first.name ? first.name : String(first); } -/** Все лейблы альбома в виде массива строк (для CardMeta.labels / ℗-строки). */ -function formatAllLabels(labels: YaLabel[] | null | undefined): string[] { +function formatAllLabels(labels: Label[] | null | undefined): string[] { if (!labels || labels.length === 0) return []; return labels.map((l) => (typeof l === "string" ? l : (l.name ?? "")).trim()).filter(Boolean); } @@ -101,7 +95,6 @@ function strOrEmpty(v: unknown): string { return v ? String(v) : ""; } -/** первое значение не из (null/undefined/""/0). */ function firstTruthy(...values: T[]): T | null { for (const v of values) { if (v !== null && v !== undefined && (v as unknown) !== "" && (v as unknown) !== 0) return v; @@ -109,9 +102,7 @@ function firstTruthy(...values: T[]): T | null { return null; } -/** трек с нецензурной лексикой. источник — content_warning='explicit'; - * itunes-флаг explicit берём фолбэком. */ -function isExplicit(track: YaTrack): boolean { +function isExplicit(track: Track): boolean { return !!(track.contentWarning || track.explicit); } @@ -120,22 +111,15 @@ const ALBUM_TYPE_RU: Record = { ep: "EP", album: "альбом", compilation: "сборник", - podcast: "подкаст", - audiobook: "аудиокнига", - remix: "ремикс", - live: "концерт", - soundtrack: "саундтрек", + playlist: "плейлист", }; -/** тип релиза → ru-ярлык. fallback по trackCount. */ export function albumTypeRu( albumType: string | null | undefined, trackCount: number | null | undefined = null, ): string | null { if (albumType) { const key = albumType.toLowerCase(); - // Object.hasOwn, а не `in` — `in` проверяет и цепочку прототипов - // (albumType === "toString" иначе давал бы Function вместо string) if (Object.hasOwn(ALBUM_TYPE_RU, key)) return ALBUM_TYPE_RU[key]!; } if (trackCount === null || trackCount === undefined) return null; @@ -146,29 +130,24 @@ export function albumTypeRu( } const ALBUM_TYPE_EMOJI: Record = { - podcast: "🎙️", - audiobook: "🎧", playlist: "🎵", + compilation: "📚", }; -/** эмодзи-маркер релиза для заголовков выгрузки: подкаст/аудиокнига получают - * свой значок вместо дефолтного «пластинки», остальные типы — как обычный альбом. */ export function albumEmoji(albumType: string | null | undefined): string { return (albumType && ALBUM_TYPE_EMOJI[albumType.toLowerCase()]) || "📀"; } -/** типы, для которых в meta-строке выгрузки явно показываем ru-ярлык: обычный - * альбом и так узнаётся по 📀, а подкаст/аудиокнигу стоит подписать явно. */ -export const LABELED_ALBUM_TYPES = new Set(["podcast", "audiobook"]); +/** типы, для которых в meta-строке выгрузки явно показываем ru-ярлык. У Spotify + * таких «специальных» типов нет — сборник обычно и так узнаётся по обложке. */ +export const LABELED_ALBUM_TYPES = new Set(["compilation"]); -/** для сингла album.title часто == track.title → дубль на карточке. */ export function isSingle(albumType: string | null | undefined, trackCount: number | null | undefined = null): boolean { if (albumType && albumType.toLowerCase() === "single") return true; return trackCount === 1; } -/** hex-цвета обложки от я.музыки (track.derivedColors). */ -function derivedColors(track: YaTrack): CardColors { +function derivedColors(track: Track): CardColors { const dc = track.derivedColors; if (!dc) return {}; return { @@ -178,9 +157,8 @@ function derivedColors(track: YaTrack): CardColors { }; } -/** (trackNumber, discNumber) из trackPosition. пробуем album, потом track. */ -function trackPosition(track: YaTrack, album: YaAlbum | null): [number | null, number | null] { - for (const src of [album, track] as Array<{ trackPosition?: YaTrackPosition | null } | null>) { +function trackPosition(track: Track, album: Album | null): [number | null, number | null] { + for (const src of [album, track] as Array<{ trackPosition?: TrackPosition | null } | null>) { const tp = src ? src.trackPosition : null; if (tp) { const index = tp.index ?? null; @@ -192,7 +170,7 @@ function trackPosition(track: YaTrack, album: YaAlbum | null): [number | null, n } /** поля для meta-строки карточки. ничего не дёргает по сети. */ -export function buildCardMeta(track: YaTrack): CardMeta { +export function buildCardMeta(track: Track): CardMeta { const album = track.albums && track.albums.length > 0 ? track.albums[0]! : null; const trackCount = album ? (album.trackCount ?? null) : null; const rawType = album ? (album.type ?? null) : null; @@ -219,96 +197,31 @@ export function buildCardMeta(track: YaTrack): CardMeta { colors: derivedColors(track), album_id: album ? (album.id ?? null) : null, track_id: track.id ?? null, - liked: null, // лайк-статус на карточке не показывается (см. bot/cardBuilder.ts) — не заполняется + liked: null, }; } -/** hex-цвет wave-агента из Lottie-иконки «Моей волны». на любой кривизне → null. */ -export function waveColorFromLottie(data: Buffer): string | null { - let doc: { layers?: Array<{ nm?: string; shapes?: Array<{ it?: Array<{ ty?: string; c?: { k?: unknown } }> }> }> }; - try { - doc = JSON.parse(data.toString("utf-8")); - } catch { - return null; - } - for (const layer of doc.layers ?? []) { - if (layer.nm !== "color") continue; - for (const shape of layer.shapes ?? []) { - for (const it of shape.it ?? []) { - if (it.ty !== "fl") continue; - const k = it.c?.k; - if (Array.isArray(k) && k.length >= 3) { - const [r, g, b] = k.slice(0, 3).map((x) => Math.max(0, Math.min(255, Math.round(Number(x) * 255)))); - // нечисловой компонент → NaN.toString(16) === "NaN", а результат - // кэшируется на весь процесс (client.ts) — лучше вообще не найти цвет - if (![r, g, b].every(Number.isFinite)) return null; - const hex = (n: number) => n.toString(16).padStart(2, "0"); - return `#${hex(r!)}${hex(g!)}${hex(b!)}`; - } - } - } - } - return null; -} +/** yt-dlp даёт только opus/mp3/m4a на выходе — lossless не бывает. */ +export type AudioQuality = "best" | "economy"; -/** True, если derived_colors ахроматичны (ч/б обложка) → стоит тянуть wave-цвет. - * wave идёт в meta.colors.wave и используется extractAccent ЛИШЬ как фолбэк для - * реально ч/б обложек (ноль насыщенных пикселей) — цветную обложку он не перебьёт. */ -export function metaNeedsWaveColor(meta: CardMeta): boolean { - const colors = meta.colors || {}; - if (colors.wave) return false; // уже подтянут - if (!meta.album_id) return false; - const h = (colors.accent || "").replace(/^#/, ""); - if (h.length !== 6) return true; // цвета нет вовсе - const r = parseInt(h.slice(0, 2), 16); - const g = parseInt(h.slice(2, 4), 16); - const b = parseInt(h.slice(4, 6), 16); - if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return true; - return Math.max(r, g, b) - Math.min(r, g, b) < 26; // ~серый -} +const ECONOMY_CAP_KBPS = 128; -/** ч/б обложка → подмешивает в meta.colors.wave кураторский цвет wave-агента. */ -export async function enrichCardMetaColor(meta: CardMeta, client: WaveColorProvider): Promise { - if (!metaNeedsWaveColor(meta)) return; - let wave: string | null = null; - try { - wave = await client.getAlbumWaveColor(meta.album_id!); - } catch { - return; - } - if (wave) meta.colors = { ...meta.colors, wave }; -} - -/** порог экономии: лучший вариант с битрейтом ≤ ECONOMY_CAP_KBPS. */ -const ECONOMY_CAP_KBPS = 192; -// "lossless" обслуживается отдельным путём (get-file-info + AES-CTR), а не -// selectDownloadInfo — здесь он трактуется как best (фолбэк mp3/aac). -export type AudioQuality = "best" | "economy" | "lossless"; - -/** выбор варианта загрузки по качеству среди mp3/aac (аналог get_specific_download_info - * из python-либы, но по тиру битрейта — lossless/FLAC этот endpoint не отдаёт). - * best — макс. битрейт (при равном предпочтение mp3); economy — лучший ≤192 - * (или самый низкий, если все варианты выше порога). */ +/** выбор варианта загрузки по качеству. У Spotify пути один — всегда есть + * один DownloadInfo, поле quality влияет только на выбор yt-dlp формата + * (bestaudio vs. worstaudio bitrate). */ export function selectDownloadInfo( downloadInfos: T[] | null | undefined, quality: AudioQuality = "best", ): T | null { if (!downloadInfos || downloadInfos.length === 0) return null; - let candidates = downloadInfos.filter((d) => d.codec === "mp3" || d.codec === "aac"); - if (candidates.length === 0) candidates = [...downloadInfos]; const pickHighest = (pool: T[]): T => - pool.reduce((best, d) => { - const score = (x: T): [number, number] => [x.bitrateInKbps, x.codec === "mp3" ? 1 : 0]; - const [b0, b1] = score(best); - const [d0, d1] = score(d); - return d0 > b0 || (d0 === b0 && d1 > b1) ? d : best; - }); + pool.reduce((best, d) => (d.bitrateInKbps > best.bitrateInKbps ? d : best)); if (quality === "economy") { - const within = candidates.filter((d) => d.bitrateInKbps <= ECONOMY_CAP_KBPS); + const within = downloadInfos.filter((d) => d.bitrateInKbps <= ECONOMY_CAP_KBPS); if (within.length > 0) return pickHighest(within); - return candidates.reduce((lo, d) => (d.bitrateInKbps < lo.bitrateInKbps ? d : lo)); + return downloadInfos.reduce((lo, d) => (d.bitrateInKbps < lo.bitrateInKbps ? d : lo)); } - return pickHighest(candidates); + return pickHighest(downloadInfos); } export interface AlbumOverride { @@ -323,13 +236,13 @@ export interface AlbumOverride { cover_url?: string; } -export function buildTrackMetadata(track: YaTrack, albumOverride: AlbumOverride = {}): TrackMetadata { +export function buildTrackMetadata(track: Track, albumOverride: AlbumOverride = {}): TrackMetadata { const o = albumOverride; const album = track.albums && track.albums.length > 0 ? track.albums[0]! : null; const meta = track.metaData ?? null; const artist = formatArtists(track.artists); - const albumArtist = artist; // яндекс не разделяет + const albumArtist = artist; const duration = Math.floor((track.durationMs || 0) / 1000); const albumTitle = firstTruthy(o.title, album?.title ?? null, meta?.album ?? null) || ""; @@ -378,7 +291,7 @@ export function buildTrackMetadata(track: YaTrack, albumOverride: AlbumOverride /** дёргает полный Album если в track.albums[0] не хватает year/label/genre/count. */ export async function enrichMetadataFromAlbum( metadata: TrackMetadata, - track: YaTrack, + track: Track, client: AlbumShortProvider, ): Promise { const albums = track.albums ?? []; @@ -402,9 +315,8 @@ export async function enrichMetadataFromAlbum( return metadata; } -/** Текст лирики → массив непустых обрезанных строк (порядок песни сохранён). - * Строки-заголовки секций целиком в скобках («[Куплет 1]», «[Припев]» — - * Genius их вставляет) выкидываем: на карточке это шум. */ +/** Текст лирики → массив непустых обрезанных строк. Строки-заголовки секций + * целиком в скобках («[Куплет 1]») выкидываем — Genius их вставляет. */ export function splitLyricsLines(lyrics: string): string[] { return lyrics .split(/\r?\n/) @@ -412,8 +324,6 @@ export function splitLyricsLines(lyrics: string): string[] { .filter((l) => l.length > 0 && !/^\[.*\]$/.test(l)); } -/** Индекс строки, лучше всего совпадающей с query: сначала подстрока, затем по - * пересечению слов. -1 — совпадения нет. lines уже разбиты splitLyricsLines. */ export function bestLyricsLineIndex(query: string, lines: string[]): number { const q = query.toLowerCase().trim(); if (lines.length === 0 || !q) return -1; @@ -446,3 +356,15 @@ export function findBestLyricsLine(query: string, lyrics: string): string | null const idx = bestLyricsLineIndex(query, lines); return idx >= 0 ? lines[idx]! : null; } + +/** Совместимость с бывшим wave-color-провайдером (яндексовским) — сейчас no-op, + * cardBuilder.enrichCardMetaColor всё равно его вызывает. */ +export interface WaveColorProvider { + getAlbumWaveColor(albumId: number | string): Promise; +} + +/** Совместимость: раньше подмешивал wave-цвет для ч/б обложек. У Spotify нет + * wave-агента, вся эта фича отсутствует. */ +export async function enrichCardMetaColor(_meta: CardMeta, _client: WaveColorProvider): Promise { + // no-op — оставлен как хук для будущего своего экстрактора цвета обложки +} diff --git a/src/music/types.ts b/src/music/types.ts new file mode 100644 index 0000000..6169ee7 --- /dev/null +++ b/src/music/types.ts @@ -0,0 +1,157 @@ +/** Доменные типы «музыкального источника» — Spotify + yt-dlp. + * + * Историческая справка: бот форкнут с nowym (Яндекс.Музыка), поэтому имена + * многих полей похожи на структуру ya.music. Внутри — маппинг ответа Spotify + * Web API в удобные для карточки/тегов агрегаты. + * + * id-ы в этом слое — всегда `string` (Spotify id, 22 base62-символа), но в + * подписях типов оставлено `number | string` для совместимости с TG file_id и + * составными id-ами тегов эффектов (":ncore"). */ + +export interface Artist { + id?: string; + name?: string; +} + +/** Label — оставлено как union: раньше пришло из яндекс-структуры, теперь тут + * всегда string (Spotify отдаёт copyrights[].text одной строкой). */ +export type Label = string | { name?: string }; + +export interface TrackPosition { + volume?: number | null; + index?: number | null; +} + +/** Готовые hex-цвета обложки. Spotify этого не отдаёт — оставлено как расширение + * на случай добавления собственного экстрактора цветов. */ +export interface DerivedColors { + accent?: string | null; + average?: string | null; + waveText?: string | null; + miniPlayer?: string | null; +} + +/** Fallback метаданные из embed-структуры. Заполняется скупо — Spotify не даёт + * композитора/жанра на уровне трека. */ +export interface TrackMetaEmbed { + album?: string | null; + year?: string | number | null; + number?: number | null; + volume?: number | null; + genre?: string | null; + composer?: string | null; + version?: string | null; +} + +export interface Album { + id?: string; + title?: string | null; + year?: number | string | null; + genre?: string | null; + trackCount?: number | null; + /** "album" | "single" | "compilation" | "" */ + type?: string | null; + labels?: Label[] | null; + coverUri?: string | null; + artists?: Artist[] | null; + available?: boolean; + trackPosition?: TrackPosition | null; + derivedColors?: DerivedColors | null; + /** только у альбома-с-треками: диски → треки */ + volumes?: Track[][] | null; +} + +export interface Track { + id?: string; + title?: string | null; + available?: boolean; + durationMs?: number | null; + /** Spotify: contentRating === "explicit". */ + contentWarning?: string | null; + explicit?: boolean | null; + version?: string | null; + artists?: Artist[] | null; + albums?: Album[] | null; + coverUri?: string | null; + derivedColors?: DerivedColors | null; + metaData?: TrackMetaEmbed | null; + trackPosition?: TrackPosition | null; + /** ISRC — критичен для точного матчинга через yt-dlp. */ + isrc?: string | null; + /** проставляется в client.getAlbum() — позиция в выгружаемом альбоме */ + _trackNumber?: number; + _discNumber?: number; +} + +/** один вариант загрузки. Для Spotify — всегда единственный, ISRC-подсказка для yt-dlp. */ +export interface DownloadInfo { + codec: string; + bitrateInKbps: number; + gain?: boolean; + preview?: boolean; + /** Не URL, а трек-id: yt-dlp резолвер сам находит источник по метаданным. */ + downloadInfoUrl: string; + direct?: boolean; +} + +// ── наши агрегаты ────────────────────────────────────────────────────────── + +export interface TrackMetadata { + artist: string; + album_artist: string; + title: string; + album: string; + year: string; + label: string; + genre: string | null; + composer: string | null; + version: string | null; + explicit: boolean; + duration: number; // секунды + track_number: number | null; + track_total: number | null; + disc_number: number | null; + disc_total: number | null; + cover_url: string; + cover_data: Buffer | null; +} + +export interface AlbumData { + id: string; + title: string; + artist: string; + year: string; + label: string; + cover_url: string; + genre: string; + /** сырое album.type: "album" | "single" | "compilation" | "playlist" */ + album_type: string; + tracks: Track[]; + volumes: Track[][]; +} + +/** поля для meta-строки карточки. */ +export interface CardMeta { + album: string; + album_type: string; + type_ru: string; + year: string; + labels: string[]; + genre: string; + track_number: number | null; + track_total: number | null; + version: string; + explicit: boolean; + is_single: boolean; + colors: CardColors; + album_id: string | null; + track_id: string | null; + liked: boolean | null; +} + +export interface CardColors { + accent?: string; + average?: string; + wave_text?: string; + wave?: string; +} diff --git a/src/music/urls.ts b/src/music/urls.ts new file mode 100644 index 0000000..341e866 --- /dev/null +++ b/src/music/urls.ts @@ -0,0 +1,7 @@ +/** Публичные ссылки Spotify — единый источник. */ +const BASE = "https://open.spotify.com"; + +export const trackUrl = (id: string | number): string => `${BASE}/track/${id}`; +export const albumUrl = (id: string | number): string => `${BASE}/album/${id}`; +/** Плейлисты в Spotify не имеют «owner»-сегмента в URL — просто playlist/. */ +export const playlistUrl = (_owner: string, id: string | number): string => `${BASE}/playlist/${id}`; diff --git a/src/music/watcher.ts b/src/music/watcher.ts new file mode 100644 index 0000000..597d5c3 --- /dev/null +++ b/src/music/watcher.ts @@ -0,0 +1,31 @@ +/** «Сейчас играет» через периодический запрос к Spotify Web API. + * + * Заменяет YnisonService из Яндекса. Spotify не даёт ws-плеера, поэтому только + * polling: /me/player/currently-playing, каждые ~3с пока играет, ~15с пока + * пусто/пауза. Прогресс между запросами интерполируется по времени. */ + +import type { Track } from "./types.ts"; + +/** результат getCurrentTrack — трек + статус воспроизведения. */ +export interface CurrentTrack { + track: Track | null; + paused: boolean; + duration_ms: number; + progress_ms: number; + entity_id: string | null; + entity_type: string | null; +} + +/** живой прогресс с учётом времени, прошедшего с момента снапшота. */ +export function liveProgressMs( + progressMs: number, + durationMs: number, + paused: boolean, + timestampMs: number, + playbackSpeed = 1.0, +): number { + if (paused || !timestampMs) return Math.max(0, progressMs); + const elapsed = (Date.now() - timestampMs) * playbackSpeed; + const progress = progressMs + elapsed; + return Math.max(0, Math.min(progress, durationMs || progress)); +} diff --git a/src/services/albums.ts b/src/services/albums.ts index 270841c..8b7d37a 100644 --- a/src/services/albums.ts +++ b/src/services/albums.ts @@ -14,8 +14,8 @@ import { getSettings } from "../settings.ts"; import type { CacheDb } from "../storage/cache.ts"; import type { UserSettings } from "../storage/settings.ts"; import type { CachedTrack, InflightAlbum } from "../storage/types.ts"; -import { makeEncrawDecrypt, type YandexClient } from "../yandex/client.ts"; -import { fetchCover } from "../yandex/media.ts"; +import type { MusicClient } from "../music/client.ts"; +import { fetchCover } from "../music/media.ts"; import { type AudioQuality, albumEmoji, @@ -24,8 +24,8 @@ import { formatTrackBasics, LABELED_ALBUM_TYPES, trackIsTooLong, -} from "../yandex/metadata.ts"; -import type { AlbumData, TrackMetadata, YaTrack } from "../yandex/types.ts"; +} from "../music/metadata.ts"; +import type { AlbumData, TrackMetadata, Track } from "../music/types.ts"; import type { CacheMetadata, CacheService } from "./cache.ts"; const log = getLogger("services.albums"); @@ -92,15 +92,15 @@ export class ProgressMessage { interface RetryBuf { chatId: number; userId: number; - tracks: YaTrack[]; + tracks: Track[]; albumData: AlbumData; - yandex: YandexClient; + music: MusicClient; } type PrepareKind = "cached" | "fresh" | "failed"; interface PreparedEntry { kind: PrepareKind; - track: YaTrack; + track: Track; trackId: string; metadata: CacheMetadata; // cached → fileId; fresh → [audio, cover]; failed → null @@ -114,7 +114,7 @@ interface PreparedEntry { interface ProgressState { sent: number; downloading: number; - failed: YaTrack[]; + failed: Track[]; lock: Semaphore; startedAt: number; lastRender: number; @@ -126,7 +126,7 @@ interface SendArgs { userId: number; albumData: AlbumData; progressMsg: ProgressMessage | null; - yandex: YandexClient; + music: MusicClient; coverBytes?: Buffer | null; } @@ -197,7 +197,7 @@ export class AlbumService { tracks: args.albumData.tracks, albumData: args.albumData, progressMsg: args.progressMsg, - yandex: args.yandex, + music: args.music, coverBytes: args.coverBytes ?? null, persistInflight: true, }); @@ -208,10 +208,10 @@ export class AlbumService { async retryFailed(args: { chatId: number; userId: number; - tracks: YaTrack[]; + tracks: Track[]; albumData: AlbumData; progressMsg: ProgressMessage | null; - yandex: YandexClient; + music: MusicClient; }): Promise { // claim — первым же синхронным действием, до всех await'ов вызывающей стороны if (!this.tryBegin(args.userId)) return false; @@ -222,7 +222,7 @@ export class AlbumService { tracks: args.tracks, albumData: args.albumData, progressMsg: args.progressMsg, - yandex: args.yandex, + music: args.music, coverBytes: null, persistInflight: false, }); @@ -232,7 +232,7 @@ export class AlbumService { /** на старте бота дочитывает альбомы, упавшие на середине. */ async resumeInflight(deps: { resolveToken(userId: number): Promise; - getYandexClient(token: string, userId: number): Promise; + getMusicClient(token: string, userId: number): Promise; }): Promise { let rows: InflightAlbum[]; try { @@ -263,21 +263,21 @@ export class AlbumService { private async resumeOne( deps: { resolveToken(userId: number): Promise; - getYandexClient(token: string, userId: number): Promise; + getMusicClient(token: string, userId: number): Promise; }, row: { id: number; user_id: number; chat_id: number; - album_id: number; + album_id: string; progress_message_id: number | null; - completed_track_ids: number[]; + completed_track_ids: string[]; }, ): Promise { const userId = row.user_id; const chatId = row.chat_id; const albumId = row.album_id; - const doneIds = new Set(row.completed_track_ids.map(Number)); + const doneIds = new Set(row.completed_track_ids.map(String)); const inflightId = row.id; const proxy = row.progress_message_id ? new ProgressMessage(this.bot, chatId, row.progress_message_id) : null; @@ -298,10 +298,10 @@ export class AlbumService { return; } - const yandex = await deps.getYandexClient(token, userId); + const music = await deps.getMusicClient(token, userId); let album: AlbumData | null; try { - album = await yandex.getAlbum(albumId); + album = await music.getAlbum(albumId); } catch (e) { log.warning(`[resume] альбом ${albumId} user=${userId}: ${e}`); album = null; @@ -314,7 +314,7 @@ export class AlbumService { return; } - const remaining = album.tracks.filter((t) => !doneIds.has(Number(t.id))); + const remaining = album.tracks.filter((t) => !doneIds.has(String(t.id))); const totalOrig = album.tracks.length; if (remaining.length === 0) { @@ -337,7 +337,7 @@ export class AlbumService { tracks: remaining, albumData: album, progressMsg: proxy, - yandex, + music, coverBytes: null, persistInflight: true, inflightId, @@ -364,23 +364,21 @@ export class AlbumService { private async sendAlbumTracks(args: { chatId: number; userId: number; - tracks: YaTrack[]; + tracks: Track[]; albumData: AlbumData; progressMsg: ProgressMessage | null; - yandex: YandexClient; + music: MusicClient; coverBytes: Buffer | null; persistInflight: boolean; inflightId?: number | null; }): Promise { - const { chatId, userId, tracks, albumData, progressMsg, yandex, coverBytes } = args; + const { chatId, userId, tracks, albumData, progressMsg, music, coverBytes } = args; const total = tracks.length; if (total === 0) return; - // режим качества юзера: lossless → весь альбом во FLAC (отдельный тир кэша), - // с пофайловым фолбэком на lossy, если у трека нет lossless. + // режим качества юзера — «best» или «economy» (у Spotify+yt-dlp lossless нет). const quality = (await this.getUserSettings(userId)).track_quality as AudioQuality; - const losslessMode = quality === "lossless"; - const tier = losslessMode ? "lossless" : "lossy"; + const tier = "lossy"; let inflightId = args.inflightId ?? null; if (args.persistInflight && inflightId === null) { @@ -388,7 +386,7 @@ export class AlbumService { inflightId = await this.db.insertInflightAlbum({ user_id: userId, chat_id: chatId, - album_id: albumData.id || 0, + album_id: albumData.id || "", progress_message_id: progressMsg?.messageId ?? null, }); } catch (e) { @@ -410,7 +408,7 @@ export class AlbumService { const metaLine = metaParts.join(", "); const headerFull = metaLine ? format`${headerShort}\n${metaLine}` : headerShort; - const buildMeta = (track: YaTrack, i: number): CacheMetadata => { + const buildMeta = (track: Track, i: number): CacheMetadata => { const trackNumber = track._trackNumber ?? i; const discNumber = multiDisc ? (track._discNumber ?? 1) : null; const meta: CacheMetadata = buildTrackMetadata(track, { @@ -447,7 +445,7 @@ export class AlbumService { const markDoneSafe = async (tid: string): Promise => { if (inflightId === null) return; try { - await this.db.markInflightTrackDone(inflightId, Number(tid)); + await this.db.markInflightTrackDone(inflightId, tid); } catch (e) { log.debug(`[inflight] mark_done track=${tid}: ${e}`); } @@ -455,7 +453,7 @@ export class AlbumService { const prepared: (PreparedEntry | null)[] = new Array(total).fill(null); - const prepare = async (idx: number, track: YaTrack): Promise => { + const prepare = async (idx: number, track: Track): Promise => { const trackId = String(track.id); const metadata = buildMeta(track, idx + 1); @@ -502,38 +500,13 @@ export class AlbumService { let actualTier = "lossy"; try { const result = await this.downloadQueue.enqueue(async () => { - // lossless-режим: тянем FLAC через get-file-info с дешифровкой encraw. - if (losslessMode) { - const ll = await yandex.getLosslessDownload(track); - if (ll !== null) { - metadata.codec = ll.codec; - metadata.bitrate_kbps = ll.bitrate; - const decrypt = makeEncrawDecrypt(ll.key); - // каждая racing-попытка берёт случайный url — естественный спред по CDN. - const llUrls = ll.links(); - const pickUrl = async (): Promise => - llUrls[Math.floor(Math.random() * llUrls.length)] ?? null; - const llCodec = ll.codec ?? "flac"; - const p = await this.cache.prepareTrackPayload( - trackId, - pickUrl, - metadata, - llCodec, - decrypt, - "lossless", - ); - if (p !== null) return { codec: llCodec, payload: p, tier: "lossless" }; - log.warning(`[album] lossless не вышел для ${trackId} → фолбэк на lossy`); - } - // фолбэк: трек без lossless или сбой — качаем lossy ниже. - } - const info = await yandex.getBestDownloadInfo(track); + const info = await music.getBestDownloadInfo(track, quality); if (info === null) return null; metadata.codec = info.codec; metadata.bitrate_kbps = info.bitrateInKbps; const p = await this.cache.prepareTrackPayload( trackId, - () => yandex.directLinkFromInfo(info), + () => music.directLinkFromInfo(info), metadata, info.codec, ); @@ -625,7 +598,7 @@ export class AlbumService { albumData, userId, chatId, - yandex, + music, elapsed: performance.now() / 1000 - progress.startedAt, }); } finally { @@ -790,14 +763,14 @@ export class AlbumService { msg: ProgressMessage | null; headerShort: FormattableString; total: number; - failed: YaTrack[]; + failed: Track[]; albumData: AlbumData; userId: number; chatId: number; - yandex: YandexClient; + music: MusicClient; elapsed: number; }): Promise { - const { msg, headerShort, total, failed, albumData, userId, chatId, yandex, elapsed } = args; + const { msg, headerShort, total, failed, albumData, userId, chatId, music, elapsed } = args; if (msg === null) return; const bar = renderProgressBar(total, total); const elapsedStr = formatEta(elapsed); @@ -805,7 +778,7 @@ export class AlbumService { if (failed.length > 0) { const failedList = join(failed, (t) => format`- ${t.title}`, "\n"); const text = format`${headerShort}\n${code(bar)} ${total - failed.length}/${total}, за ${elapsedStr}\n\n❌ не вышло:\n${failedList}`; - this.putRetry(msg.messageId, { chatId, userId, tracks: failed, albumData, yandex }); + this.putRetry(msg.messageId, { chatId, userId, tracks: failed, albumData, music }); await msg.edit(text, albumRetryMarkup(msg.messageId, pluralTracks(failed.length))); } else { const text = format`${headerShort}\n${code(bar)} ${total}/${total}, за ${elapsedStr}\n✅ готово 🎶`; diff --git a/src/services/auth.ts b/src/services/auth.ts index a07b0b2..1742c83 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,20 +1,38 @@ -/** OAuth Device Flow для яндекса. Опрашивает /token, при успехе сохраняет - * access+refresh в БД (зашифрованно). +/** Spotify OAuth Authorization Code + PKCE. * - * транспорт — DeviceAuthMixin из @dvxch/yandex-music (requestDeviceCode/ - * pollDeviceToken/refreshAccessToken); Client допускает пустой token в - * конструкторе, а device-эндпоинты на oauth.yandex.ru его не требуют. - * client_id/secret берём из settings (по умолчанию — публичный клиент Android- - * приложения Я.Музыки). */ -import { DeviceAuthError, type DeviceCode, Client as LibClient, type OAuthToken } from "@dvxch/yandex-music"; -import { sleep } from "../infra/async.ts"; + * Гибридный флоу: + * • Если задан WEBHOOK_HOST — редирект Spotify летит на + * /spotify/callback, main.ts ловит его, авторизация + * завершается сама, юзер видит «готово» в чате. + * • Иначе — paste-код: юзер открывает ссылку, авторизуется, попадает на + * любой redirect_uri (мы используем http://127.0.0.1:8888/callback), + * копирует `code=` из адресной строки и присылает боту. + * + * Токены (access + refresh) шифруются at-rest в UsersDb. */ + +import { randomBytes, createHash } from "node:crypto"; +import { HttpClient } from "../infra/http.ts"; import { getLogger } from "../infra/logging.ts"; import { getSettings } from "../settings.ts"; import type { UsersDb } from "../storage/users.ts"; const log = getLogger("auth"); -const AUTH_TIMEOUT_SEC = 300; // 5 мин, даже если сервер даёт больше +const SPOTIFY_AUTH = "https://accounts.spotify.com"; +// Права: чтение метаданных пользователя + плеера, лайки/сохранённые. +// user-library-modify нужен для setTrackLiked, user-read-currently-playing — +// для карточки «сейчас играет», user-read-recently-played — для inline «недавнее». +export const SPOTIFY_SCOPES = [ + "user-read-private", + "user-read-email", + "user-read-playback-state", + "user-read-currently-playing", + "user-read-recently-played", + "user-library-read", + "user-library-modify", +].join(" "); + +const AUTH_TIMEOUT_SEC = 600; // 10 минут — юзеру нужно время открыть браузер export type OnSuccess = (userId: number) => void | Promise; export type OnFailure = (userId: number, reason: string) => void | Promise; @@ -22,84 +40,126 @@ export type OnFailure = (userId: number, reason: string) => void | Promise export interface DeviceAuthState { userId: number; verificationUrl: string; + /** state — рандом, привязывающий OAuth-редирект к юзеру (в базе-мапе). */ + state: string; + /** для paste-режима — короткий текст, что вставлять; для webhook-режима пустой. */ userCode: string; expiresIn: number; - /** завершается, когда poll-цикл закончился (успех/отказ/таймаут/отмена). */ + /** true = webhook доедет сам; false = юзеру надо прислать код. */ + usesCallback: boolean; + /** резолвится, когда авторизация завершилась (ок/отказ/таймаут/отмена). */ done: Promise; } interface ActiveAuth { state: DeviceAuthState; cancelled: boolean; + pkceVerifier: string; + resolveDone: () => void; + onSuccess: OnSuccess; + onFailure: OnFailure; + deadline: number; } -/** транспортный срез Client'а, нужный для device-flow — внедряемый ради тестов - * (фейк без реальных запросов на oauth.yandex.ru). */ -export type DeviceAuthTransport = Pick; +function base64url(buf: Buffer): string { + return buf.toString("base64").replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +function makePkcePair(): { verifier: string; challenge: string } { + const verifier = base64url(randomBytes(32)); + const challenge = base64url(createHash("sha256").update(verifier).digest()); + return { verifier, challenge }; +} + +interface SpotifyTokenResponse { + access_token: string; + refresh_token?: string; + expires_in?: number; + token_type?: string; + scope?: string; +} export class AuthService { private active = new Map(); + // state → userId — mainhttp-callback ищет активный флоу по state из URL. + private stateToUser = new Map(); constructor( private usersDb: UsersDb, - // без токена: только device-flow эндпоинты, не привязаны к аккаунту. - private lib: DeviceAuthTransport = new LibClient(), + private http: HttpClient = new HttpClient(), ) {} - /** запустить device flow. если уже идёт — отменит старую и стартанёт новую. */ + /** Публичный (для main.ts) — существует ли активный флоу с этим state. */ + lookupPendingByState(state: string): number | null { + return this.stateToUser.get(state) ?? null; + } + + /** Публичный (для main.ts) — довести OAuth-редирект до завершения. */ + async completeAuthByCode(state: string, code: string): Promise<{ userId: number; ok: boolean }> { + const userId = this.stateToUser.get(state); + if (userId === undefined) return { userId: 0, ok: false }; + const entry = this.active.get(userId); + if (!entry || entry.cancelled) return { userId, ok: false }; + const ok = await this.exchangeAndFinish(entry, code); + return { userId, ok }; + } + async startDeviceAuth(userId: number, onSuccess: OnSuccess, onFailure: OnFailure): Promise { const existing = this.active.get(userId); - if (existing) { - existing.cancelled = true; - this.active.delete(userId); - } + if (existing) this.cancelAuthInternal(existing); const s = getSettings(); - let code: DeviceCode; - try { - code = await this.lib.requestDeviceCode(undefined, "мойва", s.YANDEX_OAUTH_CLIENT_ID); - if (!code.deviceCode) { - log.error(`device code не получен для ${userId}: пустой ответ`); - return null; - } - } catch (e) { - log.error(`device code не получен для ${userId}: ${e}`); + if (!s.SPOTIFY_CLIENT_ID) { + log.error("SPOTIFY_CLIENT_ID не задан в .env — авторизация невозможна"); return null; } + const { verifier, challenge } = makePkcePair(); + const state = base64url(randomBytes(16)); + const usesCallback = Boolean(s.WEBHOOK_HOST); + const redirectUri = this.redirectUri(); + + const url = new URL(`${SPOTIFY_AUTH}/authorize`); + url.searchParams.set("client_id", s.SPOTIFY_CLIENT_ID); + url.searchParams.set("response_type", "code"); + url.searchParams.set("redirect_uri", redirectUri); + url.searchParams.set("code_challenge_method", "S256"); + url.searchParams.set("code_challenge", challenge); + url.searchParams.set("state", state); + url.searchParams.set("scope", SPOTIFY_SCOPES); + + let resolveDone!: () => void; + const done = new Promise((r) => { + resolveDone = r; + }); const entry: ActiveAuth = { cancelled: false, + pkceVerifier: verifier, + resolveDone, + onSuccess, + onFailure, + deadline: Date.now() + AUTH_TIMEOUT_SEC * 1000, state: { userId, - verificationUrl: code.verificationUrl ?? "", - userCode: code.userCode ?? "", - expiresIn: Math.min(code.expiresIn ?? AUTH_TIMEOUT_SEC, AUTH_TIMEOUT_SEC), - done: Promise.resolve(), + verificationUrl: url.toString(), + state, + userCode: usesCallback ? "" : "пришли мне значение параметра `code` из адресной строки после авторизации", + expiresIn: AUTH_TIMEOUT_SEC, + usesCallback, + done, }, }; - // pollLoop может упасть на usersDb.save (БД моргнула в момент успешного - // логина) — единственный путь, не обёрнутый в try/catch внутри pollLoop. - // .done никто не awaitит, без catch это unhandledRejection, а юзер молча - // зависает на экране «введи код» — зовём onFailure и тут, как во всех - // остальных ветках отказа внутри pollLoop. - entry.state.done = this.pollLoop(entry, code, onSuccess, onFailure).catch(async (e) => { - log.error(`pollLoop упал для ${userId}: ${e}`); - try { - await onFailure(userId, "внутренняя ошибка, попробуй ещё раз"); - } catch { - // ignore — мы и так уже в catch-ветке отказавшего pollLoop - } - }); this.active.set(userId, entry); - log.info(`device flow стартовал для ${userId}`); + this.stateToUser.set(state, userId); + this.armTimeout(entry); + log.info(`Spotify OAuth стартовал для ${userId} (callback=${usesCallback})`); return entry.state; } cancelAuth(userId: number): boolean { const entry = this.active.get(userId); if (entry && !entry.cancelled) { - entry.cancelled = true; - this.active.delete(userId); + this.cancelAuthInternal(entry); return true; } return false; @@ -114,73 +174,129 @@ export class AuthService { return this.active.get(userId)?.state ?? null; } - /** обновляет access через refresh (OAuth2 refresh grant), сохраняет в БД. */ - async refreshToken(userId: number, refreshToken: string): Promise { + /** В paste-режиме юзер шлёт присланный `code=` — завершает авторизацию. */ + async submitPastedCode(userId: number, rawCode: string): Promise { + const entry = this.active.get(userId); + if (!entry || entry.cancelled) return false; + const code = extractCode(rawCode); + if (!code) { + await entry.onFailure(userId, "не нашёл параметр code — пришли ссылку целиком или значение code=... из адресной строки"); + return false; + } + return this.exchangeAndFinish(entry, code); + } + + private async exchangeAndFinish(entry: ActiveAuth, code: string): Promise { const s = getSettings(); try { - const token = await this.lib.refreshAccessToken( - refreshToken, - s.YANDEX_OAUTH_CLIENT_ID, - s.YANDEX_OAUTH_CLIENT_SECRET, + const body = new URLSearchParams(); + body.set("grant_type", "authorization_code"); + body.set("code", code); + body.set("redirect_uri", this.redirectUri()); + body.set("client_id", s.SPOTIFY_CLIENT_ID); + body.set("code_verifier", entry.pkceVerifier); + const res = await this.http.request(`${SPOTIFY_AUTH}/api/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (res.status !== 200) { + const text = await res.body.text(); + log.warning(`Spotify token exchange HTTP ${res.status}: ${text.slice(0, 200)}`); + await entry.onFailure(entry.state.userId, "Spotify не отдал токен — код мог протухнуть, попробуй ещё раз"); + return false; + } + const token = (await res.body.json()) as SpotifyTokenResponse; + if (!token.access_token) { + await entry.onFailure(entry.state.userId, "Spotify вернул пустой токен"); + return false; + } + await this.usersDb.save( + entry.state.userId, + token.access_token, + token.refresh_token ?? null, + token.expires_in ?? null, ); - await this.usersDb.save(userId, token.accessToken!, token.refreshToken || refreshToken, token.expiresIn ?? null); + log.info(`авторизация ок для ${entry.state.userId}`); + await entry.onSuccess(entry.state.userId); + this.finalize(entry); + return true; + } catch (e) { + log.error(`token exchange упал для ${entry.state.userId}: ${e}`); + await entry.onFailure(entry.state.userId, "внутренняя ошибка при обмене кода на токен"); + return false; + } + } + + async refreshToken(userId: number, refreshToken: string): Promise { + const s = getSettings(); + try { + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("refresh_token", refreshToken); + body.set("client_id", s.SPOTIFY_CLIENT_ID); + const res = await this.http.request(`${SPOTIFY_AUTH}/api/token`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }); + if (res.status !== 200) { + const text = await res.body.text(); + log.warning(`refresh для ${userId} HTTP ${res.status}: ${text.slice(0, 200)}`); + return null; + } + const token = (await res.body.json()) as SpotifyTokenResponse; + if (!token.access_token) return null; + await this.usersDb.save(userId, token.access_token, token.refresh_token ?? refreshToken, token.expires_in ?? null); log.info(`access обновлён для ${userId}`); - return token.accessToken!; + return token.access_token; } catch (e) { log.warning(`refresh для ${userId} не сработал: ${e}`); return null; } } - private async pollLoop( - entry: ActiveAuth, - code: DeviceCode, - onSuccess: OnSuccess, - onFailure: OnFailure, - ): Promise { - const userId = entry.state.userId; + private redirectUri(): string { const s = getSettings(); - const interval = Math.max(code.interval ?? 5, 3) * 1000; - const deadline = Date.now() + entry.state.expiresIn * 1000; + if (s.WEBHOOK_HOST) return `${s.WEBHOOK_HOST.replace(/\/+$/, "")}/spotify/callback`; + return "http://127.0.0.1:8888/callback"; + } - try { - while (true) { - await sleep(interval); - if (entry.cancelled) { - log.info(`авторизация отменена ${userId}`); - return; - } - if (Date.now() >= deadline) { - log.info(`таймаут авторизации ${userId}`); - await onFailure(userId, "timeout"); - return; - } - - // null = authorization_pending (юзер ещё не подтвердил) → опрашиваем дальше. - // прочие ошибки (в т.ч. expired/denied) pollDeviceToken бросает — ловим ниже. - let token: OAuthToken | null; - try { - token = await this.lib.pollDeviceToken( - code.deviceCode!, - s.YANDEX_OAUTH_CLIENT_ID, - s.YANDEX_OAUTH_CLIENT_SECRET, - ); - } catch (e) { - const reason = e instanceof DeviceAuthError ? e.message : String(e); - log.warning(`DeviceAuthError ${userId}: ${reason}`); - await onFailure(userId, reason); - return; - } - if (token === null) continue; - - await this.usersDb.save(userId, token.accessToken!, token.refreshToken ?? null, token.expiresIn ?? null); - log.info(`авторизация ок для ${userId}`); - await onSuccess(userId); - return; - } - } finally { - // снимаем только свою запись — параллельный start мог заменить новой - if (this.active.get(userId) === entry) this.active.delete(userId); - } + private cancelAuthInternal(entry: ActiveAuth): void { + entry.cancelled = true; + this.stateToUser.delete(entry.state.state); + this.active.delete(entry.state.userId); + entry.resolveDone(); + } + + private finalize(entry: ActiveAuth): void { + this.stateToUser.delete(entry.state.state); + if (this.active.get(entry.state.userId) === entry) this.active.delete(entry.state.userId); + entry.resolveDone(); + } + + private armTimeout(entry: ActiveAuth): void { + const ms = Math.max(0, entry.deadline - Date.now()); + setTimeout(() => { + if (entry.cancelled || this.active.get(entry.state.userId) !== entry) return; + log.info(`таймаут авторизации ${entry.state.userId}`); + void Promise.resolve(entry.onFailure(entry.state.userId, "timeout")).catch(() => {}); + this.finalize(entry); + }, ms).unref?.(); + } +} + +/** Достаёт code= из полной ссылки, из query, или считает всё за код. */ +export function extractCode(raw: string): string | null { + const trimmed = raw.trim(); + if (!trimmed) return null; + try { + const url = new URL(trimmed.startsWith("http") ? trimmed : `http://x/?${trimmed.replace(/^\?/, "")}`); + const code = url.searchParams.get("code"); + if (code) return code; + } catch { + // не URL — считаем строку кодом } + if (/^[A-Za-z0-9_-]{10,}$/.test(trimmed)) return trimmed; + return null; } diff --git a/src/services/cache.ts b/src/services/cache.ts index de30109..9720a75 100644 --- a/src/services/cache.ts +++ b/src/services/cache.ts @@ -15,9 +15,9 @@ import type { CacheDb } from "../storage/cache.ts"; import type { TrackCacheResult } from "../storage/types.ts"; import { createEmptyMp3, getFilename } from "../tagging/emptyMp3.ts"; import type { AudioTransform, TaggingService } from "../tagging/service.ts"; -import { fetchCover, origCoverUrl } from "../yandex/media.ts"; -import { estimateTrackBytes, HEAVY_TRACK_BYTES, MAX_TRACK_BYTES } from "../yandex/metadata.ts"; -import type { TrackMetadata } from "../yandex/types.ts"; +import { fetchCover, origCoverUrl } from "../music/media.ts"; +import { estimateTrackBytes, HEAVY_TRACK_BYTES, MAX_TRACK_BYTES } from "../music/metadata.ts"; +import type { TrackMetadata } from "../music/types.ts"; const log = getLogger("services.cache"); diff --git a/src/services/card.ts b/src/services/card.ts index 020faa6..187b065 100644 --- a/src/services/card.ts +++ b/src/services/card.ts @@ -12,7 +12,7 @@ import fs from "node:fs"; import path from "node:path"; import { type Canvas, createCanvas, GlobalFonts, type Image, loadImage, type SKRSContext2D } from "@napi-rs/canvas"; import { getLogger } from "../infra/logging.ts"; -import type { CardColors, CardMeta } from "../yandex/types.ts"; +import type { CardColors, CardMeta } from "../music/types.ts"; const log = getLogger("services.card"); diff --git a/src/services/lyricsVideo.ts b/src/services/lyricsVideo.ts index 46dd4a3..58efcd0 100644 --- a/src/services/lyricsVideo.ts +++ b/src/services/lyricsVideo.ts @@ -17,9 +17,9 @@ // import { fetchUserAvatar } from "../bot/cardBuilder.ts"; // import { getLogger } from "../infra/logging.ts"; // import { renderNowPlayingCard, type CardOptions, type LyricsBlock } from "./card.ts"; -// import { parseLrc, filterLrcLines } from "../yandex/lrc.ts"; -// import { fetchCover, normalizeCoverUrl } from "../yandex/media.ts"; -// import { buildCardMeta, enrichCardMetaColor, formatTrackBasics, metaNeedsWaveColor } from "../yandex/metadata.ts"; +// import { parseLrc, filterLrcLines } from "../music/lrc.ts"; +// import { fetchCover, normalizeCoverUrl } from "../music/media.ts"; +// import { buildCardMeta, enrichCardMetaColor, formatTrackBasics, metaNeedsWaveColor } from "../music/metadata.ts"; // import type { TgUser } from "../bot/handlers/common.ts"; // // const log = getLogger("services.lyricsVideo"); @@ -107,7 +107,7 @@ // senderHandle: string | null; // senderAvatar: Buffer | null; // botUsername: string | null; -// meta: Partial; +// meta: Partial; // cardOptions: CardOptions; // /** локальный путь к уже скачанному аудио — ffmpeg сам сеть не трогает, чтобы // * не зависнуть на нестабильном CDN Яндекса (см. infra/http.ts downloadAudio). */ @@ -236,8 +236,8 @@ // return; // } // -// const yandex = await container.getYandexService(token, user.id); -// const current = await yandex.getCurrentTrack(); +// const music = await container.getMusicService(token, user.id); +// const current = await music.getCurrentTrack(); // const track = current.track; // if (!track) { // await bot.api.sendMessage({ chat_id: chatId, text: "🎵 сейчас ничего не играет" }); @@ -248,7 +248,7 @@ // return; // } // -// const lrcText = await yandex.getTrackLRC(track.id); +// const lrcText = await music.getTrackLRC(track.id); // if (!lrcText) { // await bot.api.sendMessage({ chat_id: chatId, text: "у этого трека нет синхронизированных текстов 🎶" }); // return; @@ -268,7 +268,7 @@ // // if (metaNeedsWaveColor(meta)) { // try { -// await enrichCardMetaColor(meta, yandex); +// await enrichCardMetaColor(meta, music); // } catch { // /* best-effort */ // } @@ -277,7 +277,7 @@ // const [coverBytes, senderAvatar, audioUrl] = await Promise.all([ // coverUrl ? fetchCover(coverUrl, container.httpClient) : Promise.resolve(null), // settings.card_toggles.avatar ? fetchUserAvatar(bot, user.id, container) : Promise.resolve(null), -// yandex.getDownloadUrl(track, "best").catch(() => null), +// music.getDownloadUrl(track, "best").catch(() => null), // ]); // // // аудио качаем сами через downloadAudio (ретраи + короткие таймауты) и diff --git a/src/services/nowPlayingWatcher.ts b/src/services/nowPlayingWatcher.ts index 851260d..d9f3eb4 100644 --- a/src/services/nowPlayingWatcher.ts +++ b/src/services/nowPlayingWatcher.ts @@ -1,45 +1,33 @@ -/** Постоянный «сейчас играет» владельца → синхронный снимок для /now-playing и - * inline fast-path. +/** Постоянный «сейчас играет» владельца через polling Spotify Web API. * - * Тонкий адаптер над RealtimeClient из @dvxch/yandex-music: реконнект, backoff, - * go-away, стабильный deviceId, stale-детект и pull-снимок живут в либе. Здесь — - * только специфика бота: токен владельца из БД (на смену токена пересоздаём - * клиент) и резолв playable_id → Track. */ + * Заменяет прошлый Ynison-вотчер (Yandex давал ws-плеер, Spotify — нет). + * Опрашивает /me/player/currently-playing раз в SPOTIFY_NP_POLL_MS пока + * играет; в паузе/тишине интервал плавно растёт (до 30с). Прогресс между + * запросами интерполируется по времени. */ + import { EventEmitter } from "node:events"; -import { - ANDROID_DEVICE_INFO, - type DeviceInfoOverride, - generateDeviceId, - Client as LibClient, - type Track as LibTrack, - RealtimeClient, -} from "@dvxch/yandex-music"; -import { createInterruptibleSleep, withTimeout } from "../infra/async.ts"; +import { createInterruptibleSleep } from "../infra/async.ts"; +import { HttpClient } from "../infra/http.ts"; import { getLogger } from "../infra/logging.ts"; import { LRUMap } from "../infra/lruMap.ts"; +import { normalizeCoverUrl } from "../music/media.ts"; +import { formatArtists } from "../music/metadata.ts"; +import type { Track } from "../music/types.ts"; +import { trackUrl } from "../music/urls.ts"; +import type { CurrentTrack } from "../music/watcher.ts"; +import { getSettings } from "../settings.ts"; import type { UsersDb } from "../storage/users.ts"; -import { normalizeCoverUrl } from "../yandex/media.ts"; -import { formatArtists } from "../yandex/metadata.ts"; -import type { YaTrack } from "../yandex/types.ts"; -import { trackUrl } from "../yandex/urls.ts"; -import type { CurrentTrack } from "../yandex/ynison.ts"; const log = getLogger("services.now_playing"); -// Android-идентитет наблюдателя (сервер раскрывает больше полей известному app_name). -const WATCHER_DEVICE_INFO: DeviceInfoOverride = { ...ANDROID_DEVICE_INFO, title: "Мойва" }; - const NO_TOKEN_RETRY_MS = 60_000; -const TOKEN_POLL_MS = 30_000; -const TRACKS_LOOKUP_TIMEOUT_MS = 15_000; -const TRACK_CACHE_LIMIT = 200; -// ws жива, но фреймы молчат дольше порога → форс-реконнект (broken pipe без RST). -const STALE_WS_MS = 120_000; +const IDLE_MIN_MS = 5_000; +const IDLE_MAX_MS = 30_000; const RECENT_HISTORY_MAX = 20; +const HTTP_TIMEOUT_MS = 5_000; -/** JSON-safe снимок трека для /now-playing (текущий и история). */ interface NowPlayingTrack { - id: number | string | null; + id: string | null; artist: string; title: string; album: string | null; @@ -50,32 +38,77 @@ interface NowPlayingTrack { cover: string; } -/** эмитит "change" при каждой смене this.current (для SSE /now-playing/stream). */ +interface SpotifyImage { + url: string; + width?: number; + height?: number; +} +interface RawTrack { + id: string; + name: string; + duration_ms: number; + explicit: boolean; + artists: Array<{ id: string; name: string }>; + album?: { id: string; name: string; release_date?: string; images?: SpotifyImage[] }; + external_ids?: { isrc?: string }; +} +interface RawCurrent { + is_playing?: boolean; + progress_ms?: number; + timestamp?: number; + item?: RawTrack | null; + context?: { uri?: string; type?: string }; +} + +function pickCover(images: SpotifyImage[] | undefined): string { + if (!images || images.length === 0) return ""; + const mid = images.find((i) => (i.width ?? 0) >= 300 && (i.width ?? 0) <= 640); + return normalizeCoverUrl(mid?.url || images[0]!.url); +} + +function mapRaw(item: RawTrack): Track { + return { + id: item.id, + title: item.name, + durationMs: item.duration_ms, + explicit: item.explicit, + artists: item.artists.map((a) => ({ id: a.id, name: a.name })), + albums: item.album + ? [ + { + id: item.album.id, + title: item.album.name, + year: item.album.release_date ? item.album.release_date.slice(0, 4) : "", + coverUri: pickCover(item.album.images), + }, + ] + : [], + coverUri: pickCover(item.album?.images), + isrc: item.external_ids?.isrc ?? null, + }; +} + export class NowPlayingWatcher extends EventEmitter { - // публичный — inline fast-path сверяет watcher.ownerId === user.id. readonly ownerId: number; - // JSON-safe snapshot для /now-playing. current: NowPlayingTrack | null = null; - // последние сыгранные треки (новый в начале), из живых trackChange-событий — - // без похода за Yandex-историей, ограничено памятью процесса. private recentHistory: NowPlayingTrack[] = []; - - private readonly usersDb: UsersDb; + // последний снимок для inline fast-path. + private lastSnapshot: { + track: Track; + paused: boolean; + duration_ms: number; + progress_ms: number; + fetchedAt: number; + } | null = null; + private connected = false; private running = false; private stopRequested = false; private runPromise: Promise | null = null; private readonly sleeper = createInterruptibleSleep(); - - private rt: RealtimeClient | null = null; - private lib: LibClient | null = null; - private libToken: string | null = null; - // true с первого state-фрейма сессии (событие open), false на реконнект/stale. - private connected = false; - // lookup-кэш lib-Track по playable_id. - private trackCache = new LRUMap(TRACK_CACHE_LIMIT); - // СТАБИЛЬНЫЙ id на весь процесс: переживает и реконнекты (внутри RealtimeClient), - // и пересоздание клиента при смене токена — иначе Ynison дедуплицирует наблюдателя. - private readonly deviceId = generateDeviceId(); + private readonly usersDb: UsersDb; + private readonly http = new HttpClient(); + private trackCache = new LRUMap(200); + private lastPollAt = 0; constructor(usersDb: UsersDb, ownerId: number) { super(); @@ -83,26 +116,20 @@ export class NowPlayingWatcher extends EventEmitter { this.ownerId = ownerId; } - /** для inline-handler: {track, paused, duration_ms, progress_ms}. - * progress_ms уже live-интерполирован RealtimeClient.nowPlaying. */ getCurrentSnapshot(): Pick | null { - const np = this.rt?.nowPlaying; - if (!np || np.track === null) return null; - return { - track: np.track as unknown as YaTrack, - paused: np.paused, - duration_ms: np.durationMs, - progress_ms: np.progressMs, - }; + if (!this.lastSnapshot) return null; + const { track, paused, duration_ms, progress_ms, fetchedAt } = this.lastSnapshot; + const elapsed = paused ? 0 : Date.now() - fetchedAt; + const live = Math.max(0, Math.min(progress_ms + elapsed, duration_ms || progress_ms + elapsed)); + return { track, paused, duration_ms, progress_ms: live }; } - /** компактный статус для /health. */ health(): { running: boolean; connected: boolean; lastStateAgeMs: number | null; hasTrack: boolean } { return { running: this.running, connected: this.connected, - lastStateAgeMs: this.rt?.lastStateAgeMs ?? null, - hasTrack: (this.rt?.nowPlaying?.track ?? null) !== null, + lastStateAgeMs: this.lastPollAt ? Date.now() - this.lastPollAt : null, + hasTrack: (this.lastSnapshot?.track ?? null) !== null, }; } @@ -116,7 +143,6 @@ export class NowPlayingWatcher extends EventEmitter { async stop(): Promise { this.stopRequested = true; this.sleeper.wake(); - this.rt?.stop(); if (this.runPromise) { try { await this.runPromise; @@ -126,59 +152,49 @@ export class NowPlayingWatcher extends EventEmitter { } this.runPromise = null; this.running = false; + await this.http.close().catch(() => {}); } - /** ленивый lib-клиент под текущий токен; пересоздаём при смене токена. */ - private ensureLib(token: string): LibClient { - if (this.lib !== null && this.libToken === token) return this.lib; - this.lib = new LibClient({ token }); - this.libToken = token; - this.trackCache = new LRUMap(TRACK_CACHE_LIMIT); - return this.lib; + getRecent(limit = 10): NowPlayingTrack[] { + return this.recentHistory.slice(0, Math.max(0, limit)); } - private async resolveTrack(playableId: string): Promise { - const cached = this.trackCache.get(playableId); - if (cached !== undefined) return cached; - if (this.lib === null) return null; - let tracks: LibTrack[]; + private async readToken(): Promise { try { - tracks = await withTimeout(this.lib.tracks([playableId]), TRACKS_LOOKUP_TIMEOUT_MS, `tracks(${playableId})`); + return await this.usersDb.getToken(this.ownerId); } catch (e) { - log.warning(`[np] tracks(${playableId}) упал: ${e}`); + log.warning(`[np] getToken упал: ${e}`); return null; } - if (tracks.length === 0) return null; - const track = tracks[0]!; - this.trackCache.set(playableId, track); - return track; } - /** обновляет JSON-safe current из сменившегося трека; предыдущий трек (если - * реально другой, не повторный трекчейндж на тот же id) уходит в историю. */ - private updateCurrent(track: LibTrack | null): void { + private updateCurrent(track: Track | null): void { if (track === null) { - this.current = null; - this.emit("change"); + if (this.current !== null) { + this.current = null; + this.emit("change"); + } return; } - const t = track as unknown as YaTrack; - const album = t.albums && t.albums.length > 0 ? t.albums[0]! : null; + const album = track.albums && track.albums.length > 0 ? track.albums[0]! : null; const next: NowPlayingTrack = { - id: t.id ?? null, - artist: formatArtists(t.artists), - title: t.title ?? "", + id: track.id ?? null, + artist: formatArtists(track.artists), + title: track.title ?? "", album: album?.title ?? null, year: album?.year ?? null, - genre: album?.genre ?? t.metaData?.genre ?? null, - explicit: Boolean(t.explicit), - url: trackUrl(t.id ?? ""), - cover: t.coverUri ? normalizeCoverUrl(t.coverUri, "orig") : "", + genre: album?.genre ?? null, + explicit: Boolean(track.explicit), + url: track.id ? trackUrl(track.id) : "", + cover: track.coverUri ?? "", }; - if (this.current && this.current.id !== next.id) { - // realtime иногда флапает trackChange туда-обратно между теми же двумя - // id (заметно на паузе/резюме) — без дедупа тот же id всплывает в - // истории несколько раз, а дублирующиеся id ломают keyed-рендер на сайте. + if (this.current && this.current.id === next.id) { + // тот же трек — прогресс сменился, но карточка та же. Не эмитим change, + // т.к. SSE подписчикам достаточно heartbeat'а. + this.current = next; + return; + } + if (this.current) { this.recentHistory = this.recentHistory.filter((t) => t.id !== this.current!.id); this.recentHistory.unshift(this.current); if (this.recentHistory.length > RECENT_HISTORY_MAX) this.recentHistory.length = RECENT_HISTORY_MAX; @@ -187,50 +203,78 @@ export class NowPlayingWatcher extends EventEmitter { this.emit("change"); } - /** последние сыгранные треки, новый первым, без текущего. */ - getRecent(limit = 10): NowPlayingTrack[] { - return this.recentHistory.slice(0, Math.max(0, limit)); - } - - private async readToken(): Promise { + /** Один POLL /me/player/currently-playing. Возвращает интервал до следующего. */ + private async pollOnce(token: string): Promise { + const s = getSettings(); + const controller = new AbortController(); + const t = setTimeout(() => controller.abort(), HTTP_TIMEOUT_MS); + let res; try { - return await this.usersDb.getToken(this.ownerId); + res = await this.http.request("https://api.spotify.com/v1/me/player/currently-playing", { + headers: { Authorization: `Bearer ${token}` }, + }); } catch (e) { - log.warning(`[np] getToken упал: ${e}`); - return null; + clearTimeout(t); + log.warning(`[np] polling: ${e}`); + this.connected = false; + return IDLE_MAX_MS; } - } + clearTimeout(t); - /** ждёт смены токена владельца в БД (logout/login) либо stopRequested. - * Promise.race в run() не отменяет проигравшего — если rt.start() решит - * гонку первым, вызывающая сторона обязана дёрнуть cancel(), иначе этот - * цикл осиротеет и продолжит опрашивать БД раз в TOKEN_POLL_MS вечно, а - * каждая следующая итерация run() будет плодить ещё один такой же. */ - private watchToken(token: string): { promise: Promise; cancel: () => void } { - const { sleep: interruptibleSleep, wake } = createInterruptibleSleep(); - let cancelled = false; - const promise = (async () => { - while (!this.stopRequested && !cancelled) { - await interruptibleSleep(TOKEN_POLL_MS); - if (this.stopRequested || cancelled) return; - const fresh = await this.readToken(); - if (fresh && fresh !== token) { - log.info("[np] токен владельца сменился — пересоздаю realtime-клиент"); - return; - } - } - })(); - return { - promise, - cancel: () => { - cancelled = true; - wake(); - }, + if (res.status === 401) { + await res.body.dump(); + this.connected = false; + log.info("[np] 401 — токен протух, ждём обновления в БД"); + return NO_TOKEN_RETRY_MS; + } + if (res.status === 429) { + const retry = parseInt(String(res.headers["retry-after"] ?? "5"), 10); + await res.body.dump(); + return Math.max(retry, 1) * 1000; + } + if (res.status === 204) { + // пустое тело — Spotify: «плеер бездействует». + await res.body.dump(); + this.connected = true; + this.lastPollAt = Date.now(); + this.updateCurrent(null); + this.lastSnapshot = null; + return IDLE_MAX_MS; + } + if (res.status !== 200) { + const text = await res.body.text().catch(() => ""); + log.warning(`[np] HTTP ${res.status}: ${text.slice(0, 200)}`); + return IDLE_MAX_MS; + } + const raw = (await res.body.json()) as RawCurrent | null; + this.connected = true; + this.lastPollAt = Date.now(); + + if (!raw || !raw.item) { + this.updateCurrent(null); + this.lastSnapshot = null; + return IDLE_MAX_MS; + } + const track = this.getCachedOrMap(raw.item); + this.updateCurrent(track); + this.lastSnapshot = { + track, + paused: !raw.is_playing, + duration_ms: raw.item.duration_ms, + progress_ms: raw.progress_ms ?? 0, + fetchedAt: Date.now(), }; + return raw.is_playing ? s.SPOTIFY_NP_POLL_MS : IDLE_MIN_MS; + } + + private getCachedOrMap(raw: RawTrack): Track { + const cached = this.trackCache.get(raw.id); + if (cached) return cached; + const t = mapRaw(raw); + this.trackCache.set(raw.id, t); + return t; } - /** главный цикл: токен → realtime-клиент (он сам реконнектится) → пересоздание - * при смене токена. Цикл крутится, пока не stop(). */ private async run(): Promise { while (!this.stopRequested) { const token = await this.readToken(); @@ -239,50 +283,13 @@ export class NowPlayingWatcher extends EventEmitter { `[np] нет токена для owner_id=${this.ownerId}, retry через ${(NO_TOKEN_RETRY_MS / 1000).toFixed(0)}s`, ); this.current = null; + this.lastSnapshot = null; await this.sleeper.sleep(NO_TOKEN_RETRY_MS); continue; } - - this.ensureLib(token); - const rt = new RealtimeClient({ - token, - deviceId: this.deviceId, - deviceInfo: WATCHER_DEVICE_INFO, - staleTimeoutMs: STALE_WS_MS, - resolveTrack: (playableId) => this.resolveTrack(playableId), - }); - rt.on("open", () => { - this.connected = true; - }); - rt.on("reconnect", () => { - this.connected = false; - }); - rt.on("stale", (idleMs) => { - this.connected = false; - log.info(`[np] ws молчит ${(idleMs / 1000).toFixed(0)}s — переподключаюсь`); - }); - rt.on("trackChange", ({ track }) => this.updateCurrent(track)); - rt.on("error", (e) => log.warning(`[np] realtime: ${e.message}`)); - this.rt = rt; - - log.info("[np] подключение к Ynison"); - const watcher = this.watchToken(token); - try { - // rt.start() реконнектится бесконечно; гонка с watchToken рвёт его на смене токена. - await Promise.race([rt.start(), watcher.promise]); - } catch (e) { - log.error(`[np] неожиданная ошибка realtime-цикла: ${e}`); - } finally { - // если гонку решил rt.start() — watcher.promise иначе остался бы висеть - // и опрашивать БД вечно (см. комментарий watchToken); если решил сам - // watchToken — cancel() на уже завершённом цикле безвреден. - watcher.cancel(); - rt.stop(); - this.rt = null; - this.connected = false; - this.current = null; - this.emit("change"); - } + const wait = await this.pollOnce(token); + if (this.stopRequested) break; + await this.sleeper.sleep(wait); } } } diff --git a/src/services/search.ts b/src/services/search.ts index 178475b..430fa2e 100644 --- a/src/services/search.ts +++ b/src/services/search.ts @@ -10,11 +10,11 @@ import { getLogger } from "../infra/logging.ts"; import { LRUMap } from "../infra/lruMap.ts"; import type { TTLCache } from "../infra/memoryCache.ts"; import { parseSearchQuery } from "../infra/parsers.ts"; -import type { YandexClient } from "../yandex/client.ts"; -import { normalizeCoverUrl } from "../yandex/media.ts"; -import { formatArtists } from "../yandex/metadata.ts"; -import type { YaAlbum, YaTrack } from "../yandex/types.ts"; -import { albumUrl, playlistUrl } from "../yandex/urls.ts"; +import type { MusicClient } from "../music/client.ts"; +import { normalizeCoverUrl } from "../music/media.ts"; +import { formatArtists } from "../music/metadata.ts"; +import type { Album, Track } from "../music/types.ts"; +import { albumUrl, playlistUrl } from "../music/urls.ts"; import type { CacheService } from "./cache.ts"; const log = getLogger("services.search"); @@ -37,12 +37,12 @@ interface TrackMeta { } export class SearchService { - private trackObjects = new LRUMap(TRACK_LRU_MAX); + private trackObjects = new LRUMap(TRACK_LRU_MAX); // короткоживущий кэш пула истории — чтобы листание inline-«недавнего» не дёргало API. - private recentPool: { tracks: YaTrack[]; at: number } | null = null; + private recentPool: { tracks: Track[]; at: number } | null = null; constructor( - public yandexService: YandexClient, + public musicService: MusicClient, private resultsCache: TTLCache, private cacheService: CacheService | null, private botUsername: string | null, @@ -52,15 +52,15 @@ export class SearchService { private userId: number | null = null, ) {} - cacheTrackObject(track: YaTrack): void { + cacheTrackObject(track: Track): void { this.trackObjects.set(String(track.id), track); } - getTrackObject(trackId: string): YaTrack | null { + getTrackObject(trackId: string): Track | null { return this.trackObjects.get(trackId) ?? null; } - private getTrackMeta(track: YaTrack): TrackMeta { + private getTrackMeta(track: Track): TrackMeta { const duration = track.durationMs ? Math.floor(track.durationMs / 1000) : 0; const album = track.albums && track.albums.length > 0 ? track.albums[0]! : null; return { @@ -72,7 +72,7 @@ export class SearchService { } async createInlineResultFromTrack( - track: YaTrack, + track: Track, layout = "button", sendMode = "text_media", signal?: AbortSignal, @@ -103,7 +103,7 @@ export class SearchService { } /** Article «гружу X — Y», на choose заменяется на аудио через editMessageMedia. */ - private createTextMediaResult(track: YaTrack, meta: TrackMeta): InlineResult { + private createTextMediaResult(track: Track, meta: TrackMeta): InlineResult { const trackId = String(track.id); const artist = meta.artistNames; const title = (track.title || "").trim() || "—"; @@ -121,8 +121,8 @@ export class SearchService { ); } - private async createFallbackResult(track: YaTrack, meta: TrackMeta): Promise { - const audioUrl = await this.yandexService.getDownloadUrl(track); + private async createFallbackResult(track: Track, meta: TrackMeta): Promise { + const audioUrl = await this.musicService.getDownloadUrl(track); if (!audioUrl) throw new Error("нет вариантов скачивания для трека"); return InlineQueryResult.audio(String(track.id), audioUrl, track.title || "—", { performer: meta.artistNames, @@ -131,7 +131,7 @@ export class SearchService { } private async createCachedResult( - track: YaTrack, + track: Track, meta: TrackMeta, layout: string, signal?: AbortSignal, @@ -193,7 +193,7 @@ export class SearchService { return rows.every((r) => r?.is_cached === true); } - private createAlbumResult(album: YaAlbum): InlineResult | null { + private createAlbumResult(album: Album): InlineResult | null { if (!this.botUsername) return null; const albumId = album.id; if (albumId === undefined || albumId === null) return null; @@ -222,29 +222,25 @@ export class SearchService { }); } - /** плейлист — статья с кнопкой deep-link'а, как у альбома. */ + /** плейлист — статья с кнопкой deep-link'а, как у альбома. + * У Spotify owner в URL нет — оставлен для совместимости сигнатуры (пусто). */ private createPlaylistResult( - owner: string, - kind: number, + _owner: string, + kind: string, pl: { title: string; coverUri: string; trackCount: number }, ): InlineResult | null { if (!this.botUsername) return null; - // payload deep-link'а телеграм принимает только из [A-Za-z0-9_-], а owner - // бывает с точкой — такому плейлисту кнопку просто не показываем. - // ponytail: если точечные логины окажутся частыми — кодировать owner в payload. - if (!/^[\w-]+$/.test(owner)) return null; const title = pl.title || "Без названия"; const suffix = pl.trackCount ? `${pl.trackCount} треков` : ""; - const link = playlistUrl(owner, kind); + const link = playlistUrl("", kind); const text = suffix ? format`🎵 ${bold(title)}\n${italic(suffix)}\n\n${link}` : format`🎵 ${bold(title)}\n\n${link}`; const thumbUrl = normalizeCoverUrl(pl.coverUri, "200x200"); - // owner может содержать '_', поэтому kind якорится хвостовым _\d+$ (см. START_PLAYLIST_RE) - const deepLink = `https://t.me/${this.botUsername}?start=playlist_${owner}_${kind}`; - return InlineQueryResult.article(`playlist:${owner}:${kind}`, `🎵 ${title}`, InputMessageContent.text(text), { + const deepLink = `https://t.me/${this.botUsername}?start=playlist_${kind}`; + return InlineQueryResult.article(`playlist:${kind}`, `🎵 ${title}`, InputMessageContent.text(text), { description: suffix || "плейлист", ...(thumbUrl ? { thumbnail_url: thumbUrl } : {}), reply_markup: { inline_keyboard: [[{ text: "выгрузить плейлист мне в личку", url: deepLink }]] }, @@ -277,7 +273,7 @@ export class SearchService { let trackIdsInResult: (string | number)[] = []; if (parsed.type === "track_link") { - const track = await this.yandexService.getTrackById(parsed.track_id); + const track = await this.musicService.getTrackById(parsed.track_id); if (track) { const r = await this.safeCreateTrack(track, layout, sendMode, signal); if (r) { @@ -286,13 +282,13 @@ export class SearchService { } } } else if (parsed.type === "album_link") { - const album = await this.yandexService.getAlbumShort(parsed.album_id); + const album = await this.musicService.getAlbumShort(parsed.album_id); if (album) { const r = this.createAlbumResult(album); if (r) results.push(r); } } else if (parsed.type === "playlist_link") { - const pl = await this.yandexService.getPlaylistShort(parsed.owner, parsed.kind); + const pl = await this.musicService.getPlaylistShort(parsed.owner, parsed.kind); if (pl) { const r = this.createPlaylistResult(parsed.owner, parsed.kind, pl); if (r) results.push(r); @@ -301,8 +297,8 @@ export class SearchService { // tracksOnly — для поиска по строке лирики (" ...): нужен только трек, // альбомы тут шум. const [tracks, albums] = await Promise.all([ - this.yandexService.searchTracks(query, maxResults), - tracksOnly ? Promise.resolve([] as YaAlbum[]) : this.yandexService.searchAlbums(query, 2), + this.musicService.searchTracks(query, maxResults), + tracksOnly ? Promise.resolve([] as Album[]) : this.musicService.searchAlbums(query, 2), ]); const trackResults = tracks.length > 0 ? await this.createTrackResultsParallel(tracks, layout, sendMode, signal) : []; @@ -310,7 +306,7 @@ export class SearchService { results = [...trackResults, ...albumResults]; // альбомы вторым блоком trackIdsInResult = tracks.map((t) => t.id!); } else if (parsed.type === "empty") { - const current = await this.yandexService.getCurrentTrack(); + const current = await this.musicService.getCurrentTrack(); if (current.track) { const r = await this.safeCreateTrack(current.track, layout, sendMode, signal); if (r) results.push(r); @@ -346,7 +342,7 @@ export class SearchService { const ypage = Math.max(0, parseInt(ypageStr || "0", 10) || 0); const within = Math.max(0, parseInt(withinStr || "0", 10) || 0); - const { tracks, total, perPage } = await this.yandexService.searchTracksPage(query, ypage); + const { tracks, total, perPage } = await this.musicService.searchTracksPage(query, ypage); const slice = tracks.slice(within, within + pageSize); const cacheKey = `paged:${layout}:${sendMode}:${query}:${ypage}:${within}:${pageSize}`; @@ -370,10 +366,10 @@ export class SearchService { } /** пул недавнего (история) с коротким кэшем — листание не дёргает API каждую страницу. */ - private async getRecentPool(): Promise { + private async getRecentPool(): Promise { const now = Date.now(); if (this.recentPool && now - this.recentPool.at < RECENT_POOL_TTL_MS) return this.recentPool.tracks; - const tracks = await this.yandexService.getRecentTracks(RECENT_POOL_SIZE); + const tracks = await this.musicService.getRecentTracks(RECENT_POOL_SIZE); this.recentPool = { tracks, at: now }; return tracks; } @@ -407,7 +403,7 @@ export class SearchService { } private async createTrackResultsParallel( - tracks: YaTrack[], + tracks: Track[], layout: string, sendMode: string, signal?: AbortSignal, @@ -432,7 +428,7 @@ export class SearchService { } private async safeCreateTrack( - track: YaTrack, + track: Track, layout: string, sendMode: string, signal?: AbortSignal, diff --git a/src/services/stale.ts b/src/services/stale.ts index 850a8da..daa204c 100644 --- a/src/services/stale.ts +++ b/src/services/stale.ts @@ -7,8 +7,8 @@ import { createInterruptibleSleep } from "../infra/async.ts"; import { getLogger } from "../infra/logging.ts"; import type { CacheDb } from "../storage/cache.ts"; import type { UsersDb } from "../storage/users.ts"; -import type { YandexClient } from "../yandex/client.ts"; -import { buildTrackMetadata, enrichMetadataFromAlbum, trackIsTooLong } from "../yandex/metadata.ts"; +import type { MusicClient } from "../music/client.ts"; +import { buildTrackMetadata, enrichMetadataFromAlbum, trackIsTooLong } from "../music/metadata.ts"; import type { CacheMetadata, CacheService } from "./cache.ts"; const log = getLogger("services.stale"); @@ -19,8 +19,8 @@ export interface StaleDeps { cacheService: CacheService; /** расшифровать/обновить токен юзера. */ resolveToken: (userId: number) => Promise; - /** получить YandexClient под токеном. */ - getYandexClient: (token: string, userId: number) => Promise; + /** получить MusicClient под токеном. */ + getMusicClient: (token: string, userId: number) => Promise; } export class StaleRefresher { @@ -77,14 +77,14 @@ export class StaleRefresher { return; } - const yandex = await this.deps.getYandexClient(token, userId); + const music = await this.deps.getMusicClient(token, userId); let refreshed = 0; for (const entry of stale) { if (this.stopped) break; // shutdown — не начинаем ещё один тяжёлый докачивание const trackId = entry.track_id; try { - const track = await yandex.getTrackById(Number(trackId)); + const track = await music.getTrackById(Number(trackId)); if (!track) { await this.deps.cacheDb.touchStale(trackId); continue; @@ -95,8 +95,8 @@ export class StaleRefresher { continue; } let metadata: CacheMetadata = buildTrackMetadata(track); - metadata = (await enrichMetadataFromAlbum(metadata, track, yandex)) as CacheMetadata; - const info = await yandex.getBestDownloadInfo(track); + metadata = (await enrichMetadataFromAlbum(metadata, track, music)) as CacheMetadata; + const info = await music.getBestDownloadInfo(track); if (info === null) { await this.deps.cacheDb.touchStale(trackId); continue; @@ -108,7 +108,7 @@ export class StaleRefresher { // за download-info на один трек вместо одного (см. albums.ts — там уже так). const fileId = await this.deps.cacheService.downloadAndCacheTrack( trackId, - () => yandex.directLinkFromInfo(info), + () => music.directLinkFromInfo(info), metadata, info.codec, ); diff --git a/src/settings.ts b/src/settings.ts index ba6d6da..eac1f65 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -48,10 +48,12 @@ export interface Settings { HTTP_CONNECTION_LIMIT: number; HTTP_LIMIT_PER_HOST: number; - YANDEX_RATE_LIMIT_INTERVAL: number; - YANDEX_MAX_RETRIES: number; - YANDEX_OAUTH_CLIENT_ID: string; - YANDEX_OAUTH_CLIENT_SECRET: string; + SPOTIFY_RATE_LIMIT_INTERVAL: number; + SPOTIFY_MAX_RETRIES: number; + /** Client ID Spotify-приложения (my apps → create app). Обязателен. */ + SPOTIFY_CLIENT_ID: string; + /** Интервал polling'а /me/player/currently-playing в мс (по умолчанию 3000). */ + SPOTIFY_NP_POLL_MS: number; DOWNLOAD_QUEUE_CONCURRENCY: number; ALBUM_PARALLEL_TRACKS: number; @@ -109,10 +111,10 @@ function build(): Settings { HTTP_CONNECTION_LIMIT: envInt("HTTP_CONNECTION_LIMIT", "100"), HTTP_LIMIT_PER_HOST: envInt("HTTP_LIMIT_PER_HOST", "30"), - YANDEX_RATE_LIMIT_INTERVAL: envFloat("YANDEX_RATE_LIMIT_INTERVAL", "1.0"), - YANDEX_MAX_RETRIES: envInt("YANDEX_MAX_RETRIES", "3"), - YANDEX_OAUTH_CLIENT_ID: env("YANDEX_OAUTH_CLIENT_ID", "23cabbbdc6cd418abb4b39c32c41195d"), - YANDEX_OAUTH_CLIENT_SECRET: env("YANDEX_OAUTH_CLIENT_SECRET", "53bc75238f0c4d08a118e51fe9203300"), + SPOTIFY_RATE_LIMIT_INTERVAL: envFloat("SPOTIFY_RATE_LIMIT_INTERVAL", "0.2"), + SPOTIFY_MAX_RETRIES: envInt("SPOTIFY_MAX_RETRIES", "3"), + SPOTIFY_CLIENT_ID: env("SPOTIFY_CLIENT_ID"), + SPOTIFY_NP_POLL_MS: envInt("SPOTIFY_NP_POLL_MS", "3000"), DOWNLOAD_QUEUE_CONCURRENCY: envInt("DOWNLOAD_QUEUE_CONCURRENCY", "6"), ALBUM_PARALLEL_TRACKS: envInt("ALBUM_PARALLEL_TRACKS", "4"), @@ -154,6 +156,9 @@ function validate(s: Settings): void { ["TOKEN_ENCRYPTION_KEY", s.TOKEN_ENCRYPTION_KEY], ["POSTGRES_USERS_DSN", s.POSTGRES_USERS_DSN], ["POSTGRES_CACHE_DSN", s.POSTGRES_CACHE_DSN], + // SPOTIFY_CLIENT_ID проверяется отдельно в services/auth.ts при первой + // попытке залогиниться, чтобы юнит-тесты, не касающиеся Spotify, не + // требовали его в env. ]; for (const [name, val] of required) { if (!val) throw new Error(`${name} не задан в .env`); diff --git a/src/storage/cache.ts b/src/storage/cache.ts index eb5c40a..a2741a5 100644 --- a/src/storage/cache.ts +++ b/src/storage/cache.ts @@ -46,7 +46,7 @@ export class CacheDb { // UNLOGGED — кэш файлов, можем потерять при crash postgres, не критично await cachePool.query(` CREATE UNLOGGED TABLE IF NOT EXISTS telegram_cache ( - track_id BIGINT PRIMARY KEY, + track_id TEXT PRIMARY KEY, telegram_file_id TEXT NOT NULL, telegram_message_id BIGINT, is_cached BOOLEAN NOT NULL DEFAULT FALSE, @@ -90,9 +90,9 @@ export class CacheDb { id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, chat_id BIGINT NOT NULL, - album_id BIGINT NOT NULL, + album_id TEXT NOT NULL, progress_message_id BIGINT, - completed_track_ids BIGINT[] NOT NULL DEFAULT '{}', + completed_track_ids TEXT[] NOT NULL DEFAULT '{}', started_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); @@ -102,7 +102,7 @@ export class CacheDb { async get(trackId: string | number, quality = "lossy"): Promise { const res = await cachePool.query>( `SELECT ${SELECT_COLS} FROM telegram_cache WHERE track_id = $1 AND quality = $2`, - [Number(trackId), quality], + [String(trackId), quality], ); const row = res.rows[0]; return row === undefined ? null : rowToCachedTrack(row); @@ -180,7 +180,7 @@ export class CacheDb { async touchStale(trackId: string | number, quality = "lossy"): Promise { await cachePool.query( "UPDATE telegram_cache SET updated_at = NOW() WHERE track_id = $1 AND quality = $2 AND is_cached = FALSE", - [Number(trackId), quality], + [String(trackId), quality], ); } @@ -213,7 +213,7 @@ export class CacheDb { async insertInflightAlbum(args: { user_id: number; chat_id: number; - album_id: number; + album_id: string; progress_message_id: number | null; }): Promise { return cachePool.transaction(async (c) => { @@ -226,7 +226,7 @@ export class CacheDb { }); } - async markInflightTrackDone(inflightId: number, trackId: number): Promise { + async markInflightTrackDone(inflightId: number, trackId: string): Promise { await cachePool.query( "UPDATE album_inflight SET completed_track_ids = array_append(completed_track_ids, $2) WHERE id = $1 AND NOT ($2 = ANY(completed_track_ids))", [inflightId, trackId], @@ -249,9 +249,9 @@ export class CacheDb { id: r.id as number, user_id: r.user_id as number, chat_id: r.chat_id as number, - album_id: r.album_id as number, + album_id: String(r.album_id), progress_message_id: (r.progress_message_id as number | null) ?? null, - completed_track_ids: ((r.completed_track_ids as number[]) ?? []).map(Number), + completed_track_ids: ((r.completed_track_ids as string[]) ?? []).map(String), })); } diff --git a/src/storage/pool.ts b/src/storage/pool.ts index d2f9181..0ca70c9 100644 --- a/src/storage/pool.ts +++ b/src/storage/pool.ts @@ -2,7 +2,7 @@ * pg-пулы к двум БД: users (токены/настройки) и cache (file_id треков). * * BIGINT (int8, OID 20) парсим в Number: все наши bigint'ы (telegram id, - * yandex track/album id, epoch-секунды) надёжно < 2^53. + * music track/album id, epoch-секунды) надёжно < 2^53. */ import pg from "pg"; import { getLogger } from "../infra/logging.ts"; diff --git a/src/storage/settings.ts b/src/storage/settings.ts index 1ab2847..2f8ee34 100644 --- a/src/storage/settings.ts +++ b/src/storage/settings.ts @@ -8,7 +8,7 @@ const TRACK_SEND_MODES = new Set(["stub", "text_media"]); const DEFAULT_TRACK_SEND_MODE = "text_media"; // качество звука: best — макс. битрейт, economy — ≤192 (меньше вес, влезает в 50МБ). -export const TRACK_QUALITIES = new Set(["best", "economy", "lossless"]); +export const TRACK_QUALITIES = new Set(["best", "economy"]); const DEFAULT_TRACK_QUALITY = "best"; const CARD_LAYOUTS = new Set(["button", "text", "both", "none"]); diff --git a/src/storage/types.ts b/src/storage/types.ts index 53dbf66..5d4594e 100644 --- a/src/storage/types.ts +++ b/src/storage/types.ts @@ -26,7 +26,9 @@ export interface InflightAlbum { id: number; user_id: number; chat_id: number; - album_id: number; + /** Spotify id альбома — строка (22 base62). */ + album_id: string; progress_message_id: number | null; - completed_track_ids: number[]; + /** Spotify id-ы уже отправленных треков — строки. */ + completed_track_ids: string[]; } diff --git a/src/tagging/service.ts b/src/tagging/service.ts index 2222a32..c86e15f 100644 --- a/src/tagging/service.ts +++ b/src/tagging/service.ts @@ -17,7 +17,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { type AudioFile, TagLib } from "taglib-wasm"; import { getLogger } from "../infra/logging.ts"; -import type { TrackMetadata } from "../yandex/types.ts"; +import type { TrackMetadata } from "../music/types.ts"; const log = getLogger("tagging"); diff --git a/src/yandex/client.ts b/src/yandex/client.ts deleted file mode 100644 index 4e7e7b2..0000000 --- a/src/yandex/client.ts +++ /dev/null @@ -1,724 +0,0 @@ -/** Высокоуровневый клиент Yandex Music: кэши, retry, rate-limit, refresh. - * Поверх @dvxch/yandex-music (каталог/лайки/ynison/download). */ -import { performance } from "node:perf_hooks"; -import { - decryptEncraw, - Client as LibClient, - type DownloadInfo as LibDownloadInfo, - type LosslessDownloadInfo as LibLosslessDownloadInfo, - NetworkError as LibNetworkError, - NotFoundError as LibNotFoundError, - UnauthorizedError as LibUnauthorizedError, -} from "@dvxch/yandex-music"; -import { sleep, withTimeout } from "../infra/async.ts"; -import { getLogger } from "../infra/logging.ts"; -import { LRUMap } from "../infra/lruMap.ts"; -import { AsyncTokenGate } from "../infra/rateLimit.ts"; -import { getSettings } from "../settings.ts"; -import { isLrc } from "./lrc.ts"; -import { normalizeCoverUrl } from "./media.ts"; -import type { AlbumShortProvider, LikeProvider, WaveColorProvider } from "./metadata.ts"; -import { type AudioQuality, bareTrackId, formatArtists, selectDownloadInfo, waveColorFromLottie } from "./metadata.ts"; -import type { AlbumData, DownloadInfo, YaAlbum, YaTrack } from "./types.ts"; -import { type CurrentTrack, YnisonService } from "./ynison.ts"; - -const log = getLogger("yandex.client"); - -/** Фабрика расшифровщика encraw-потока (FLAC) по hex-ключу из get-file-info, или - * undefined для незашифрованных. */ -export function makeEncrawDecrypt(key: string | null | undefined): ((data: Buffer) => Buffer) | undefined { - if (!key) return undefined; - return (data: Buffer): Buffer => Buffer.from(decryptEncraw(data, key)); -} - -/** async () → новый access_token или null. */ -export type RefreshCallback = () => Promise; - -const CLIENT_INIT_TIMEOUT = 9_000; -const NP_CACHE_TTL_MS = 5_000; -const NP_FETCH_TIMEOUT_MS = 4_000; -const LIKES_CACHE_TTL_MS = 30_000; -// сколько API-вызовов подряд можно сделать без rate-задержки (интерактивный burst) -const RATE_BURST = 5; - -export class YandexClient implements WaveColorProvider, LikeProvider, AlbumShortProvider { - private token: string; - // библиотечный клиент @dvxch/yandex-music — единый транспорт (каталог/лайки/ - // ynison/download/lyrics/history/landing). - private lib: LibClient | null = null; - private ynison: YnisonService | null = null; - - // token-bucket: burst мгновенных вызовов + долив 1/minInterval. Интерактивные - // потоки не платят rate-задержку, массовые (альбом) спейсятся; 429 страхует withRetry. - private readonly gate: AsyncTokenGate; - private readonly maxRetries: number; - private readonly refreshCb: RefreshCallback | null; - - // дедуп параллельных get_current_track - private npInflight: Promise | null = null; - private npCache: CurrentTrack | null = null; - private npCacheAt = 0; - // кэш кураторских wave-цветов по album_id - private waveColorCache = new LRUMap(500); - // кэш set лайкнутых трек-id (строки) с TTL - private likesCache: Set | null = null; - private likesCacheAt = 0; - // кэш лирики по track_id (null = нет лирики) - private lyricsCache = new LRUMap(1000); - - constructor(token: string, refreshCallback: RefreshCallback | null = null) { - if (!token || typeof token !== "string") throw new Error("токен должен быть непустой строкой"); - this.token = token; - const s = getSettings(); - this.gate = new AsyncTokenGate(RATE_BURST, s.YANDEX_RATE_LIMIT_INTERVAL * 1000); - this.maxRetries = s.YANDEX_MAX_RETRIES; - this.refreshCb = refreshCallback; - } - - /** явная инициализация (валидация токена через lib.init → account/status). */ - async init(): Promise { - await this.getLib(); - } - - /** ленивый библиотечный клиент (init загружает аккаунт + валидирует токен). */ - private async getLib(): Promise { - if (this.lib === null) { - const lib = new LibClient({ token: this.token }); - await withTimeout(lib.init(), CLIENT_INIT_TIMEOUT, "lib-init"); - this.lib = lib; - } - return this.lib; - } - - private async getYnison(): Promise { - if (this.ynison === null) { - const lib = await this.getLib(); - this.ynison = new YnisonService(lib, this.token); - } - return this.ynison; - } - - private async tryRefreshToken(): Promise { - if (this.refreshCb === null) return false; - let newToken: string | null; - try { - newToken = await this.refreshCb(); - } catch (e) { - log.warning(`refresh_callback бросил: ${e}`); - return false; - } - if (!newToken) return false; - this.token = newToken; - // сбрасываем — следующий getLib пересоздаст с новым токеном - this.lib = null; - this.ynison = null; - log.info("токен обновлён реактивно после 401"); - return true; - } - - /** func() с retry: 401 → refresh+повтор, 429 → экспоненциальный backoff. */ - private async withRetry(func: () => Promise, baseDelayMs = 2000): Promise { - let lastError: unknown = null; - let refreshTried = false; - - for (let attempt = 0; attempt <= this.maxRetries; attempt++) { - try { - await this.gate.acquire(); - return await func(); - } catch (e) { - // библиотека для 401/403 всегда кидает типизированный UnauthorizedError - // (request.ts: handleErrorResponse) — доверяем instanceof, а не - // substring-мэтчу текста ошибки: "401"/"429" может встретиться в - // сообщении по совпадению (например Invalid track id: "42912345"), - // и тогда невалидный трек ретраился бы как будто он рейт-лимит/401. - const isUnauthorized = e instanceof LibUnauthorizedError; - - if (isUnauthorized && !refreshTried) { - lastError = e; - if (await this.tryRefreshToken()) { - refreshTried = true; - continue; // повтор без счётчика — это смена токена, не наш rate limit - } - throw e; - } - - // 429 у библиотеки не имеет своего класса — падает в generic - // NetworkError с текстом "... (429)" на конце (see request.ts). Матчим - // именно этот формат, привязанный к концу строки, а не голую подстроку. - const isRateLimit = e instanceof LibNetworkError && /\(429\)$/.test(e.message); - if (isRateLimit && attempt < this.maxRetries) { - const delay = baseDelayMs * 2 ** attempt + Math.random() * 1000; - log.warning(`rate limit, попытка ${attempt + 1}/${this.maxRetries + 1}, ждём ${(delay / 1000).toFixed(1)}с`); - await sleep(delay); - lastError = e; - } else { - throw e; - } - } - } - throw new Error(`превышено число попыток: ${lastError}`); - } - - /** withRetry + единый catch-лог-фолбэк для чтения через lib. */ - private async withLib( - label: string, - fallback: T, - func: (lib: LibClient) => Promise, - level: "error" | "warning" = "error", - ): Promise { - try { - return await this.withRetry(async () => func(await this.getLib())); - } catch (e) { - log[level](`ошибка ${label}: ${e}`); - return fallback; - } - } - - async getTrackById(trackId: number | string): Promise { - return this.withLib(`getTrackById(${trackId})`, null, async (lib) => { - const tracks = await lib.tracks([trackId]); - return tracks.length > 0 ? (tracks[0] as unknown as YaTrack) : null; - }); - } - - async searchTracks(query: string, limit?: number): Promise { - const lim = limit ?? getSettings().MAX_SEARCH_RESULTS; - return this.withLib(`searchTracks(${JSON.stringify(query)})`, [], async (lib) => { - const result = await lib.search(query, false, "track"); - const tracks = (result?.tracks?.results ?? []) as unknown as YaTrack[]; - return tracks.slice(0, lim); - }); - } - - /** полная страница поиска треков (для inline-пагинации): результаты + total/perPage. */ - async searchTracksPage(query: string, page = 0): Promise<{ tracks: YaTrack[]; total: number; perPage: number }> { - return this.withLib( - `searchTracksPage(${JSON.stringify(query)}, ${page})`, - { tracks: [], total: 0, perPage: 0 }, - async (lib) => { - const result = await lib.search(query, false, "track", page); - const t = result?.tracks; - return { - tracks: (t?.results ?? []) as unknown as YaTrack[], - total: t?.total ?? 0, - perPage: t?.perPage ?? 0, - }; - }, - ); - } - - /** исправленный/канонический текст запроса через /search/suggest (best.text). - * Лучший-эффект: null при ошибке/отсутствии подсказки. */ - async suggestCorrection(query: string): Promise { - return this.withLib( - `suggestCorrection(${JSON.stringify(query)})`, - null, - async (lib) => { - const res = await lib.searchSuggest(query); - return res?.best?.text?.trim() || null; - }, - "warning", - ); - } - - /** альбом БЕЗ трек-листа (1 запрос) — добор year/labels/genre. */ - async getAlbumShort(albumId: number | string): Promise { - return this.withLib(`getAlbumShort(${albumId})`, null, async (lib) => { - const result = await lib.albums([albumId]); - return result.length > 0 ? (result[0] as unknown as YaAlbum) : null; - }); - } - - /** кураторский hex-цвет wave-агента альбома. кэш вечный, на ошибке → null. */ - async getAlbumWaveColor(albumId: number): Promise { - if (this.waveColorCache.has(albumId)) return this.waveColorCache.get(albumId)!; - let color: string | null = null; - try { - color = await withTimeout(this.fetchAlbumWaveColor(albumId), 5_000, "wave-color"); - } catch (e) { - log.debug(`wave-color ${albumId}: ${e}`); - } - this.waveColorCache.set(albumId, color); - return color; - } - - private async fetchAlbumWaveColor(albumId: number): Promise { - const lib = await this.getLib(); - const se = await lib.albumsSimilarEntities(albumId); - const items = (se?.items ?? []) as Array<{ - type?: string; - data?: { agent?: { animationUri?: string } }; - }>; - for (const it of items) { - if (it.type !== "wave_agent_item") continue; - const uri = it.data?.agent?.animationUri; - if (uri) { - const raw = await lib.request.retrieve(uri); - return waveColorFromLottie(Buffer.from(raw)); - } - } - return null; - } - - /** множество id лайкнутых треков (строки, без album-части). кэш 30с. */ - async getLikedTrackIds(): Promise> { - const now = performance.now(); - if (this.likesCache !== null && now - this.likesCacheAt < LIKES_CACHE_TTL_MS) { - return this.likesCache; - } - let ids: Set; - try { - ids = await this.withRetry(async () => { - const lib = await this.getLib(); - const library = await lib.usersLikesTracks(); - return new Set((library?.tracks ?? []).map((t) => bareTrackId(t.id))); - }); - } catch (e) { - log.debug(`usersLikesTracks: ${e}`); - return this.likesCache ?? new Set(); - } - this.likesCache = ids; - this.likesCacheAt = now; - return ids; - } - - async isTrackLiked(trackId: number | string): Promise { - const ids = await this.getLikedTrackIds(); - return ids.has(bareTrackId(trackId)); - } - - /** ставит/снимает лайк; синхронит локальный likesCache (без лишнего фетча). */ - async setTrackLiked(trackId: number | string, liked: boolean): Promise { - const id = bareTrackId(trackId); - const ok = await this.withRetry(async () => { - const lib = await this.getLib(); - return liked ? await lib.usersLikesTracksAdd(id) : await lib.usersLikesTracksRemove(id); - }); - if (ok && this.likesCache !== null) { - if (liked) this.likesCache.add(id); - else this.likesCache.delete(id); - } else if (ok) { - // кэша ещё не было — точечно не угадать, сбрасываем для свежего фетча - this.likesCacheAt = 0; - } - return ok; - } - - /** best-effort репорт "трек играет" в саму Яндекс.Музыку — без этого прослушивания - * через бота не попадают в историю аккаунта и не двигают "Мою волну". Без withRetry - * и без выброса наружу намеренно: это телеметрия, не критичная операция — неудача - * просто теряется, платить за неё бэкоффом/повторами незачем. */ - async reportPlayback(track: YaTrack): Promise { - const albumId = track.albums?.[0]?.id; - if (albumId === undefined) return; // playAudio требует albumId — без него нечего репортить - try { - const lib = await this.getLib(); - await lib.playAudio({ trackId: bareTrackId(track.id), albumId, from: "Мойва" }); - } catch (e) { - log.debug(`playAudio: ${e}`); - } - } - - /** недавно слушанные треки из личной истории (/music-history), плоско + дедуп. - * Фолбэк на лендинг (play-contexts), если история пуста/недоступна. */ - async getRecentTracks(limit = 12): Promise { - let ids = await this.recentIdsFromHistory(limit); - if (ids.length === 0) { - ids = await this.recentIdsFromLanding(limit); - } - if (ids.length === 0) return []; - try { - return await this.withRetry(async () => { - const lib = await this.getLib(); - return (await lib.tracks(ids)) as unknown as YaTrack[]; - }); - } catch (e) { - log.warning(`[recent] резолв треков: ${e}`); - return []; - } - } - - /** трек-id из /music-history: вкладки по датам → группы → tracks[].data.itemId. */ - private async recentIdsFromHistory(limit: number): Promise { - let history: Awaited>; - try { - const lib = await this.getLib(); - history = await lib.musicHistory(0); - } catch (e) { - log.warning(`music-history: ${e}`); - return []; - } - const ids: string[] = []; - const seen = new Set(); - const push = (raw: number | string | null | undefined) => { - if (raw === undefined || raw === null) return; - const id = bareTrackId(raw); - if (id && id !== "null" && !seen.has(id)) { - seen.add(id); - ids.push(id); - } - }; - const tabs = history?.historyTabs ?? []; - for (const tab of tabs) { - for (const group of tab.items ?? []) { - for (const item of group.tracks ?? []) { - push(item.data?.itemId?.trackId); - if (ids.length >= limit) break; - } - if (ids.length >= limit) break; - } - if (ids.length >= limit) break; - } - log.info(`[recent] music-history → ${ids.length} треков`); - return ids; - } - - /** легаси-источник: лендинг play-contexts (otherTracks[].trackId). */ - private async recentIdsFromLanding(limit: number): Promise { - let landing: Awaited>; - try { - const lib = await this.getLib(); - landing = await lib.landing("play-contexts"); - } catch (e) { - log.warning(`landing play-contexts: ${e}`); - return []; - } - const ids: string[] = []; - const seen = new Set(); - const pushRef = (t: { id?: number | string; track_id?: number | string; trackId?: { id?: number | string } }) => { - const raw = t.trackId?.id ?? t.id ?? t.track_id; - if (raw === undefined || raw === null) return; - const id = bareTrackId(raw); - if (!seen.has(id)) { - seen.add(id); - ids.push(id); - } - }; - for (const block of landing?.blocks ?? []) { - const data = block.data as - | { - otherTracks?: Array<{ - id?: number | string; - track_id?: number | string; - trackId?: { id?: number | string }; - }>; - other_tracks?: Array<{ - id?: number | string; - track_id?: number | string; - trackId?: { id?: number | string }; - }>; - play_contexts?: Array<{ - tracks?: Array<{ id?: number | string; track_id?: number | string; trackId?: { id?: number | string } }>; - }>; - playContexts?: Array<{ - tracks?: Array<{ id?: number | string; track_id?: number | string; trackId?: { id?: number | string } }>; - }>; - } - | undefined; - if (!data) continue; - // основная форма блока play-contexts: data.otherTracks[].trackId - for (const t of data.otherTracks ?? data.other_tracks ?? []) { - pushRef(t); - if (ids.length >= limit) break; - } - // фолбэк-форма: data.play_contexts[].tracks[] - for (const ctx of data.play_contexts ?? data.playContexts ?? []) { - for (const t of ctx.tracks ?? []) { - pushRef(t); - if (ids.length >= limit) break; - } - if (ids.length >= limit) break; - } - if (ids.length >= limit) break; - } - log.info(`[recent] play-contexts (фолбэк) → ${ids.length} треков`); - return ids; - } - - /** полный текст песни. кэш вечный, null = нет текста / API недоступен. */ - async getTrackLyrics(trackId: number | string): Promise { - const key = bareTrackId(trackId); - if (this.lyricsCache.has(key)) return this.lyricsCache.get(key)!; - let text: string | null = null; - let apiOk = false; - try { - text = await this.withRetry(async () => { - const lib = await this.getLib(); - const lyrics = await withTimeout(lib.tracksLyrics(key), 5_000, "lyrics"); - apiOk = true; - return lyrics ? await withTimeout(lyrics.fetchLyrics(), 5_000, "lyrics-fetch") : null; - }); - } catch (e) { - // NotFoundError = у трека нет текста (валидный ответ) → кэшируем null - if (e instanceof LibNotFoundError) apiOk = true; - else log.debug(`getTrackLyrics(${key}): ${e}`); - } - if (apiOk) this.lyricsCache.set(key, text); - return text; - } - - /** LRC-текст трека (синхронизированная лирика). null = нет LRC / API недоступен. - * Не кэшируем: тяжёлая операция, вызывается редко (генерация видео). */ - async getTrackLRC(trackId: number | string): Promise { - const key = bareTrackId(trackId); - try { - const lib = await this.getLib(); - const lyrics = await withTimeout(lib.tracksLyrics(key, "LRC"), 5_000, "lrc"); - if (!lyrics) return null; - const text = await withTimeout(lyrics.fetchLyrics(), 5_000, "lrc-fetch"); - if (!text || !isLrc(text)) return null; - return text; - } catch (e) { - if (e instanceof LibNotFoundError) return null; - log.debug(`getTrackLRC(${key}): ${e}`); - return null; - } - } - - async searchAlbums(query: string, limit = 3): Promise { - return this.withLib(`searchAlbums(${JSON.stringify(query)})`, [], async (lib) => { - const result = await lib.search(query, false, "album"); - const albums = (result?.albums?.results ?? []) as unknown as YaAlbum[]; - return albums.slice(0, limit).filter((a) => a && a.available); - }); - } - - /** живой ws тяжёлый — кэш 5с, дедуп параллельных, таймаут чтобы inline window не сгорело. */ - async getCurrentTrack(): Promise { - const now = performance.now(); - if (this.npCache !== null && now - this.npCacheAt < NP_CACHE_TTL_MS) return this.npCache; - if (this.npInflight !== null) return this.npInflight; - - const fetch = async (): Promise => { - const ynison = await this.getYnison(); - return withTimeout(ynison.getCurrentTrack(), NP_FETCH_TIMEOUT_MS, "now-playing"); - }; - - this.npInflight = fetch(); - try { - const result = await this.npInflight; - this.npCache = result; - this.npCacheAt = performance.now(); - return result; - } finally { - this.npInflight = null; - } - } - - /** вариант загрузки по качеству (best — макс. битрейт, economy — ≤192). */ - async getBestDownloadInfo(track: YaTrack, quality: AudioQuality = "best"): Promise { - try { - return await this.withRetry(async () => { - const lib = await this.getLib(); - const infos = (await lib.tracksDownloadInfo(track.id!)) as unknown as DownloadInfo[]; - const chosen = selectDownloadInfo(infos, quality); - if (chosen === null) return null; - log.debug(`DownloadInfo для ${track.id} (${quality}): ${chosen.codec} ${chosen.bitrateInKbps}kbps`); - // chosen — библиотечная модель (несёт getDirectLink) под структурным типом - return chosen; - }); - } catch (e) { - log.error(`ошибка getBestDownloadInfo(${track.id}): ${e}`); - return null; - } - } - - /** lossless/FLAC через /get-file-info. Возвращает LibLosslessDownloadInfo напрямую - * (несёт urls/key/codec/bitrate/links()/downloadBytes()). null, если эндпоинт не - * дал ссылок или трек недоступен. */ - async getLosslessDownload(track: YaTrack): Promise { - try { - return await this.withRetry(async () => { - const lib = await this.getLib(); - const info = await lib.tracksLosslessInfo(track.id!, "lossless"); - if (!info || info.links().length === 0) return null; - log.info( - `get-file-info ${track.id}: ${info.codec} ${info.bitrate}kbps enc=${Boolean(info.key)} urls=${info.links().length}`, - ); - return info; - }); - } catch (e) { - log.error(`ошибка getLosslessDownload(${track.id}): ${e}`); - return null; - } - } - - async getDownloadUrl(track: YaTrack, quality: AudioQuality = "best"): Promise { - const info = await this.getBestDownloadInfo(track, quality); - if (info === null) return null; - return this.directLinkFromInfo(info); - } - - /** свежая прямая ссылка из готового DownloadInfo (пере-подписывается каждый вызов). */ - async directLinkFromInfo(info: DownloadInfo): Promise { - try { - return await this.withRetry(async () => { - const libInfo = info as unknown as LibDownloadInfo; - libInfo.directLink = undefined; // сбрасываем кэш — нужна свежая подпись (ссылка живёт ~минуту) - return await libInfo.getDirectLink(); - }); - } catch (e) { - log.error(`ошибка buildDirectLink: ${e}`); - return null; - } - } - - /** альбом со всеми треками. проставляет _trackNumber / _discNumber. */ - async getAlbum(albumId: number): Promise { - const album = await this.withLib(`getAlbum(${albumId})`, null, async (lib) => { - return (await lib.albumsWithTracks(albumId)) as unknown as YaAlbum | null; - }); - if (!album) return null; - - const tracks: YaTrack[] = []; - const volumes: YaTrack[][] = []; - let discIdx = 0; - for (const volume of album.volumes ?? []) { - discIdx++; - const disc: YaTrack[] = []; - let trackIdx = 0; - for (const track of volume) { - trackIdx++; - if (track && track.available) { - track._trackNumber = trackIdx; - track._discNumber = discIdx; - disc.push(track); - tracks.push(track); - } - } - volumes.push(disc); - } - - if (tracks.length === 0) { - log.warning(`альбом ${albumId} без доступных треков`); - return null; - } - - const artist = formatArtists(album.artists); - const year = album.year ? String(album.year) : ""; - const label = - (album.labels && album.labels.length > 0 - ? typeof album.labels[0] === "string" - ? album.labels[0] - : (album.labels[0]!.name ?? "") - : "") || ""; - const genre = album.genre || ""; - const coverUrl = normalizeCoverUrl(album.coverUri || "", "orig"); - - log.info( - `альбом ${albumId}: «${album.title}» ${artist} [${year}], ${tracks.length} треков, ${volumes.length} дисков`, - ); - - return { - id: albumId, - title: album.title || "", - artist, - year, - label, - cover_url: coverUrl, - genre, - album_type: album.type || "", - tracks, - volumes, - }; - } - - /** сырой ответ usersPlaylists: треки тут — «короткие» (часто только id). */ - private async fetchPlaylist(owner: string, kind: number) { - return this.withLib(`getPlaylist(${owner}, ${kind})`, null, async (lib) => { - return (await lib.usersPlaylists(kind, owner)) as unknown as { - title?: string; - cover?: { uri?: string }; - tracks?: Array<{ id?: string | number; track?: YaTrack }>; - } | null; - }); - } - - /** шапка плейлиста без резолва треков — для inline-результата (одним запросом). */ - async getPlaylistShort( - owner: string, - kind: number, - ): Promise<{ title: string; coverUri: string; trackCount: number } | null> { - const playlist = await this.fetchPlaylist(owner, kind); - if (!playlist) return null; - return { - title: playlist.title || "", - coverUri: playlist.cover?.uri || "", - trackCount: playlist.tracks?.length ?? 0, - }; - } - - /** плейлист как AlbumData (переиспользует всю инфру AlbumService: chunked - * download/send/retry/resume) — owner из URL, kind = id плейлиста внутри owner. */ - async getPlaylist(owner: string, kind: number): Promise { - const playlist = await this.fetchPlaylist(owner, kind); - if (!playlist) return null; - - const shorts = playlist.tracks ?? []; - const byId = new Map(); - const idsToFetch: (string | number)[] = []; - for (const s of shorts) { - if (s.track) byId.set(String(s.id), s.track); - else if (s.id !== undefined) idsToFetch.push(s.id); - } - - if (idsToFetch.length > 0) { - // POST с телом (не query) — id одним запросом, без постраничного чанкинга. - // ponytail: для плейлистов в тысячи треков стоит бить на пачки, если - // вообще станет проблемой — для личной библиотеки это не размер. - const fetched = await this.withLib(`getPlaylist tracks(${idsToFetch.length})`, [] as YaTrack[], async (lib) => { - return (await lib.tracks(idsToFetch)) as unknown as YaTrack[]; - }); - for (const t of fetched) if (t?.id !== undefined) byId.set(String(t.id), t); - } - - const orderedTracks: YaTrack[] = []; - for (const s of shorts) { - const t = s.id !== undefined ? byId.get(String(s.id)) : undefined; - if (t) orderedTracks.push(t); - } - - return buildPlaylistAlbumData(kind, playlist.title || "", playlist.cover?.uri || "", orderedTracks); - } - - async close(): Promise { - this.lib = null; - this.ynison = null; - } -} - -/** треки плейлиста → AlbumData (переиспользует карточку/выгрузку альбома). - * Чистая функция — сеть уже отработала, тут только форма и фильтр available. */ -export function buildPlaylistAlbumData( - kind: number, - title: string, - coverUri: string, - tracks: YaTrack[], -): AlbumData | null { - const available = tracks.filter((t) => t && t.available); - if (available.length === 0) return null; - available.forEach((t, i) => { - t._trackNumber = i + 1; - t._discNumber = 1; - }); - return { - id: kind, - title, - artist: "Плейлист", - year: "", - label: "", - cover_url: coverUri ? normalizeCoverUrl(coverUri, "orig") : "", - genre: "", - album_type: "playlist", - tracks: available, - volumes: [available], - }; -} - -export class YandexClientFactory { - static async create(token: string, refreshCallback: RefreshCallback | null = null): Promise { - const service = new YandexClient(token, refreshCallback); - await service.init(); // сразу валидируем токен (lib.init → account/status) - return service; - } -} diff --git a/src/yandex/media.ts b/src/yandex/media.ts deleted file mode 100644 index 619d116..0000000 --- a/src/yandex/media.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** Обложки яндекса. */ -import type { HttpClient } from "../infra/http.ts"; -import { getLogger } from "../infra/logging.ts"; - -const log = getLogger("yandex.media"); - -/** cover_uri от яндекса → HTTPS URL. размер: 400x400 / orig. */ -export function normalizeCoverUrl(uri: string | null | undefined, size = "400x400"): string { - if (!uri) return ""; - const url = uri.replace("%%", size); - if (url.startsWith("//")) return "https:" + url; - if (!url.startsWith("http")) return "https://" + url; - return url; -} - -/** тот же cover_url, но в оригинальном размере — для вшивания в файл (/orig). - * Миниатюра для Telegram остаётся в исходном (мелком) размере. */ -export function origCoverUrl(url: string): string { - if (!url) return ""; - return url.replace(/\/\d+x\d+(?=$|\?)/, "/orig"); -} - -export async function fetchCover(url: string, http: HttpClient): Promise { - if (!url) return null; - try { - const res = await http.request(url); - if (res.status === 200) return Buffer.from(await res.body.arrayBuffer()); - await res.body.dump(); - log.warning(`обложка HTTP ${res.status}: ${url}`); - } catch (e) { - log.warning(`не вышло скачать обложку ${url}: ${e}`); - } - return null; -} diff --git a/src/yandex/types.ts b/src/yandex/types.ts deleted file mode 100644 index e5e7e1c..0000000 --- a/src/yandex/types.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** Доменные типы Yandex Music. - * - * Структурные срезы поверхности @dvxch/yandex-music: моделируем только поля, - * которые реально читает наш код (client.ts + metadata.ts), и на границе - * приводим библиотечные модели к этим формам (`as unknown as YaTrack/YaAlbum`). - * Плюс наши агрегаты AlbumData / TrackMetadata / CardMeta. */ - -export interface YaArtist { - id?: number | string; - name?: string; -} - -/** Album.labels: либо [{name}], либо [string]. */ -export type YaLabel = string | { name?: string }; - -export interface YaTrackPosition { - volume?: number | null; - index?: number | null; -} - -/** track.derivedColors / album.derivedColors — готовые hex-цвета обложки. */ -export interface YaDerivedColors { - accent?: string | null; - average?: string | null; - waveText?: string | null; - miniPlayer?: string | null; -} - -/** track.metaData — fallback из embed-структуры (редко присутствует). */ -export interface YaMetaData { - album?: string | null; - year?: string | number | null; - number?: number | null; - volume?: number | null; - genre?: string | null; - composer?: string | null; - version?: string | null; -} - -export interface YaAlbum { - id?: number; - title?: string | null; - year?: number | string | null; - genre?: string | null; - trackCount?: number | null; - type?: string | null; - labels?: YaLabel[] | null; - coverUri?: string | null; - artists?: YaArtist[] | null; - available?: boolean; - trackPosition?: YaTrackPosition | null; - derivedColors?: YaDerivedColors | null; - /** только у albums_with_tracks: список дисков, каждый — список треков */ - volumes?: YaTrack[][] | null; -} - -export interface YaTrack { - id?: number | string; - title?: string | null; - available?: boolean; - durationMs?: number | null; - contentWarning?: string | null; - explicit?: boolean | null; - version?: string | null; - artists?: YaArtist[] | null; - albums?: YaAlbum[] | null; - coverUri?: string | null; - derivedColors?: YaDerivedColors | null; - metaData?: YaMetaData | null; - trackPosition?: YaTrackPosition | null; - /** проставляется нами в client.getAlbum() — позиция в выгружаемом альбоме */ - _trackNumber?: number; - _discNumber?: number; -} - -/** один вариант загрузки из /tracks/{id}/download-info */ -export interface DownloadInfo { - codec: string; - bitrateInKbps: number; - gain?: boolean; - preview?: boolean; - downloadInfoUrl: string; - direct?: boolean; -} - -// ── наши агрегаты ────────────────────────────────────────────────────────── - -export interface TrackMetadata { - artist: string; - album_artist: string; - title: string; - album: string; - year: string; - label: string; - genre: string | null; - composer: string | null; - version: string | null; - explicit: boolean; - duration: number; // секунды - track_number: number | null; - track_total: number | null; - disc_number: number | null; - disc_total: number | null; - cover_url: string; - cover_data: Buffer | null; -} - -export interface AlbumData { - id: number; - title: string; - artist: string; - year: string; - label: string; - cover_url: string; - genre: string; - /** сырое album.type из API: "single" | "podcast" | "audiobook" | ... | "" */ - album_type: string; - tracks: YaTrack[]; - volumes: YaTrack[][]; -} - -/** поля, возвращаемые buildCardMeta — для meta-строки карточки. */ -export interface CardMeta { - album: string; - album_type: string; - type_ru: string; - year: string; - /** все лейблы альбома (для ℗-строки на карточке). */ - labels: string[]; - genre: string; - track_number: number | null; - track_total: number | null; - version: string; - explicit: boolean; - is_single: boolean; - colors: CardColors; - album_id: number | null; - track_id: number | string | null; - liked: boolean | null; -} - -export interface CardColors { - accent?: string; - average?: string; - wave_text?: string; - /** подмешивается enrichCardMetaColor для ч/б обложек */ - wave?: string; -} diff --git a/src/yandex/urls.ts b/src/yandex/urls.ts deleted file mode 100644 index 2267e52..0000000 --- a/src/yandex/urls.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Публичные ссылки веб-плеера Я.Музыки — единый источник. */ -const BASE = "https://music.yandex"; - -export const trackUrl = (id: string | number): string => `${BASE}/track/${id}`; -export const albumUrl = (id: string | number): string => `${BASE}/album/${id}`; -export const playlistUrl = (owner: string, kind: number): string => `${BASE}/users/${owner}/playlists/${kind}`; diff --git a/src/yandex/ynison.ts b/src/yandex/ynison.ts deleted file mode 100644 index ca301ed..0000000 --- a/src/yandex/ynison.ts +++ /dev/null @@ -1,116 +0,0 @@ -/** Ynison — живое «сейчас играет» через JSON-over-WebSocket. - * - * Одноразовый сценарий: на каждый вызов открывает свежий ws (redirect→state), - * берёт первый state-фрейм, резолвит playable_id в трек и закрывается. */ - -import { type Client as LibClient, YnisonClient, YnisonError, type YnisonState } from "@dvxch/yandex-music"; -import { sleep } from "../infra/async.ts"; -import { getLogger } from "../infra/logging.ts"; -import type { YaTrack } from "./types.ts"; - -const log = getLogger("yandex.ynison"); - -const RETRY_BACKOFF_MS = 500; -// ждём первый state-фрейм; client.ts оборачивает весь getCurrentTrack в 4с-таймаут. -const FIRST_STATE_TIMEOUT_MS = 3_000; -const REDIRECT_TIMEOUT_MS = 3_000; - -/** результат get_current_track: трек + статус воспроизведения. */ -export interface CurrentTrack { - track: YaTrack | null; - paused: boolean; - duration_ms: number; - progress_ms: number; - entity_id: string | null; - entity_type: string | null; -} - -/** живой прогресс с учётом времени, прошедшего с последнего ts от ynison. */ -export function liveProgressMs( - progressMs: number, - durationMs: number, - paused: boolean, - timestampMs: number, - playbackSpeed = 1.0, -): number { - if (paused || !timestampMs) return Math.max(0, progressMs); - const elapsed = (Date.now() - timestampMs) * playbackSpeed; - const progress = progressMs + elapsed; - return Math.max(0, Math.min(progress, durationMs || progress)); -} - -export class YnisonService { - constructor( - private lib: LibClient, - private token: string, - ) {} - - /** один retry на YnisonError — транзиентные ws-сбои. - * реальный «не играет» (пустая очередь) сюда не падает — отдаёт валидный state. */ - private async getStateWithRetry(): Promise { - try { - return await this.fetchFirstState(); - } catch (e) { - if (!(e instanceof YnisonError)) throw e; - log.debug(`ynison первая попытка: ${e.message}, повтор`); - } - await sleep(RETRY_BACKOFF_MS); - try { - return await this.fetchFirstState(); - } catch (e) { - throw new Error(`ynison недоступен: ${e instanceof Error ? e.message : e}`); - } - } - - /** одна ws-сессия: handshake → первый state-фрейм → закрытие. */ - private async fetchFirstState(): Promise { - const client = new YnisonClient({ token: this.token }); - // connect() резолвится только при закрытии сессии — гоняем в фоне и читаем - // его ошибку, чтобы не было «unhandled rejection» при обрыве handshake. - const connectPromise = client.connect(REDIRECT_TIMEOUT_MS).catch((e: unknown) => { - throw e instanceof Error ? e : new Error(String(e)); - }); - const first = client.waitFirstState(FIRST_STATE_TIMEOUT_MS); - try { - // что наступит раньше: первый фрейм или фатальный обрыв connect. - return await Promise.race([first, connectPromise.then(() => first)]); - } finally { - await client.disconnect(); - // дочитываем connect, чтобы заглушить unhandled rejection после закрытия. - void connectPromise.catch(() => {}); - } - } - - /** {track, paused, duration_ms, progress_ms, entity_id, entity_type}. - * бросает Error если ynison лёг или нет playable. */ - async getCurrentTrack(): Promise { - const state = await this.getStateWithRetry(); - - const playableList = state.playableList; - const idx = state.currentPlayableIndex; - if (playableList.length === 0 || idx < 0 || idx >= playableList.length) { - throw new Error("нет текущего трека"); - } - - const playableId = playableList[idx]!.playableId; - if (!playableId) throw new Error("playable_id отсутствует"); - - const tracks = (await this.lib.tracks([playableId])) as unknown as YaTrack[]; - const track = tracks.length > 0 ? tracks[0]! : null; - - return { - track, - paused: state.paused, - duration_ms: state.durationMs, - progress_ms: liveProgressMs( - state.progressMs, - state.durationMs, - state.paused, - state.timestampMs, - state.playbackSpeed, - ), - entity_id: state.entityId, - entity_type: state.entityType, - }; - } -} diff --git a/test/albums.test.ts b/test/albums.test.ts index 247aed5..616adf1 100644 --- a/test/albums.test.ts +++ b/test/albums.test.ts @@ -7,8 +7,8 @@ import { AlbumService, formatEta, renderProgressBar } from "../src/services/albu import type { CacheService } from "../src/services/cache.ts"; import type { CacheDb } from "../src/storage/cache.ts"; import type { UserSettings } from "../src/storage/settings.ts"; -import type { YandexClient } from "../src/yandex/client.ts"; -import type { AlbumData, YaTrack } from "../src/yandex/types.ts"; +import type { MusicClient } from "../src/music/client.ts"; +import type { AlbumData, Track } from "../src/music/types.ts"; function mkService(): AlbumService { return new AlbumService( @@ -27,9 +27,9 @@ function mkRetryBuf(over: Partial<{ chatId: number; userId: number }> = {}) { return { chatId: over.chatId ?? 1, userId: over.userId ?? 2, - tracks: [] as YaTrack[], + tracks: [] as Track[], albumData: {} as AlbumData, - yandex: {} as YandexClient, + music: {} as MusicClient, }; } diff --git a/test/auth.test.ts b/test/auth.test.ts deleted file mode 100644 index 977bdb7..0000000 --- a/test/auth.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { DeviceAuthError } from "@dvxch/yandex-music"; - -// AuthService читает getSettings() (YANDEX_OAUTH_CLIENT_ID/SECRET) — settings.ts -// валидирует обязательные переменные лениво при первом вызове. Задаём их здесь, -// т.к. это первый тестовый файл, реально доходящий до getSettings(). Node -// запускает каждый тестовый файл в своём процессе — на другие файлы не влияет. -process.env.TELEGRAM_BOT_TOKEN ??= "test-token"; -process.env.TELEGRAM_CHANNEL_ID ??= "test-channel"; -process.env.TOKEN_ENCRYPTION_KEY ??= "test-key"; -process.env.POSTGRES_USERS_DSN ??= "postgres://test/test"; -process.env.POSTGRES_CACHE_DSN ??= "postgres://test/test"; -// один из двух обязателен (см. settings.ts validate) — иначе тест падает не на -// том, что реально проверяет, а на отсутствии .env у того, кто просто клонировал репо -process.env.BOT_API_BASE_URL ??= "http://127.0.0.1:8081/bot"; - -import { AuthService, type DeviceAuthTransport } from "../src/services/auth.ts"; -import type { UsersDb } from "../src/storage/users.ts"; - -function mkUsersDb(): { - db: UsersDb; - saved: Array<{ userId: number; access: string; refresh: string | null; expires: number | null }>; -} { - const saved: Array<{ userId: number; access: string; refresh: string | null; expires: number | null }> = []; - const db = { - save: async (userId: number, access: string, refresh: string | null = null, expires: number | null = null) => { - saved.push({ userId, access, refresh, expires }); - }, - }; - return { db: db as unknown as UsersDb, saved }; -} - -test("startDeviceAuth → pollDeviceToken отдаёт токен сразу — onSuccess, запись в БД", async () => { - const { db, saved } = mkUsersDb(); - const lib: DeviceAuthTransport = { - requestDeviceCode: async () => - ({ - deviceCode: "dc-1", - userCode: "US-1234", - verificationUrl: "https://ya.ru/auth", - expiresIn: 300, - interval: 3, - }) as never, - pollDeviceToken: async () => ({ accessToken: "AT1", refreshToken: "RT1", expiresIn: 3600 }) as never, - refreshAccessToken: async () => { - throw new Error("не должен вызываться"); - }, - }; - const auth = new AuthService(db, lib); - - let successUserId: number | null = null; - let failure: string | null = null; - const state = await auth.startDeviceAuth( - 42, - (uid) => { - successUserId = uid; - }, - (_uid, reason) => { - failure = reason; - }, - ); - - assert.ok(state); - assert.equal(state!.userCode, "US-1234"); - assert.equal(state!.verificationUrl, "https://ya.ru/auth"); - await state!.done; - - assert.equal(successUserId, 42); - assert.equal(failure, null); - assert.deepEqual(saved, [{ userId: 42, access: "AT1", refresh: "RT1", expires: 3600 }]); - assert.equal(auth.isPending(42), false); // запись снята после завершения -}); - -test("pollLoop — authorization_pending (null) продолжает опрос до успеха", async () => { - const { db, saved } = mkUsersDb(); - let calls = 0; - const lib: DeviceAuthTransport = { - requestDeviceCode: async () => - ({ deviceCode: "dc-2", userCode: "US-2", verificationUrl: "u", expiresIn: 300, interval: 3 }) as never, - pollDeviceToken: async () => { - calls += 1; - return calls < 2 ? null : ({ accessToken: "AT2", refreshToken: null, expiresIn: null } as never); - }, - refreshAccessToken: async () => { - throw new Error("не должен вызываться"); - }, - }; - const auth = new AuthService(db, lib); - - let ok = false; - const state = await auth.startDeviceAuth( - 7, - () => { - ok = true; - }, - () => {}, - ); - await state!.done; - - assert.equal(calls, 2); - assert.equal(ok, true); - assert.deepEqual(saved, [{ userId: 7, access: "AT2", refresh: null, expires: null }]); -}); - -test("pollLoop — DeviceAuthError → onFailure с причиной, в БД ничего не пишется", async () => { - const { db, saved } = mkUsersDb(); - const lib: DeviceAuthTransport = { - requestDeviceCode: async () => - ({ deviceCode: "dc-3", userCode: "US-3", verificationUrl: "u", expiresIn: 300, interval: 3 }) as never, - pollDeviceToken: async () => { - throw new DeviceAuthError("access_denied"); - }, - refreshAccessToken: async () => { - throw new Error("не должен вызываться"); - }, - }; - const auth = new AuthService(db, lib); - - let reason: string | null = null; - const state = await auth.startDeviceAuth( - 9, - () => { - throw new Error("не должен вызываться"); - }, - (_uid, r) => { - reason = r; - }, - ); - await state!.done; - - assert.equal(reason, "access_denied"); - assert.deepEqual(saved, []); -}); - -test("startDeviceAuth → requestDeviceCode падает — возвращает null, не запускает поллинг", async () => { - const { db } = mkUsersDb(); - const lib: DeviceAuthTransport = { - requestDeviceCode: async () => { - throw new Error("сеть недоступна"); - }, - pollDeviceToken: async () => { - throw new Error("не должен вызываться"); - }, - refreshAccessToken: async () => { - throw new Error("не должен вызываться"); - }, - }; - const auth = new AuthService(db, lib); - const state = await auth.startDeviceAuth( - 1, - () => {}, - () => {}, - ); - assert.equal(state, null); - assert.equal(auth.isPending(1), false); -}); - -test("refreshToken — успех сохраняет новый access, переиспользует старый refresh если новый не пришёл", async () => { - const { db, saved } = mkUsersDb(); - const lib: DeviceAuthTransport = { - requestDeviceCode: async () => { - throw new Error("не должен вызываться"); - }, - pollDeviceToken: async () => { - throw new Error("не должен вызываться"); - }, - refreshAccessToken: async () => ({ accessToken: "NEW_AT", refreshToken: undefined, expiresIn: 1800 }) as never, - }; - const auth = new AuthService(db, lib); - const result = await auth.refreshToken(5, "OLD_RT"); - assert.equal(result, "NEW_AT"); - assert.deepEqual(saved, [{ userId: 5, access: "NEW_AT", refresh: "OLD_RT", expires: 1800 }]); -}); - -test("refreshToken — библиотека бросает → null, ничего не сохраняется", async () => { - const { db, saved } = mkUsersDb(); - const lib: DeviceAuthTransport = { - requestDeviceCode: async () => { - throw new Error("не должен вызываться"); - }, - pollDeviceToken: async () => { - throw new Error("не должен вызываться"); - }, - refreshAccessToken: async () => { - throw new DeviceAuthError("invalid_grant"); - }, - }; - const auth = new AuthService(db, lib); - const result = await auth.refreshToken(5, "OLD_RT"); - assert.equal(result, null); - assert.deepEqual(saved, []); -}); diff --git a/test/client.test.ts b/test/client.test.ts deleted file mode 100644 index 412c880..0000000 --- a/test/client.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** YandexClient.reportPlayback — best-effort playAudio-репорт. - * lib — приватное поле, инжектим напрямую (getLib() при непустом this.lib - * не трогает сеть), как priv()-каст в cache.test.ts. */ -import { strict as assert } from "node:assert"; -import { test } from "node:test"; - -// new YandexClient(...) читает getSettings() в конструкторе (client.ts:72) — -// та же ловушка, что и в auth.test.ts: settings.ts валидирует обязательные -// переменные лениво при первом вызове, а в чистом клоне/CI их нет. -process.env.TELEGRAM_BOT_TOKEN ??= "test-token"; -process.env.TELEGRAM_CHANNEL_ID ??= "test-channel"; -process.env.TOKEN_ENCRYPTION_KEY ??= "test-key"; -process.env.POSTGRES_USERS_DSN ??= "postgres://test/test"; -process.env.POSTGRES_CACHE_DSN ??= "postgres://test/test"; -process.env.BOT_API_BASE_URL ??= "http://127.0.0.1:8081/bot"; - -import { YandexClient } from "../src/yandex/client.ts"; -import type { YaTrack } from "../src/yandex/types.ts"; - -type FakeLib = { playAudio(opts: unknown): Promise }; -const withLib = (client: YandexClient, lib: FakeLib): void => { - (client as unknown as { lib: FakeLib }).lib = lib; -}; - -test("reportPlayback: без albumId — не зовёт playAudio вообще", async () => { - const client = new YandexClient("token"); - let calls = 0; - withLib(client, { - playAudio: async () => { - calls += 1; - return true; - }, - }); - - const track: YaTrack = { id: 42, albums: [] }; - await client.reportPlayback(track); - - assert.equal(calls, 0, "без album.id репортить нечего"); -}); - -test("reportPlayback: с albumId — зовёт playAudio с правильными полями", async () => { - const client = new YandexClient("token"); - let seenOpts: unknown = null; - withLib(client, { - playAudio: async (opts) => { - seenOpts = opts; - return true; - }, - }); - - const track: YaTrack = { id: "42:100500", albums: [{ id: 777 }] }; - await client.reportPlayback(track); - - assert.deepEqual(seenOpts, { trackId: "42", albumId: 777, from: "Мойва" }); -}); - -test("reportPlayback: падение playAudio гасится, наружу не летит", async () => { - const client = new YandexClient("token"); - withLib(client, { - playAudio: async () => { - throw new Error("сеть моргнула"); - }, - }); - - const track: YaTrack = { id: 42, albums: [{ id: 777 }] }; - await assert.doesNotReject(client.reportPlayback(track)); -}); diff --git a/test/downloadQuality.test.ts b/test/downloadQuality.test.ts deleted file mode 100644 index 7a4b6a1..0000000 --- a/test/downloadQuality.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** Выбор варианта загрузки по качеству — selectDownloadInfo (#6). */ -import { strict as assert } from "node:assert"; -import { test } from "node:test"; -import { selectDownloadInfo } from "../src/yandex/metadata.ts"; - -const mk = (codec: string, bitrateInKbps: number) => ({ codec, bitrateInKbps }); - -test("selectDownloadInfo: best — макс. битрейт", () => { - const infos = [mk("mp3", 192), mk("mp3", 320), mk("aac", 128)]; - assert.deepEqual(selectDownloadInfo(infos, "best"), mk("mp3", 320)); -}); - -test("selectDownloadInfo: best — при равном битрейте предпочтение mp3", () => { - const infos = [mk("aac", 320), mk("mp3", 320)]; - assert.deepEqual(selectDownloadInfo(infos, "best"), mk("mp3", 320)); -}); - -test("selectDownloadInfo: economy — лучший ≤192", () => { - const infos = [mk("mp3", 320), mk("mp3", 192), mk("aac", 128)]; - assert.deepEqual(selectDownloadInfo(infos, "economy"), mk("mp3", 192)); -}); - -test("selectDownloadInfo: economy — все варианты выше порога → самый низкий", () => { - const infos = [mk("mp3", 320), mk("aac", 256)]; - assert.deepEqual(selectDownloadInfo(infos, "economy"), mk("aac", 256)); -}); - -test("selectDownloadInfo: фолбэк, если нет mp3/aac", () => { - const infos = [mk("flac", 1000)]; - assert.deepEqual(selectDownloadInfo(infos, "best"), mk("flac", 1000)); - assert.deepEqual(selectDownloadInfo(infos, "economy"), mk("flac", 1000)); -}); - -test("selectDownloadInfo: пусто/null → null", () => { - assert.equal(selectDownloadInfo([], "best"), null); - assert.equal(selectDownloadInfo(null, "economy"), null); - assert.equal(selectDownloadInfo(undefined), null); -}); - -test("selectDownloadInfo: дефолт quality = best", () => { - const infos = [mk("mp3", 128), mk("mp3", 320)]; - assert.deepEqual(selectDownloadInfo(infos), mk("mp3", 320)); -}); diff --git a/test/ensureFileId.test.ts b/test/ensureFileId.test.ts index 2c9b2ce..9591d11 100644 --- a/test/ensureFileId.test.ts +++ b/test/ensureFileId.test.ts @@ -4,7 +4,7 @@ import { test } from "node:test"; import type { Container } from "../src/bot/container.ts"; import { ensureFileId } from "../src/bot/handlers/inline.ts"; import type { CachedTrack } from "../src/storage/types.ts"; -import type { YaTrack } from "../src/yandex/types.ts"; +import type { Track } from "../src/music/types.ts"; const entry = (fileId: string, codec: string, bitrate: number): CachedTrack => ({ telegram_file_id: fileId, is_cached: true, codec, bitrate_kbps: bitrate }) as CachedTrack; @@ -23,12 +23,12 @@ function mkContainer(byTier: Record): { container: Containe return { container, calls }; } -const yandex = {} as never; +const music = {} as never; test("ensureFileId — tier эффекта берёт свой тир кэша, а не оригинал", async () => { const { container, calls } = mkContainer({ ncore: entry("FID_NCORE", "mp3", 320) }); - const res = await ensureFileId(container, entry("FID_PLAIN", "flac", 1000), null, yandex, "42", { tier: "ncore" }); + const res = await ensureFileId(container, entry("FID_PLAIN", "flac", 1000), null, music, "42", { tier: "ncore" }); assert.deepEqual(res, { ok: true, fileId: "FID_NCORE", codec: "mp3", bitrateKbps: 320, degraded: false }); assert.deepEqual(calls, ["ncore"]); }); @@ -36,15 +36,15 @@ test("ensureFileId — tier эффекта берёт свой тир кэша, test("ensureFileId — без tier отдаёт обычный кэш как есть", async () => { const { container, calls } = mkContainer({}); - const res = await ensureFileId(container, entry("FID_PLAIN", "flac", 1000), null, yandex, "42"); + const res = await ensureFileId(container, entry("FID_PLAIN", "flac", 1000), null, music, "42"); assert.deepEqual(res, { ok: true, fileId: "FID_PLAIN", codec: "flac", bitrateKbps: 1000, degraded: false }); assert.deepEqual(calls, []); // quality=best — хватает переданного cached, в БД не ходим }); test("ensureFileId — промах кэша без трека → not_found (и с эффектом тоже)", async () => { const { container } = mkContainer({}); - assert.deepEqual(await ensureFileId(container, null, null, yandex, "42"), { ok: false, kind: "not_found" }); - assert.deepEqual(await ensureFileId(container, null, null, yandex, "42", { tier: "ncore" }), { + assert.deepEqual(await ensureFileId(container, null, null, music, "42"), { ok: false, kind: "not_found" }); + assert.deepEqual(await ensureFileId(container, null, null, music, "42", { tier: "ncore" }), { ok: false, kind: "not_found", }); @@ -52,6 +52,6 @@ test("ensureFileId — промах кэша без трека → not_found (и test("ensureFileId — слишком длинный трек → too_long, до скачивания", async () => { const { container } = mkContainer({}); - const long = { durationMs: 5 * 60 * 60 * 1000 } as YaTrack; - assert.deepEqual(await ensureFileId(container, null, long, yandex, "42"), { ok: false, kind: "too_long" }); + const long = { durationMs: 5 * 60 * 60 * 1000 } as Track; + assert.deepEqual(await ensureFileId(container, null, long, music, "42"), { ok: false, kind: "too_long" }); }); diff --git a/test/inline.test.ts b/test/inline.test.ts index 67a959a..66e50d3 100644 --- a/test/inline.test.ts +++ b/test/inline.test.ts @@ -2,11 +2,13 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { buildChosenEditPayload, parseTrackResultId } from "../src/bot/handlers/inline.ts"; -test("parseTrackResultId", () => { - assert.equal(parseTrackResultId("123456"), "123456"); - assert.equal(parseTrackResultId("tm:123456"), "123456"); +test("parseTrackResultId — валидный Spotify id (22 base62)", () => { + const id = "4iV5W9uYEdYUVa79Axb7Rh"; + assert.equal(parseTrackResultId(id), id); + assert.equal(parseTrackResultId(`tm:${id}`), id); assert.equal(parseTrackResultId("tm:abc"), null); assert.equal(parseTrackResultId("album:1"), null); + assert.equal(parseTrackResultId("123456"), null); // короткий id — не Spotify assert.equal(parseTrackResultId(""), null); }); diff --git a/test/inlineCard.test.ts b/test/inlineCard.test.ts index e0c8d4f..cdab765 100644 --- a/test/inlineCard.test.ts +++ b/test/inlineCard.test.ts @@ -10,10 +10,15 @@ import { isCardResultId, trackCardBasics, } from "../src/bot/handlers/inlineCard.ts"; -import type { YaTrack } from "../src/yandex/types.ts"; +import type { Track } from "../src/music/types.ts"; -function mkTrack(id: number | string = 1): YaTrack { - return { id, title: "Song", artists: [{ name: "Artist" }], coverUri: "avatars.yandex.net/get/%%" }; +function mkTrack(id: string = "1"): Track { + return { + id, + title: "Song", + artists: [{ name: "Artist" }], + coverUri: "https://i.scdn.co/image/ab67616d0000b273abc", + }; } function mkContainer(botUsername: string | null = "nowymbot"): Container { @@ -98,13 +103,15 @@ test("trackCardBasics — собирает artist/title/обложки/handle", assert.equal(b.artist, "Artist"); assert.equal(b.title, "Song"); assert.equal(b.senderHandle, "@vasya"); - assert.ok(b.coverUrl.includes("1000x1000")); - assert.ok(b.thumb.includes("100x100")); + // У Spotify обложка приходит уже с полным URL — normalizeCoverUrl её только + // нормализует до https://, размер уже вшит в путь. + assert.ok(b.coverUrl.startsWith("https://")); + assert.ok(b.thumb.startsWith("https://")); }); test("buildNowPlayingCardResult — resultId содержит userId (изоляция между пользователями)", () => { const container = mkContainer(); - const track = mkTrack(777); + const track = mkTrack("777"); const r1 = buildNowPlayingCardResult(container, track, { progress_ms: 5000 }, { id: 1 }); const r2 = buildNowPlayingCardResult(container, track, { progress_ms: 5000 }, { id: 2 }); assert.ok(r1 && r2); @@ -118,7 +125,7 @@ test("buildNowPlayingCardResult — resultId содержит userId (изоля test("buildLyricsCardResult — resultId тоже содержит userId, разные фразы расходятся", () => { const container = mkContainer(); - const track = mkTrack(777); + const track = mkTrack("777"); const r1 = buildLyricsCardResult(container, track, "phrase one", { id: 1 }); const r2 = buildLyricsCardResult(container, track, "phrase one", { id: 2 }); const r3 = buildLyricsCardResult(container, track, "phrase two", { id: 1 }); diff --git a/test/lrc.test.ts b/test/lrc.test.ts index dbd4d31..962568d 100644 --- a/test/lrc.test.ts +++ b/test/lrc.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { filterLrcLines, isLrc, parseLrc } from "../src/yandex/lrc.ts"; +import { filterLrcLines, isLrc, parseLrc } from "../src/music/lrc.ts"; describe("parseLrc", () => { it("стандартный формат [MM:SS.cc]", () => { diff --git a/test/parsers.test.ts b/test/parsers.test.ts index 34514d6..d22e9b6 100644 --- a/test/parsers.test.ts +++ b/test/parsers.test.ts @@ -1,45 +1,49 @@ +/** parseSearchQuery: пустой запрос, ссылки Spotify, произвольный текст. */ import assert from "node:assert/strict"; import { test } from "node:test"; import { parseSearchQuery } from "../src/infra/parsers.ts"; -test("пустой запрос → empty", () => { +test("пустой / только пробелы → empty", () => { assert.deepEqual(parseSearchQuery(""), { type: "empty" }); - assert.deepEqual(parseSearchQuery(" "), { type: "empty" }); + assert.deepEqual(parseSearchQuery(" \n"), { type: "empty" }); }); test("ссылка на трек → track_link", () => { - assert.deepEqual(parseSearchQuery("https://music.yandex.ru/album/123/track/456"), { + const id = "4iV5W9uYEdYUVa79Axb7Rh"; // валидный Spotify id (22 base62) + assert.deepEqual(parseSearchQuery(`https://open.spotify.com/track/${id}`), { type: "track_link", - track_id: 456, + track_id: id, + }); + assert.deepEqual(parseSearchQuery(`spotify:track:${id}`), { type: "track_link", track_id: id }); + assert.deepEqual(parseSearchQuery(`https://open.spotify.com/intl-ru/track/${id}?si=xyz`), { + type: "track_link", + track_id: id, }); - assert.deepEqual(parseSearchQuery("music.yandex/album/1/track/2"), { type: "track_link", track_id: 2 }); - assert.deepEqual(parseSearchQuery("https://yandex.ru/music/album/10/track/20"), { type: "track_link", track_id: 20 }); }); -test("ссылка на альбом → album_link (lookahead отделяет от /track)", () => { - assert.deepEqual(parseSearchQuery("https://music.yandex.ru/album/789"), { type: "album_link", album_id: 789 }); - // с /track это всё-таки трек, не альбом - assert.equal(parseSearchQuery("music.yandex.ru/album/789/track/1").type, "track_link"); +test("ссылка на альбом → album_link", () => { + const id = "6TJmQnO44YE5BtTxH8pop1"; + assert.deepEqual(parseSearchQuery(`https://open.spotify.com/album/${id}`), { + type: "album_link", + album_id: id, + }); + // трек внутри альбома-ссылки: track_link выигрывает (проверяется первым) + const tid = "4iV5W9uYEdYUVa79Axb7Rh"; + assert.equal(parseSearchQuery(`https://open.spotify.com/track/${tid}`).type, "track_link"); }); -test("ссылка на плейлист → playlist_link", () => { - assert.deepEqual(parseSearchQuery("https://music.yandex.ru/users/ya.playlist/playlists/1035"), { - type: "playlist_link", - owner: "ya.playlist", - kind: 1035, - }); - assert.deepEqual(parseSearchQuery("music.yandex/users/some_user/playlists/3"), { +test("ссылка на плейлист → playlist_link (owner пустой у Spotify)", () => { + const id = "37i9dQZF1DXcBWIGoYBM5M"; + assert.deepEqual(parseSearchQuery(`https://open.spotify.com/playlist/${id}`), { type: "playlist_link", - owner: "some_user", - kind: 3, + owner: "", + kind: id, }); - // мусор вместо kind — не ссылка, обычный поиск - assert.equal(parseSearchQuery("music.yandex.ru/users/vasya/playlists/abc").type, "search"); }); test("произвольный текст → search", () => { - assert.deepEqual(parseSearchQuery("nirvana smells like"), { + assert.deepEqual(parseSearchQuery("smells like teen spirit"), { type: "search", - content: "nirvana smells like", + content: "smells like teen spirit", }); }); diff --git a/test/playlists.test.ts b/test/playlists.test.ts index 1a8f3f9..f2e4119 100644 --- a/test/playlists.test.ts +++ b/test/playlists.test.ts @@ -1,32 +1,29 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { START_PLAYLIST_RE } from "../src/bot/handlers/common.ts"; -import { buildPlaylistAlbumData } from "../src/yandex/client.ts"; -import type { YaTrack } from "../src/yandex/types.ts"; - -test("deep-link playlist__: owner с '_' не съедает kind", () => { - // owner жадный, kind якорится хвостовым _\d+$ — разбор однозначен - assert.deepEqual(START_PLAYLIST_RE.exec("playlist_some_user_42")?.slice(1), ["some_user", "42"]); - assert.deepEqual(START_PLAYLIST_RE.exec("playlist_vasya_1035")?.slice(1), ["vasya", "1035"]); - // owner, сам кончающийся на _<цифры> - assert.deepEqual(START_PLAYLIST_RE.exec("playlist_user_12_34")?.slice(1), ["user_12", "34"]); - assert.equal(START_PLAYLIST_RE.exec("playlist_vasya"), null); - assert.equal(START_PLAYLIST_RE.exec("album_123"), null); +import { buildPlaylistAlbumData } from "../src/music/client.ts"; +import type { Track } from "../src/music/types.ts"; + +test("deep-link playlist_: 22 base62 разбирается однозначно", () => { + const id = "37i9dQZF1DXcBWIGoYBM5M"; + assert.deepEqual(START_PLAYLIST_RE.exec(`playlist_${id}`)?.slice(1), [id]); + assert.equal(START_PLAYLIST_RE.exec("playlist_short"), null); + assert.equal(START_PLAYLIST_RE.exec("album_" + id), null); }); -function mkTrack(id: number, available = true): YaTrack { +function mkTrack(id: string, available = true): Track { return { id, title: `трек ${id}`, available }; } test("buildPlaylistAlbumData — фильтрует недоступные, нумерует оставшиеся по порядку", () => { - const tracks = [mkTrack(1), mkTrack(2, false), mkTrack(3)]; - const album = buildPlaylistAlbumData(42, "мой плейлист", "avatars.yandex.net/get-music/%%/orig", tracks); + const tracks = [mkTrack("1"), mkTrack("2", false), mkTrack("3")]; + const album = buildPlaylistAlbumData("42", "мой плейлист", "https://i.scdn.co/image/abc", tracks); assert.ok(album); assert.equal(album!.tracks.length, 2); assert.deepEqual( album!.tracks.map((t) => t.id), - [1, 3], + ["1", "3"], ); assert.equal(album!.tracks[0]!._trackNumber, 1); assert.equal(album!.tracks[1]!._trackNumber, 2); @@ -34,18 +31,18 @@ test("buildPlaylistAlbumData — фильтрует недоступные, ну }); test("buildPlaylistAlbumData — все треки недоступны → null", () => { - const album = buildPlaylistAlbumData(1, "пусто", "", [mkTrack(1, false), mkTrack(2, false)]); + const album = buildPlaylistAlbumData("1", "пусто", "", [mkTrack("1", false), mkTrack("2", false)]); assert.equal(album, null); }); test("buildPlaylistAlbumData — пустой список треков → null", () => { - assert.equal(buildPlaylistAlbumData(1, "пусто", "", []), null); + assert.equal(buildPlaylistAlbumData("1", "пусто", "", []), null); }); test("buildPlaylistAlbumData — поля AlbumData под переиспользуемую инфру альбомов", () => { - const album = buildPlaylistAlbumData(42, "мой плейлист", "", [mkTrack(1)]); + const album = buildPlaylistAlbumData("42", "мой плейлист", "", [mkTrack("1")]); assert.ok(album); - assert.equal(album!.id, 42); + assert.equal(album!.id, "42"); assert.equal(album!.title, "мой плейлист"); assert.equal(album!.artist, "Плейлист"); assert.equal(album!.album_type, "playlist"); @@ -54,6 +51,6 @@ test("buildPlaylistAlbumData — поля AlbumData под переисполь }); test("buildPlaylistAlbumData — пустой coverUri не роняет normalizeCoverUrl", () => { - const album = buildPlaylistAlbumData(1, "x", "", [mkTrack(1)]); + const album = buildPlaylistAlbumData("1", "x", "", [mkTrack("1")]); assert.equal(album!.cover_url, ""); }); diff --git a/test/searchPaging.test.ts b/test/searchPaging.test.ts index 70b8466..b3fa14d 100644 --- a/test/searchPaging.test.ts +++ b/test/searchPaging.test.ts @@ -4,8 +4,8 @@ import { TTLCache } from "../src/infra/memoryCache.ts"; import type { CacheService } from "../src/services/cache.ts"; import type { InlineResult } from "../src/services/search.ts"; import { SearchService } from "../src/services/search.ts"; -import type { YandexClient } from "../src/yandex/client.ts"; -import type { YaTrack } from "../src/yandex/types.ts"; +import type { MusicClient } from "../src/music/client.ts"; +import type { Track } from "../src/music/types.ts"; // всегда "уже закэшировано" → allAlreadyCached()=true → resultsCache реально // пишется (без этого cacheService=null в mkService по умолчанию — тест @@ -14,25 +14,25 @@ const fakeCacheService = { cacheDb: { get: async () => ({ is_cached: true }) }, } as unknown as CacheService; -function mkTrack(id: number): YaTrack { +function mkTrack(id: string): Track { return { id, title: `track ${id}` }; } /** SearchService без сети: cacheService=null → createInlineResultFromTrack идёт * по text_media-пути (только строит InlineQueryResult.article, без I/O). */ function mkService( - fake: Partial, + fake: Partial, userId: number | null = null, resultsCache: TTLCache = new TTLCache(100, 300, 60), cacheService: CacheService | null = null, ): SearchService { - return new SearchService(fake as unknown as YandexClient, resultsCache, cacheService, "nowymbot", userId); + return new SearchService(fake as unknown as MusicClient, resultsCache, cacheService, "nowymbot", userId); } test("recentResultsPaged — листает пул страницами, nextOffset двигается", async () => { - const pool = Array.from({ length: 7 }, (_, i) => mkTrack(i)); + const pool = Array.from({ length: 7 }, (_, i) => mkTrack(String(i))); let calls = 0; - const fake: Partial = { + const fake: Partial = { getRecentTracks: async () => { calls += 1; return pool; @@ -57,8 +57,8 @@ test("recentResultsPaged — листает пул страницами, nextOff }); test("recentResultsPaged — offset за пределами пула отдаёт пусто без nextOffset", async () => { - const pool = Array.from({ length: 4 }, (_, i) => mkTrack(i)); - const fake: Partial = { getRecentTracks: async () => pool }; + const pool = Array.from({ length: 4 }, (_, i) => mkTrack(String(i))); + const fake: Partial = { getRecentTracks: async () => pool }; const service = mkService(fake); const r = await service.recentResultsPaged("100", 3, "button", "text_media"); @@ -69,12 +69,12 @@ test("searchTracksPaged — переход внутри страницы, зат // страница 0: 5 треков (id 0-4), всего 12, perPage=5 // страница 1: 5 треков (id 5-9) // страница 2: 2 трека (id 10-11) — последняя - const pages: Record = { - 0: Array.from({ length: 5 }, (_, i) => mkTrack(i)), - 1: Array.from({ length: 5 }, (_, i) => mkTrack(5 + i)), - 2: Array.from({ length: 2 }, (_, i) => mkTrack(10 + i)), + const pages: Record = { + 0: Array.from({ length: 5 }, (_, i) => mkTrack(String(i))), + 1: Array.from({ length: 5 }, (_, i) => mkTrack(String(5 + i))), + 2: Array.from({ length: 2 }, (_, i) => mkTrack(String(10 + i))), }; - const fake: Partial = { + const fake: Partial = { searchTracksPage: async (_query: string, page = 0) => ({ tracks: pages[page] ?? [], total: 12, @@ -113,8 +113,8 @@ test("recentResultsPaged — resultsCache общий на всех (как conta // container.trackCache для ЛЮБОГО SearchService, независимо от userId. const sharedCache = new TTLCache(100, 300, 60); - const poolA = [mkTrack(100), mkTrack(101), mkTrack(102)]; - const poolB = [mkTrack(200), mkTrack(201), mkTrack(202)]; + const poolA = [mkTrack("100"), mkTrack("101"), mkTrack("102")]; + const poolB = [mkTrack("200"), mkTrack("201"), mkTrack("202")]; const serviceA = mkService({ getRecentTracks: async () => poolA }, 1, sharedCache, fakeCacheService); const serviceB = mkService({ getRecentTracks: async () => poolB }, 2, sharedCache, fakeCacheService); diff --git a/test/settings.test.ts b/test/settings.test.ts index 60d6ee6..5a1cb4f 100644 --- a/test/settings.test.ts +++ b/test/settings.test.ts @@ -12,7 +12,7 @@ import { test("norm: валидное значение проходит как есть", () => { assert.equal(norm("text", TRACK_LAYOUTS, DEFAULT_TRACK_LAYOUT), "text"); - assert.equal(norm("lossless", TRACK_QUALITIES, "best"), "lossless"); + assert.equal(norm("economy", TRACK_QUALITIES, "best"), "economy"); }); test("norm: невалидное/null/undefined → дефолт", () => { diff --git a/test/tagging.test.ts b/test/tagging.test.ts index 060cbce..e76461e 100644 --- a/test/tagging.test.ts +++ b/test/tagging.test.ts @@ -8,8 +8,8 @@ import { join } from "node:path"; import { test } from "node:test"; import { createEmptyMp3, getFilename } from "../src/tagging/emptyMp3.ts"; import { TaggingService } from "../src/tagging/service.ts"; -import { origCoverUrl } from "../src/yandex/media.ts"; -import type { TrackMetadata } from "../src/yandex/types.ts"; +import { origCoverUrl } from "../src/music/media.ts"; +import type { TrackMetadata } from "../src/music/types.ts"; const sha = (b: Buffer) => createHash("sha256").update(b).digest("hex"); @@ -73,9 +73,8 @@ test("getFilename — чистит запрещённые символы", () => assert.equal(getFilename("", "", "aac"), "Unknown - Unknown.m4a"); }); -test("origCoverUrl — подменяет размер на orig", () => { - assert.equal(origCoverUrl("https://x.net/a/400x400"), "https://x.net/a/orig"); - assert.equal(origCoverUrl("https://x.net/a/200x200?q=1"), "https://x.net/a/orig?q=1"); +test("origCoverUrl — для Spotify возвращает URL как есть (нет /orig-размера)", () => { + assert.equal(origCoverUrl("https://i.scdn.co/image/ab67616d0000b273abc"), "https://i.scdn.co/image/ab67616d0000b273abc"); assert.equal(origCoverUrl(""), ""); }); diff --git a/test/yandexMetadata.test.ts b/test/yandexMetadata.test.ts deleted file mode 100644 index a917ddb..0000000 --- a/test/yandexMetadata.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/** Чистые функции yandex/metadata.ts — порт логики из Python. */ -import { strict as assert } from "node:assert"; -import { test } from "node:test"; -import { normalizeCoverUrl } from "../src/yandex/media.ts"; -import { - albumEmoji, - albumTypeRu, - buildCardMeta, - findBestLyricsLine, - formatArtists, - formatDurationHuman, - isSingle, - LABELED_ALBUM_TYPES, - metaNeedsWaveColor, - selectDownloadInfo, - waveColorFromLottie, -} from "../src/yandex/metadata.ts"; -import type { CardMeta, YaTrack } from "../src/yandex/types.ts"; - -test("formatArtists", () => { - assert.equal(formatArtists([{ name: "A" }, { name: "B" }]), "A, B"); - assert.equal(formatArtists([]), "Unknown"); - assert.equal(formatArtists(null, "—"), "—"); - assert.equal(formatArtists([{ id: 1 }]), "Unknown"); // нет name -}); - -test("formatDurationHuman", () => { - assert.equal(formatDurationHuman(185_000), "3:05"); - assert.equal(formatDurationHuman(3_725_000), "1:02:05"); - assert.equal(formatDurationHuman(0), "0:00"); - assert.equal(formatDurationHuman(null), "0:00"); -}); - -test("albumTypeRu — словарь + fallback по track_count", () => { - assert.equal(albumTypeRu("single"), "сингл"); - assert.equal(albumTypeRu("EP"), "EP"); - assert.equal(albumTypeRu(null, 1), "сингл"); - assert.equal(albumTypeRu(null, 4), "EP"); - assert.equal(albumTypeRu(null, 10), "альбом"); - assert.equal(albumTypeRu(null, null), null); -}); - -test("albumEmoji — подкаст/аудиокнига получают свой значок, остальное — 📀", () => { - assert.equal(albumEmoji("podcast"), "🎙️"); - assert.equal(albumEmoji("AUDIOBOOK"), "🎧"); - assert.equal(albumEmoji("single"), "📀"); - assert.equal(albumEmoji(null), "📀"); - assert.equal(albumEmoji(undefined), "📀"); -}); - -test("LABELED_ALBUM_TYPES — только podcast/audiobook подписываются явным ru-ярлыком", () => { - assert.equal(LABELED_ALBUM_TYPES.has("podcast"), true); - assert.equal(LABELED_ALBUM_TYPES.has("audiobook"), true); - assert.equal(LABELED_ALBUM_TYPES.has("album"), false); - assert.equal(LABELED_ALBUM_TYPES.has("single"), false); -}); - -test("isSingle", () => { - assert.equal(isSingle("single"), true); - assert.equal(isSingle(null, 1), true); - assert.equal(isSingle("album", 10), false); -}); - -test("selectDownloadInfo — mp3 при равном битрейте", () => { - const infos = [ - { codec: "aac", bitrateInKbps: 192 }, - { codec: "mp3", bitrateInKbps: 192 }, - { codec: "mp3", bitrateInKbps: 128 }, - ]; - assert.deepEqual(selectDownloadInfo(infos, "best"), { codec: "mp3", bitrateInKbps: 192 }); - assert.equal(selectDownloadInfo([], "best"), null); -}); - -test("normalizeCoverUrl", () => { - assert.equal(normalizeCoverUrl("avatars.yandex.net/get/%%", "400x400"), "https://avatars.yandex.net/get/400x400"); - assert.equal(normalizeCoverUrl("//host/x%%", "orig"), "https://host/xorig"); - assert.equal(normalizeCoverUrl(null), ""); -}); - -test("buildCardMeta — извлекает поля без сети", () => { - const track: YaTrack = { - id: 42, - title: "Song", - contentWarning: "explicit", - artists: [{ name: "Artist" }], - albums: [{ id: 7, title: "Alb", year: 2020, type: "single", trackCount: 1, genre: "pop" }], - derivedColors: { accent: "#aabbcc", average: "#112233", waveText: "#fff" }, - }; - const m = buildCardMeta(track); - assert.equal(m.album, "Alb"); - assert.equal(m.year, "2020"); - assert.equal(m.type_ru, "сингл"); - assert.equal(m.explicit, true); - assert.equal(m.is_single, true); - assert.equal(m.album_id, 7); - assert.equal(m.track_id, 42); - assert.equal(m.colors.accent, "#aabbcc"); - assert.equal(m.liked, null); -}); - -test("metaNeedsWaveColor — серый accent требует wave-цвет", () => { - const gray: CardMeta = { ...baseMeta(), colors: { accent: "#808080" }, album_id: 1 }; - const vivid: CardMeta = { ...baseMeta(), colors: { accent: "#ff0033" }, album_id: 1 }; - assert.equal(metaNeedsWaveColor(gray), true); - assert.equal(metaNeedsWaveColor(vivid), false); - assert.equal(metaNeedsWaveColor({ ...gray, colors: { wave: "#abc" } }), false); // уже есть - assert.equal(metaNeedsWaveColor({ ...gray, album_id: null }), false); // нет альбома -}); - -test("waveColorFromLottie — fill слоя color", () => { - const lottie = Buffer.from( - JSON.stringify({ - layers: [{ nm: "color", shapes: [{ it: [{ ty: "fl", c: { k: [1, 0, 0, 1] } }] }] }], - }), - ); - assert.equal(waveColorFromLottie(lottie), "#ff0000"); - assert.equal(waveColorFromLottie(Buffer.from("not json")), null); -}); - -test("findBestLyricsLine — точное вхождение, потом пересечение слов", () => { - const lyrics = "first line here\nsecond magic line\nthird"; - assert.equal(findBestLyricsLine("magic", lyrics), "second magic line"); - assert.equal(findBestLyricsLine("first here", lyrics), "first line here"); - assert.equal(findBestLyricsLine("zzz qqq", lyrics), null); -}); - -function baseMeta(): CardMeta { - return { - album: "", - album_type: "", - type_ru: "", - year: "", - labels: [], - genre: "", - track_number: null, - track_total: null, - version: "", - explicit: false, - is_single: false, - colors: {}, - album_id: null, - track_id: null, - liked: null, - }; -} diff --git a/test/ynison.test.ts b/test/ynison.test.ts deleted file mode 100644 index 32aceb0..0000000 --- a/test/ynison.test.ts +++ /dev/null @@ -1,192 +0,0 @@ -/** Тесты Ynison: liveProgressMs (интерполяция/клампы), билдеры запросов - * (структура сверена с betterproto-json из venv) и парсер state-фрейма. - * - * Live ws не тестируем — нет кредов; фокус на чистой логике и форме payload'ов. */ - -import assert from "node:assert/strict"; -import { test } from "node:test"; -import { - ANDROID_DEVICE_INFO, - buildUpdateFullStateRequest, - type DeviceInfoOverride, - generateDeviceId, - generateRequestId, - parseRedirectFrame, - parseStateFrame, -} from "@dvxch/yandex-music"; -import { liveProgressMs } from "../src/yandex/ynison.ts"; - -// идентитет наблюдателя, как его собирает nowPlayingWatcher. -const WATCHER_DEVICE_INFO: DeviceInfoOverride = { ...ANDROID_DEVICE_INFO, title: "Мойва" }; - -// ── liveProgressMs ────────────────────────────────────────────────────────── - -test("liveProgressMs: на паузе возвращает снимок без интерполяции", () => { - assert.equal(liveProgressMs(42_000, 200_000, true, Date.now() - 10_000), 42_000); -}); - -test("liveProgressMs: ts=0 → без интерполяции (снимок как есть)", () => { - assert.equal(liveProgressMs(42_000, 200_000, false, 0), 42_000); -}); - -test("liveProgressMs: играет — двигает позицию вперёд на прошедшее время", () => { - const ts = Date.now() - 5_000; // 5с назад - const r = liveProgressMs(10_000, 200_000, false, ts); - // ~15000 ± погрешность планировщика - assert.ok(r >= 14_900 && r <= 15_200, `ожидал ~15000, получил ${r}`); -}); - -test("liveProgressMs: клампит к duration", () => { - const ts = Date.now() - 100_000; // ушли бы далеко за конец - assert.equal(liveProgressMs(190_000, 200_000, false, ts), 200_000); -}); - -test("liveProgressMs: учитывает playback_speed", () => { - const ts = Date.now() - 4_000; - const r = liveProgressMs(0, 200_000, false, ts, 2.0); - assert.ok(r >= 7_900 && r <= 8_200, `ожидал ~8000, получил ${r}`); -}); - -test("liveProgressMs: отрицательный снимок клампится к 0", () => { - assert.equal(liveProgressMs(-5_000, 200_000, true, 0), 0); -}); - -test("liveProgressMs: duration=0 — не клампит (прогресс как есть)", () => { - const ts = Date.now() - 3_000; - const r = liveProgressMs(1_000, 0, false, ts); - assert.ok(r >= 3_900 && r <= 4_200, `ожидал ~4000, получил ${r}`); -}); - -// ── билдеры запросов ────────────────────────────────────────────────────────── - -// эталон из venv: -// get_update_full_state_request('test-device-id').to_json() -// rid — случайный UUID, сравниваем структуру без него. -const PY_DEFAULT = { - updateFullState: { - playerState: { - status: { paused: true, playbackSpeed: 1, version: { deviceId: "test-device-id" } }, - playerQueue: { - entityType: "VARIOUS", - currentPlayableIndex: -1, - options: { repeatMode: "NONE" }, - version: { deviceId: "test-device-id" }, - fromOptional: "", - }, - }, - device: { - info: { deviceId: "test-device-id", title: "Node.js SDK", type: "WEB", appName: "yandex-music" }, - capabilities: { canBeRemoteController: true }, - volumeInfo: {}, - }, - }, -}; - -// эталон watcher (info.type=ANDROID, app_name/version/title подменены): -const PY_WATCHER_INFO = { - deviceId: "test-device-id", - title: "Мойва", - type: "ANDROID", - appName: "ru.yandex.music", - appVersion: "2026.05.3", -}; - -test("buildUpdateFullStateRequest (default): структура совпадает с Python", () => { - const req = buildUpdateFullStateRequest("test-device-id"); - assert.equal(typeof req.rid, "string"); - assert.ok(req.rid.length > 0); - const { rid, ...rest } = req; - void rid; - assert.deepEqual(rest, PY_DEFAULT); -}); - -test("buildUpdateFullStateRequest (watcher): info = Android-идентитет", () => { - const req = buildUpdateFullStateRequest("test-device-id", WATCHER_DEVICE_INFO); - assert.deepEqual(req.updateFullState.device.info, PY_WATCHER_INFO); - // остальное (status/playerQueue/capabilities) идентично default'у - assert.deepEqual(req.updateFullState.playerState, PY_DEFAULT.updateFullState.playerState); - assert.deepEqual(req.updateFullState.device.capabilities, PY_DEFAULT.updateFullState.device.capabilities); -}); - -test("buildUpdateFullStateRequest: сериализуется в валидный JSON без лишних полей", () => { - const req = buildUpdateFullStateRequest("dev-x"); - const parsed = JSON.parse(JSON.stringify(req)); - // playerActionTimestampMs/isCurrentlyActive/canBePlayer и т.п. опущены (как betterproto) - assert.ok(!("playerActionTimestampMs" in parsed)); - assert.ok(!("isCurrentlyActive" in parsed.updateFullState)); - assert.ok(!("canBePlayer" in parsed.updateFullState.device.capabilities)); -}); - -test("generateDeviceId: непустой hex", () => { - const id = generateDeviceId(); - assert.match(id, /^[0-9a-f]+$/); -}); - -test("generateRequestId: валидный uuid4", () => { - assert.match(generateRequestId(), /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); -}); - -// ── парсер state-фрейма ────────────────────────────────────────────────────── - -// эталон PutYnisonStateResponse.to_json() из venv (int64 строками): -const SAMPLE_STATE = JSON.stringify({ - playerState: { - status: { - progressMs: "42000", - durationMs: "200000", - playbackSpeed: 1.0, - version: { deviceId: "dev", timestampMs: "1716900000000" }, - }, - playerQueue: { - entityId: "49", - entityType: "ALBUM", - playableList: [ - { playableId: "123456", playableType: "TRACK", title: "t1" }, - { playableId: "789", playableType: "TRACK", title: "t2" }, - ], - }, - }, - timestampMs: "1716900000000", - rid: "abc", -}); - -test("parseStateFrame: int64-строки коэрсятся в number, currentIndex по умолчанию 0", () => { - const s = parseStateFrame(SAMPLE_STATE); - assert.equal(s.progressMs, 42_000); - assert.equal(s.durationMs, 200_000); - assert.equal(s.timestampMs, 1_716_900_000_000); - assert.equal(s.playbackSpeed, 1); - assert.equal(s.paused, false); // paused опущен → false - assert.equal(s.currentPlayableIndex, 0); // currentPlayableIndex опущен → 0 - assert.equal(s.entityId, "49"); - assert.equal(s.entityType, "ALBUM"); - assert.equal(s.playableList.length, 2); - assert.equal(s.playableList[0]?.playableId, "123456"); -}); - -test("parseStateFrame: 'не играет' (idx=-1, пустые поля)", () => { - const frame = JSON.stringify({ - playerState: { status: { paused: true }, playerQueue: { currentPlayableIndex: -1 } }, - }); - const s = parseStateFrame(frame); - assert.equal(s.currentPlayableIndex, -1); - assert.equal(s.paused, true); - assert.equal(s.playableList.length, 0); - assert.equal(s.progressMs, 0); -}); - -test("parseRedirectFrame: достаёт host/ticket/sessionId (int64 строкой)", () => { - const frame = JSON.stringify({ - host: "ynison-x.music.yandex.ru", - redirectTicket: "tkt-123", - sessionId: "9876543210", - }); - const r = parseRedirectFrame(frame); - assert.equal(r.host, "ynison-x.music.yandex.ru"); - assert.equal(r.redirectTicket, "tkt-123"); - assert.equal(r.sessionId, "9876543210"); -}); - -test("parseRedirectFrame: бросает на неполном фрейме", () => { - assert.throws(() => parseRedirectFrame(JSON.stringify({ host: "x" }))); -});