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 @@
-
+
-
-
-Бот для Яндекс Музыки в 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` под сторис/шортсы.
-
-
-
-
-
-
-
-
-Есть и лирик-режим — вместо прогресс-бара лента строк текста вокруг активной
-(Genius → Yandex fallback):
-
-
-
-
-
-
-
+ В `.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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
- 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 = 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;
+ private musicClients!: TTLCache;
private searchServices!: TTLCache;
// дедуп конкурентных refreshToken на одного юзера — иначе resolveToken()
// (проактивный путь) и makeRefreshCallback (реактивный, на 401 внутри
- // YandexClient) могли одновременно слать один и тот же refresh_token,
+ // MusicClient) могли одновременно слать один и тот же refresh_token,
// и параллельные usersDb.save() гонялись бы за тем, чья пара токенов останется.
private refreshInFlight = new Map>();
@@ -113,8 +113,8 @@ export class Container {
// владельцы инлайнов: живут сутки (инлайн-сообщение могут переоткрыть позже)
this.inlineOwners = new TTLCache(10000, 24 * 3600, 3600);
this.inlineEffects = new TTLCache(10000, 24 * 3600, 3600);
- // per-user YandexClient/SearchService — час простоя, пересборка дешёвая
- this.yandexClients = new TTLCache(1000, 3600, 600);
+ // per-user MusicClient/SearchService — час простоя, пересборка дешёвая
+ this.musicClients = new TTLCache(1000, 3600, 600);
this.searchServices = new TTLCache(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 {
- if (userId === undefined) return YandexClientFactory.create(token);
+ async getMusicService(token: string, userId?: number): Promise {
+ 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 {
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 = { stub: "пустышка
const TRACK_QUALITY_LABELS: Record = {
best: "оптимальное",
economy: "экономичное",
- lossless: "превосходное",
};
const CARD_PROGRESS_LABELS: Record = { wavy: "волна", bar: "полоска" };
const CARD_FIELD_LABELS: Record = {
@@ -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 {
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,
+ fetch: (music: MusicClient) => Promise,
): Promise {
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 {
- 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 {
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 | 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 {
- 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 {
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 => 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 {
// кэш этого тира проверил 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 } = {},
): Promise {
@@ -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 | 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__, и из ссылки в личке. */
+/** зовут и из /start playlist_, и из ссылки в личке. У Spotify owner в
+ * URL нет — оставлен как пустая строка для совместимости сигнатуры. */
export async function startPlaylistUpload(
bot: Bot,
chatId: number,
userId: number,
- owner: string,
- kind: number,
+ _owner: string,
+ kind: string,
container: Container,
): Promise {
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 {
+ 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/, так
+// и spotify:track: (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/ — 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 {
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 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 {
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 {
+ 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 `${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