Translate documentation to english
This commit is contained in:
334
DESIGN.md
334
DESIGN.md
@@ -1,86 +1,86 @@
|
||||
# Design / Architecture
|
||||
|
||||
## Цель
|
||||
## Goal
|
||||
|
||||
Сервис генерирует Spotify playlist "daily vibe" на основе:
|
||||
The service generates a Spotify "daily vibe" playlist based on:
|
||||
|
||||
- recent listening пользователя
|
||||
- кэша liked tracks
|
||||
- истории уже выданных рекомендаций
|
||||
- the user's recent listening
|
||||
- a local cache of liked tracks
|
||||
- the history of tracks previously recommended by the bot
|
||||
|
||||
Управление идет через Telegram-бота (`/generate`, `/connect`, `/status` и т.д.), а также опционально через nightly cron trigger.
|
||||
The main user interface is a Telegram bot (`/generate`, `/connect`, `/status`, etc.), with an optional nightly cron trigger.
|
||||
|
||||
## Высокоуровневая схема
|
||||
## High-level overview
|
||||
|
||||
Компоненты:
|
||||
Core components:
|
||||
|
||||
- `FastAPI` приложение
|
||||
- healthcheck
|
||||
- `FastAPI` application
|
||||
- health check
|
||||
- Spotify OAuth start/callback
|
||||
- internal endpoint для cron (`/internal/jobs/nightly`)
|
||||
- internal endpoint for cron (`/internal/jobs/nightly`)
|
||||
- `TelegramBotRunner` (polling)
|
||||
- принимает команды пользователей
|
||||
- запускает генерацию и отправляет статусы
|
||||
- handles user commands
|
||||
- starts generation and sends status updates
|
||||
- `PlaylistJobService`
|
||||
- orchestration одного run (token -> sync likes -> candidates -> playlist -> persist)
|
||||
- orchestrates a single run (token -> sync likes -> candidates -> playlist -> persist)
|
||||
- `RecommendationEngine`
|
||||
- строит seed profile
|
||||
- собирает candidate pool
|
||||
- ранжирует и отбирает треки
|
||||
- builds seed profile
|
||||
- collects candidate pool
|
||||
- ranks and selects tracks
|
||||
- `SpotifyClient` / `LastFmClient`
|
||||
- внешние API вызовы
|
||||
- `SQLite` (через SQLAlchemy async)
|
||||
- пользователи, кэш лайков, история рекомендаций, run log
|
||||
- external API calls
|
||||
- `SQLite` (via async SQLAlchemy)
|
||||
- users, liked cache, recommendation history, run log
|
||||
|
||||
## Runtime / Lifecycle
|
||||
## Runtime / lifecycle
|
||||
|
||||
Точка входа: `app/main.py`.
|
||||
Entry point: `app/main.py`.
|
||||
|
||||
На startup:
|
||||
On startup:
|
||||
|
||||
1. Загружается `Settings` (`app/config.py`)
|
||||
2. Создается async SQLAlchemy engine и session factory (`app/db/session.py`)
|
||||
3. Выполняется `create_all` (автосоздание таблиц)
|
||||
4. Создается общий `httpx.AsyncClient`
|
||||
5. Создаются клиенты:
|
||||
1. Load `Settings` (`app/config.py`)
|
||||
2. Create async SQLAlchemy engine and session factory (`app/db/session.py`)
|
||||
3. Run `create_all` (auto-create tables)
|
||||
4. Create shared `httpx.AsyncClient`
|
||||
5. Create API clients:
|
||||
- `SpotifyClient`
|
||||
- `LastFmClient`
|
||||
6. Создаются сервисы:
|
||||
6. Create services:
|
||||
- `SpotifyAuthService`
|
||||
- `RecommendationEngine`
|
||||
- `PlaylistJobService`
|
||||
7. Инициализируется `TelegramBotRunner` и запускается polling
|
||||
8. Все объекты складываются в `app.state.runtime` и `app.state.services`
|
||||
7. Initialize `TelegramBotRunner` and start polling
|
||||
8. Store runtime/service objects in `app.state.runtime` and `app.state.services`
|
||||
|
||||
На shutdown:
|
||||
On shutdown:
|
||||
|
||||
- останавливается Telegram polling
|
||||
- закрывается `httpx.AsyncClient`
|
||||
- закрывается DB engine
|
||||
- stop Telegram polling
|
||||
- close `httpx.AsyncClient`
|
||||
- dispose DB engine
|
||||
|
||||
## Контейнеры / Deployment
|
||||
## Containers / deployment
|
||||
|
||||
`docker-compose.yml`:
|
||||
`docker-compose.yml` defines:
|
||||
|
||||
- `app` (основной сервис, FastAPI + Telegram polling)
|
||||
- `cron` (опциональный сервис с `supercronic`)
|
||||
- `app` (main service, FastAPI + Telegram polling)
|
||||
- `cron` (optional service with `supercronic`)
|
||||
|
||||
Важно:
|
||||
Important:
|
||||
|
||||
- `cron` помечен `profiles: ["cron"]` и по умолчанию не стартует
|
||||
- manual-first режим: пользователь генерирует плейлисты через Telegram `/generate`
|
||||
- `cron` is under `profiles: ["cron"]` and does not start by default
|
||||
- the project is now manual-first: users generate playlists via Telegram `/generate`
|
||||
|
||||
`cron` выполняет `scripts/run_nightly.sh`, который вызывает:
|
||||
`cron` runs `scripts/run_nightly.sh`, which calls:
|
||||
|
||||
- `POST /internal/jobs/nightly` с `Authorization: Bearer <INTERNAL_JOB_TOKEN>`
|
||||
- `POST /internal/jobs/nightly` with `Authorization: Bearer <INTERNAL_JOB_TOKEN>`
|
||||
|
||||
## Слои приложения
|
||||
## Application layers
|
||||
|
||||
### 1. API Layer (`app/api/routes.py`)
|
||||
### 1. API layer (`app/api/routes.py`)
|
||||
|
||||
Назначение:
|
||||
Responsibilities:
|
||||
|
||||
- HTTP endpoints для OAuth и internal jobs
|
||||
- HTTP endpoints for OAuth and internal jobs
|
||||
|
||||
Endpoints:
|
||||
|
||||
@@ -89,18 +89,18 @@ Endpoints:
|
||||
- `GET /auth/spotify/callback`
|
||||
- `POST /internal/jobs/nightly`
|
||||
|
||||
Особенности:
|
||||
Notes:
|
||||
|
||||
- OAuth callback после успеха отправляет сообщение в Telegram пользователю
|
||||
- internal nightly endpoint защищен `INTERNAL_JOB_TOKEN`
|
||||
- OAuth callback sends a Telegram notification to the user on success
|
||||
- nightly endpoint is protected by `INTERNAL_JOB_TOKEN`
|
||||
|
||||
### 2. Bot Layer (`app/bot/telegram_bot.py`)
|
||||
### 2. Bot layer (`app/bot/telegram_bot.py`)
|
||||
|
||||
Назначение:
|
||||
Responsibilities:
|
||||
|
||||
- пользовательский интерфейс через Telegram команды
|
||||
- user-facing interface via Telegram commands and reply-keyboard buttons
|
||||
|
||||
Поддерживаемые команды:
|
||||
Supported commands:
|
||||
|
||||
- `/start`
|
||||
- `/help`
|
||||
@@ -111,118 +111,120 @@ Endpoints:
|
||||
- `/setsize`
|
||||
- `/setratio`
|
||||
- `/sync`
|
||||
- `/lang`
|
||||
|
||||
Особенности:
|
||||
Notes:
|
||||
|
||||
- `/generate` запускает `PlaylistJobService.generate_for_user(..., force=True, notify=False)`
|
||||
- `/sync` только обновляет кэш лайков
|
||||
- у каждой команды свой короткий DB session через `session_factory`
|
||||
- `/generate` calls `PlaylistJobService.generate_for_user(..., force=True, notify=False)`
|
||||
- `/sync` only refreshes liked tracks cache
|
||||
- each command uses a short-lived DB session from `session_factory`
|
||||
- bot UI supports `ru` and `en` (localized text/buttons)
|
||||
|
||||
### 3. Service Layer
|
||||
### 3. Service layer
|
||||
|
||||
#### `SpotifyAuthService` (`app/services/spotify_auth.py`)
|
||||
|
||||
Роли:
|
||||
Responsibilities:
|
||||
|
||||
- создание OAuth state
|
||||
- обмен `code` на токены
|
||||
- create OAuth state
|
||||
- exchange `code` for tokens
|
||||
- refresh access token
|
||||
- защита от истекшего access token
|
||||
- ensure valid access token before Spotify calls
|
||||
|
||||
Особенности:
|
||||
Notes:
|
||||
|
||||
- сравнение дат нормализуется в UTC (важно для SQLite naive datetime)
|
||||
- сохраняет scopes и expiry в таблице `users`
|
||||
- datetime comparison is normalized to UTC (important for SQLite naive datetimes)
|
||||
- stores scopes and expiry on the `users` row
|
||||
|
||||
#### `RecommendationEngine` (`app/services/recommendation.py`)
|
||||
|
||||
Роли:
|
||||
Responsibilities:
|
||||
|
||||
- sync liked tracks в локальный кэш
|
||||
- sync liked tracks into local cache
|
||||
- build seed profile
|
||||
- collect candidates из нескольких источников
|
||||
- rank/select итоговый список
|
||||
- collect candidates from multiple sources
|
||||
- rank/select final track list
|
||||
|
||||
Текущие источники кандидатов:
|
||||
Current candidate sources:
|
||||
|
||||
- Spotify recommendations
|
||||
- Spotify artist top tracks
|
||||
- Spotify search (seed artist fallback)
|
||||
- Spotify search (seed-artist fallback)
|
||||
- Last.fm track similar -> Spotify search
|
||||
- Last.fm artist similar -> Spotify search
|
||||
|
||||
Ключевые особенности:
|
||||
Key implementation details:
|
||||
|
||||
- соблюдение лимита Spotify recommendations: максимум `5` seed'ов на запрос
|
||||
- мягкая деградация при частичных ошибках источников
|
||||
- liked fallback (если весь пул оказался уже в лайках)
|
||||
- respects Spotify recommendations seed limit: max `5` seeds per request
|
||||
- degrades gracefully when some sources fail
|
||||
- includes liked fallback (if all candidates are already liked)
|
||||
|
||||
#### `PlaylistJobService` (`app/services/playlist_job.py`)
|
||||
|
||||
Роли:
|
||||
Responsibilities:
|
||||
|
||||
- orchestration полного run
|
||||
- создание Spotify playlist и добавление треков
|
||||
- запись run и треков в БД
|
||||
- обновление recommendation history
|
||||
- отправка уведомления в Telegram (если задан notifier)
|
||||
- orchestrate an end-to-end playlist generation run
|
||||
- create Spotify playlist and add tracks
|
||||
- persist run details and track list
|
||||
- update recommendation history
|
||||
- send Telegram notifications (if notifier is configured)
|
||||
|
||||
Порядок выполнения run:
|
||||
Run sequence:
|
||||
|
||||
1. Проверка пользователя / Spotify connection
|
||||
2. Создание записи `playlist_runs` со статусом `running`
|
||||
3. Получение valid access token
|
||||
1. Validate user / Spotify connection
|
||||
2. Create `playlist_runs` row with `running` status
|
||||
3. Get valid access token
|
||||
4. Sync liked tracks
|
||||
5. Сборка плейлиста через `RecommendationEngine`
|
||||
6. Создание playlist в Spotify
|
||||
7. Добавление треков в playlist
|
||||
8. Сохранение run-трека/истории/метаданных
|
||||
9. Commit и возврат `JobOutcome`
|
||||
5. Build playlist via `RecommendationEngine`
|
||||
6. Create playlist in Spotify
|
||||
7. Add tracks to playlist
|
||||
8. Persist run tracks / history / metadata
|
||||
9. Commit and return `JobOutcome`
|
||||
|
||||
При ошибке:
|
||||
On error:
|
||||
|
||||
- `playlist_runs.status = failed`
|
||||
- в `notes` записывается сообщение ошибки
|
||||
- error message is written to `notes`
|
||||
|
||||
## Client Layer
|
||||
## Client layer
|
||||
|
||||
### `SpotifyClient` (`app/clients/spotify.py`)
|
||||
|
||||
Инкапсулирует Spotify Web API.
|
||||
Encapsulates Spotify Web API calls.
|
||||
|
||||
Что важно в текущей реализации:
|
||||
Important implementation choices:
|
||||
|
||||
- `create_playlist()` использует `POST /me/playlists`
|
||||
- это выбранный маршрут, потому что `POST /users/{id}/playlists` может давать `403` в некоторых аккаунтах/приложениях
|
||||
- `add_playlist_items()` использует `POST /playlists/{playlist_id}/items`
|
||||
- маршрут `/tracks` в практике может отдавать `403`, хотя `/items` работает
|
||||
- `delete_playlist()` вызывает `DELETE /playlists/{playlist_id}/followers`
|
||||
- это "unfollow" (Spotify не поддерживает hard delete playlist)
|
||||
- встроены retry на `429` (rate-limit) с `Retry-After`
|
||||
- `create_playlist()` uses `POST /me/playlists`
|
||||
- chosen because `POST /users/{id}/playlists` can return `403` in some app/account combinations
|
||||
- `add_playlist_items()` uses `POST /playlists/{playlist_id}/items`
|
||||
- `/tracks` may return `403` while `/items` succeeds
|
||||
- `delete_playlist()` uses `DELETE /playlists/{playlist_id}/followers`
|
||||
- this is "unfollow" (Spotify does not support hard-delete of playlists)
|
||||
- built-in retry for `429` rate limiting using `Retry-After`
|
||||
|
||||
### `LastFmClient` (`app/clients/lastfm.py`)
|
||||
|
||||
Используется как optional enrichment layer.
|
||||
Optional enrichment source for similarity.
|
||||
|
||||
- может быть отключен (если `LASTFM_API_KEY` пустой)
|
||||
- ошибки Last.fm не должны ломать весь run, если другие источники работают
|
||||
- can be disabled (empty `LASTFM_API_KEY`)
|
||||
- Last.fm errors should not fail the whole run if other sources still work
|
||||
|
||||
## Persistence Layer (SQLite + SQLAlchemy)
|
||||
## Persistence layer (SQLite + SQLAlchemy)
|
||||
|
||||
### Таблицы (`app/db/models.py`)
|
||||
### Tables (`app/db/models.py`)
|
||||
|
||||
#### `users`
|
||||
|
||||
Хранит:
|
||||
Stores:
|
||||
|
||||
- Telegram identity (`telegram_chat_id`, `telegram_username`)
|
||||
- Spotify identity/tokens/scopes (`spotify_user_id`, access/refresh token, expiry, scopes)
|
||||
- пользовательские настройки (`playlist_size`, `min_new_ratio`, timezone)
|
||||
- последние результаты (`last_generated_date`, `latest_playlist_id`, `latest_playlist_url`)
|
||||
- user settings (`playlist_size`, `min_new_ratio`, timezone)
|
||||
- last outputs (`last_generated_date`, `latest_playlist_id`, `latest_playlist_url`)
|
||||
|
||||
#### `auth_states`
|
||||
|
||||
Временные OAuth state для callback:
|
||||
Temporary OAuth state for callback:
|
||||
|
||||
- `state`
|
||||
- `telegram_chat_id`
|
||||
@@ -230,15 +232,15 @@ Endpoints:
|
||||
|
||||
#### `saved_tracks`
|
||||
|
||||
Локальный кэш `Liked Songs` пользователя:
|
||||
Local cache of the user's `Liked Songs`:
|
||||
|
||||
- `spotify_track_id`
|
||||
- название/артисты/album/popularity
|
||||
- track/artist metadata, album, popularity
|
||||
- `added_at`
|
||||
|
||||
#### `recommendation_history`
|
||||
|
||||
История ранее рекомендованных треков:
|
||||
History of previously recommended tracks:
|
||||
|
||||
- `spotify_track_id`
|
||||
- `first_recommended_at`
|
||||
@@ -247,30 +249,30 @@ Endpoints:
|
||||
|
||||
#### `playlist_runs`
|
||||
|
||||
Run log генерации:
|
||||
Playlist generation run log:
|
||||
|
||||
- статус (`running/success/failed`)
|
||||
- метаданные Spotify playlist
|
||||
- статистика (`total/new/reused`)
|
||||
- status (`running/success/failed`)
|
||||
- Spotify playlist metadata
|
||||
- stats (`total/new/reused`)
|
||||
- `notes`
|
||||
|
||||
#### `playlist_run_tracks`
|
||||
|
||||
Снимок состава конкретного run:
|
||||
Snapshot of tracks in a specific run:
|
||||
|
||||
- track id / name / artists
|
||||
- source (из какого источника пришел)
|
||||
- позиция
|
||||
- source (which source produced the track)
|
||||
- position
|
||||
- `is_new_to_bot`
|
||||
|
||||
### Repository Layer (`app/db/repositories.py`)
|
||||
### Repository layer (`app/db/repositories.py`)
|
||||
|
||||
Паттерн:
|
||||
Pattern:
|
||||
|
||||
- thin repositories над SQLAlchemy AsyncSession
|
||||
- изолируют CRUD/query-логику от service layer
|
||||
- thin repositories over `AsyncSession`
|
||||
- isolates CRUD/query logic from the service layer
|
||||
|
||||
Примеры:
|
||||
Repositories include:
|
||||
|
||||
- `UserRepository`
|
||||
- `AuthStateRepository`
|
||||
@@ -278,55 +280,55 @@ Run log генерации:
|
||||
- `RecommendationHistoryRepository`
|
||||
- `PlaylistRunRepository`
|
||||
|
||||
## Потоки данных
|
||||
## Data flows
|
||||
|
||||
### OAuth Flow
|
||||
### OAuth flow
|
||||
|
||||
1. Telegram `/connect`
|
||||
2. `SpotifyAuthService.create_connect_url()`
|
||||
3. Пользователь идет в Spotify auth page
|
||||
3. User opens Spotify auth page
|
||||
4. `GET /auth/spotify/callback`
|
||||
5. `SpotifyAuthService.handle_callback()`
|
||||
6. Токены и Spotify profile сохраняются в `users`
|
||||
7. Пользователю отправляется сообщение в Telegram
|
||||
6. Tokens and Spotify profile are saved to `users`
|
||||
7. User receives a Telegram confirmation message
|
||||
|
||||
### Manual Generate Flow (`/generate`)
|
||||
### Manual generation flow (`/generate`)
|
||||
|
||||
1. Telegram `/generate`
|
||||
2. `PlaylistJobService.generate_for_user(..., force=True)`
|
||||
3. Sync likes + recent listening + candidate collection
|
||||
4. Playlist create + add items в Spotify
|
||||
3. Sync likes + load recent listening + collect candidates
|
||||
4. Create playlist + add items in Spotify
|
||||
5. Persist run/history
|
||||
6. Ответ пользователю в Telegram
|
||||
6. Reply to user in Telegram
|
||||
|
||||
### Nightly Cron Flow (optional)
|
||||
### Nightly cron flow (optional)
|
||||
|
||||
1. `supercronic` в `cron` контейнере
|
||||
1. `supercronic` in the `cron` container
|
||||
2. `scripts/run_nightly.sh`
|
||||
3. `POST /internal/jobs/nightly`
|
||||
4. `PlaylistJobService.generate_for_all_connected_users()`
|
||||
|
||||
## Concurrency / Consistency
|
||||
## Concurrency / consistency
|
||||
|
||||
- Генерация защищена одним `asyncio.Lock` (`generate_lock`) в `PlaylistJobService`
|
||||
- предотвращает одновременные run'ы и гонки обновления history
|
||||
- Большинство операций run выполняются в одной DB session
|
||||
- Ошибки внутри run переводят запись run в `failed`
|
||||
- Generation is protected by a single `asyncio.Lock` (`generate_lock`) in `PlaylistJobService`
|
||||
- prevents overlapping runs and history update races
|
||||
- Most run operations happen in one DB session
|
||||
- Errors inside a run mark the run as `failed`
|
||||
|
||||
## Алгоритм рекомендаций (кратко)
|
||||
## Recommendation algorithm (summary)
|
||||
|
||||
Подробно см. `README.md`, но архитектурно pipeline такой:
|
||||
Detailed explanation is in `README.md`, but architecturally the pipeline is:
|
||||
|
||||
1. Seed profile (recent + liked)
|
||||
2. Candidate pool (Spotify + Last.fm + fallback search)
|
||||
3. Dedupe
|
||||
4. Rank (score penalties/boosts)
|
||||
1. Build seed profile (recent + liked)
|
||||
2. Collect candidate pool (Spotify + Last.fm + fallback search)
|
||||
3. Deduplicate
|
||||
4. Rank (penalties/boosts)
|
||||
5. Select (min_new_ratio + artist caps)
|
||||
6. Persist stats/history
|
||||
|
||||
## Конфигурация
|
||||
## Configuration
|
||||
|
||||
Основные env-переменные (`app/config.py`):
|
||||
Main environment variables (`app/config.py`):
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `SPOTIFY_CLIENT_ID`
|
||||
@@ -341,23 +343,23 @@ Run log генерации:
|
||||
- `RECENT_DAYS_WINDOW`
|
||||
- `PLAYLIST_VISIBILITY`
|
||||
|
||||
## Диагностика / Наблюдаемость
|
||||
## Diagnostics / observability
|
||||
|
||||
Сейчас:
|
||||
Current state:
|
||||
|
||||
- основной feedback идет через Telegram сообщения и `playlist_runs.notes`
|
||||
- HTTP `/health` для liveness
|
||||
- тесты покрывают критичные Spotify routes и части recommendation pipeline
|
||||
- primary feedback comes from Telegram messages and `playlist_runs.notes`
|
||||
- HTTP `/health` for liveness
|
||||
- tests cover critical Spotify routes and parts of the recommendation pipeline
|
||||
|
||||
Что можно улучшить:
|
||||
Possible improvements:
|
||||
|
||||
- структурированные логи по source coverage (сколько кандидатов из каждого источника)
|
||||
- метрики latency/ошибок Spotify/Last.fm
|
||||
- отдельный debug endpoint для dry-run (без создания playlist)
|
||||
- structured logs for source coverage (how many candidates from each source)
|
||||
- metrics for Spotify/Last.fm errors and latency
|
||||
- dedicated debug dry-run endpoint (without creating a playlist)
|
||||
|
||||
## Известные ограничения
|
||||
## Known limitations
|
||||
|
||||
- SQLite подходит для small-scale / single-node сценария
|
||||
- Telegram polling + FastAPI живут в одном процессе/контейнере
|
||||
- per-user timezone используется ограниченно (cron общий)
|
||||
- внешние API ограничения (Spotify/Last.fm) могут различаться между приложениями/аккаунтами
|
||||
- SQLite is suitable for small-scale / single-node setups
|
||||
- Telegram polling + FastAPI run in the same process/container
|
||||
- per-user timezone support is limited (cron is global)
|
||||
- external API limitations (Spotify/Last.fm) vary by app/account
|
||||
|
||||
345
README.md
345
README.md
@@ -1,64 +1,64 @@
|
||||
# Spotify Daily Vibe Bot (Telegram + Spotify + Docker)
|
||||
|
||||
Готовый backend-сервис, который:
|
||||
Ready-to-run backend service that:
|
||||
|
||||
- привязывается к вашему Spotify-аккаунту
|
||||
- читает лайкнутые треки (`Liked Songs`)
|
||||
- учитывает недавние прослушивания (последние дни)
|
||||
- генерирует Spotify-плейлист с похожим вайбом по команде `/generate`
|
||||
- опционально может запускаться по расписанию через `cron`
|
||||
- минимизирует повторы и старается держать `>=80%` новых треков (не лайкнутых и не рекомендованных ранее ботом)
|
||||
- управляется через Telegram
|
||||
- запускается в Docker (`app`, опционально `cron`)
|
||||
- connects to your Spotify account
|
||||
- reads your liked tracks (`Liked Songs`)
|
||||
- uses your recent listening history
|
||||
- generates a Spotify playlist with a similar vibe via `/generate`
|
||||
- can optionally run on a schedule via `cron`
|
||||
- minimizes repeats and tries to keep `>=80%` of tracks "new" (not liked and not previously recommended by the bot)
|
||||
- is controlled via Telegram
|
||||
- runs in Docker (`app`, optional `cron`)
|
||||
|
||||
## Что внутри
|
||||
## What's inside
|
||||
|
||||
- `FastAPI` backend (OAuth callback + internal job endpoint)
|
||||
- `python-telegram-bot` (polling)
|
||||
- `SQLite` (история рекомендаций, кэш лайкнутых, run log)
|
||||
- `supercronic` в отдельном контейнере для nightly cron trigger (опционально)
|
||||
- `SQLite` (recommendation history, liked-track cache, run log)
|
||||
- `supercronic` in a separate container for nightly cron trigger (optional)
|
||||
|
||||
## Важный момент по Spotify API
|
||||
## Important note about the Spotify API
|
||||
|
||||
Spotify endpoint `/recommendations` может быть ограничен/недоступен для некоторых приложений. В сервисе реализован fallback:
|
||||
Spotify endpoint `/recommendations` may be limited/unavailable for some apps. The service includes fallbacks:
|
||||
|
||||
- Spotify recommendations (если доступен)
|
||||
- top tracks по артистам из вашего recent listening / liked library
|
||||
- Spotify search по seed-артистам (если recommendations/top-tracks недоступны)
|
||||
- optional Last.fm similarity (очень желательно для лучшего "вайба")
|
||||
- Spotify recommendations (if available)
|
||||
- top tracks by artists from your recent listening / liked library
|
||||
- Spotify search by seed artists (fallback when recommendations/top-tracks are unavailable)
|
||||
- optional Last.fm similarity (very helpful for better "vibe" quality)
|
||||
|
||||
Для лучшего качества рекомендаций рекомендуется добавить `LASTFM_API_KEY`.
|
||||
For better recommendation quality, adding `LASTFM_API_KEY` is recommended.
|
||||
|
||||
## Быстрый старт
|
||||
## Quick Start
|
||||
|
||||
1. Создайте Telegram-бота через `@BotFather` и получите токен.
|
||||
2. Создайте Spotify App: https://developer.spotify.com/dashboard
|
||||
3. В Spotify App добавьте Redirect URI (должен совпасть 1 в 1), например:
|
||||
1. Create a Telegram bot via `@BotFather` and get a token.
|
||||
2. Create a Spotify App: https://developer.spotify.com/dashboard
|
||||
3. Add a Redirect URI in the Spotify App (must match exactly), for example:
|
||||
- `https://your-domain.com/auth/spotify/callback`
|
||||
- или для локальной разработки через tunnel: `https://xxxx.ngrok-free.app/auth/spotify/callback`
|
||||
4. Скопируйте `.env.example` в `.env` и заполните значения.
|
||||
5. Запустите:
|
||||
- or for local development via tunnel: `https://xxxx.ngrok-free.app/auth/spotify/callback`
|
||||
4. Copy `.env.example` to `.env` and fill in the values.
|
||||
5. Start:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
По умолчанию это поднимет только `app` (ручной режим через Telegram `/generate`).
|
||||
By default this starts only `app` (manual mode via Telegram `/generate`).
|
||||
|
||||
Если захотите включить ночной `cron`, запустите отдельно:
|
||||
If you want nightly `cron`, start it separately:
|
||||
|
||||
```bash
|
||||
docker compose --profile cron up -d cron
|
||||
```
|
||||
|
||||
6. Откройте Telegram и напишите боту:
|
||||
6. Open Telegram and message the bot:
|
||||
- `/start`
|
||||
- `/connect` (получите ссылку на Spotify auth)
|
||||
- после подключения: `/generate`
|
||||
- `/connect` (get the Spotify auth link)
|
||||
- after connecting: `/generate`
|
||||
|
||||
## Настройка `.env`
|
||||
## `.env` configuration
|
||||
|
||||
Минимально обязательные поля:
|
||||
Minimum required fields:
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `SPOTIFY_CLIENT_ID`
|
||||
@@ -66,237 +66,238 @@ docker compose --profile cron up -d cron
|
||||
- `SPOTIFY_REDIRECT_URI`
|
||||
- `INTERNAL_JOB_TOKEN`
|
||||
|
||||
Рекомендуемые:
|
||||
Recommended:
|
||||
|
||||
- `LASTFM_API_KEY` (улучшает похожесть треков)
|
||||
- `LASTFM_API_KEY` (improves similarity quality)
|
||||
- `APP_TIMEZONE` / `TZ`
|
||||
- `SPOTIFY_DEFAULT_MARKET` (двухбуквенный код страны, например `NL`, `DE`, `US`)
|
||||
- `CRON_SCHEDULE` (например `15 2 * * *`, только если включаете `cron`)
|
||||
- `SPOTIFY_DEFAULT_MARKET` (two-letter country code, e.g. `NL`, `DE`, `US`)
|
||||
- `CRON_SCHEDULE` (e.g. `15 2 * * *`, only if you enable `cron`)
|
||||
|
||||
## Telegram команды
|
||||
## Telegram commands
|
||||
|
||||
- `/connect` — привязать Spotify
|
||||
- `/status` — статус подключения и последний плейлист
|
||||
- `/generate` — сгенерировать плейлист сейчас
|
||||
- `/latest` — ссылка на последний плейлист
|
||||
- `/setsize 30` — размер плейлиста (5..100)
|
||||
- `/setratio 0.8` — целевая доля новых треков (0.5..1.0)
|
||||
- `/sync` — принудительно обновить лайкнутые треки
|
||||
- `/connect` - connect Spotify
|
||||
- `/status` - connection status and latest playlist run
|
||||
- `/generate` - generate a playlist now
|
||||
- `/latest` - latest playlist link
|
||||
- `/setsize 30` - playlist size (5..100)
|
||||
- `/setratio 0.8` - target new-track ratio (0.5..1.0)
|
||||
- `/sync` - force sync liked tracks
|
||||
- `/lang ru|en` - switch bot language
|
||||
|
||||
## Алгоритм подбора рекомендаций
|
||||
## Recommendation Algorithm
|
||||
|
||||
Ниже описан фактический пайплайн генерации плейлиста (как он сейчас работает в коде).
|
||||
This is the actual playlist generation pipeline used by the current code.
|
||||
|
||||
### 1. Подготовка входных данных
|
||||
### 1. Input preparation
|
||||
|
||||
Перед генерацией бот:
|
||||
Before generation, the bot:
|
||||
|
||||
- обновляет Spotify access token по refresh token (если нужно)
|
||||
- синхронизирует лайкнутые треки из `Liked Songs` в локальный кэш (`saved_tracks`)
|
||||
- загружает recent listening за окно `RECENT_DAYS_WINDOW` (по умолчанию `5` дней)
|
||||
- загружает историю ранее рекомендованных треков (`recommendation_history`)
|
||||
- refreshes Spotify access token if needed
|
||||
- syncs liked tracks from `Liked Songs` into the local cache (`saved_tracks`)
|
||||
- loads recent listening for the `RECENT_DAYS_WINDOW` period (default `5` days)
|
||||
- loads history of previously recommended tracks (`recommendation_history`)
|
||||
|
||||
### 2. Построение seed-профиля
|
||||
### 2. Seed profile construction
|
||||
|
||||
Бот собирает seed'ы из двух источников: recent plays и liked library.
|
||||
The bot builds seeds from two sources: recent plays and liked library.
|
||||
|
||||
- Recent plays:
|
||||
- каждый recent track получает вес с убыванием по позиции (более свежие прослушивания важнее)
|
||||
- накапливаются веса по трекам и артистам
|
||||
- each track gets a recency-weighted score (newer plays matter more)
|
||||
- weights are accumulated for both tracks and artists
|
||||
- Liked tracks:
|
||||
- берется срез последних лайков (`~120`)
|
||||
- плюс случайная выборка из более старых лайков (для разнообразия)
|
||||
- из них также накапливаются веса по артистам
|
||||
- takes a slice of recent likes (`~120`)
|
||||
- adds a random sample from older likes (for exploration/diversity)
|
||||
- accumulates artist weights from this pool as well
|
||||
|
||||
На выходе seed-профиля формируются:
|
||||
Seed profile output includes:
|
||||
|
||||
- `seed_track_ids` (до ~10 треков)
|
||||
- `seed_artists` (до ~20 артистов)
|
||||
- `seed_artist_names` (для Last.fm и Spotify Search fallback)
|
||||
- `recent_track_meta` (нужно для Last.fm track-similar)
|
||||
- `seed_track_ids` (up to ~10 tracks)
|
||||
- `seed_artists` (up to ~20 artists)
|
||||
- `seed_artist_names` (used by Last.fm and Spotify Search fallback)
|
||||
- `recent_track_meta` (used for Last.fm track-similar lookups)
|
||||
|
||||
### 3. Сбор кандидатов (candidate pool)
|
||||
### 3. Candidate collection (candidate pool)
|
||||
|
||||
Бот собирает общий пул кандидатов из нескольких источников и дедуплицирует их.
|
||||
The bot builds a shared candidate pool from multiple sources and deduplicates results.
|
||||
|
||||
Источники (по порядку):
|
||||
Sources (in order):
|
||||
|
||||
1. `Spotify recommendations`
|
||||
- вызывается батчами
|
||||
- соблюдается лимит Spotify: максимум `5` seed'ов на запрос (суммарно track + artist)
|
||||
- requested in batches
|
||||
- respects Spotify limit: max `5` seeds per request (track + artist combined)
|
||||
2. `Spotify artist top tracks`
|
||||
- по seed-артистам
|
||||
3. `Spotify search` по seed-артистам (fallback)
|
||||
- используется, если recommendations / top-tracks ограничены или дали мало результатов
|
||||
- by seed artists
|
||||
3. `Spotify search` by seed artists (fallback)
|
||||
- used when recommendations / top-tracks are restricted or return too few results
|
||||
4. `Last.fm track similar` -> `Spotify search`
|
||||
- для recent seed-треков
|
||||
- for recent seed tracks
|
||||
5. `Last.fm artist similar` -> `Spotify search`
|
||||
- для seed-артистов
|
||||
- for seed artists
|
||||
|
||||
Если Spotify/Last.fm отдают ошибки на отдельных вызовах, бот старается деградировать мягко (использовать другие источники), а не валить весь run сразу.
|
||||
If Spotify/Last.fm fails on individual calls, the bot tries to degrade gracefully (use other sources) instead of failing the whole run immediately.
|
||||
|
||||
### 4. Дедупликация кандидатов
|
||||
### 4. Candidate deduplication
|
||||
|
||||
Кандидаты дедуплицируются:
|
||||
Candidates are deduplicated:
|
||||
|
||||
- по `spotify_track_id`
|
||||
- по нормализованной сигнатуре `track_name + artist_names` (на случай дублей / разных версий)
|
||||
- by `spotify_track_id`
|
||||
- by normalized signature `track_name + artist_names` (to catch duplicates / alternate versions)
|
||||
|
||||
Если один и тот же трек найден из нескольких источников:
|
||||
If the same track is found via multiple sources:
|
||||
|
||||
- сохраняется лучший score
|
||||
- источник объединяется (например, `source1+source2`)
|
||||
- the best score is kept
|
||||
- the source field is merged (e.g. `source1+source2`)
|
||||
|
||||
### 5. Фильтрация и ранжирование
|
||||
### 5. Filtering and ranking
|
||||
|
||||
Базовая логика:
|
||||
Base logic:
|
||||
|
||||
- сначала исключаются треки, которые уже есть в ваших лайках (`liked_ids`)
|
||||
- если после этого пул пустой, включается fallback:
|
||||
- разрешается использовать already-liked треки (с penalty), чтобы не падать с пустым результатом
|
||||
- first, tracks already in your likes (`liked_ids`) are excluded
|
||||
- if that leaves an empty pool, a fallback is enabled:
|
||||
- already-liked tracks may be used (with a penalty) so the run does not fail with an empty result
|
||||
|
||||
Дополнительные коррекции score:
|
||||
Additional score adjustments:
|
||||
|
||||
- penalty за уже рекомендованные раньше ботом (`history_ids`)
|
||||
- penalty за лайкнутые (если включился liked fallback)
|
||||
- небольшой boost за коллаборации / нескольких артистов
|
||||
- небольшой boost за накопленные причины/источники
|
||||
- popularity scoring слегка тяготеет к mid-popularity (не только мейнстрим и не только deep cuts)
|
||||
- penalty for tracks previously recommended by the bot (`history_ids`)
|
||||
- penalty for liked tracks (only if liked fallback is active)
|
||||
- small boost for collaborations / multiple artists
|
||||
- small boost for tracks with multiple source/reason signals
|
||||
- popularity scoring slightly favors mid-popularity tracks (not only mainstream and not only obscure tracks)
|
||||
|
||||
### 6. Отбор финального списка (selection)
|
||||
### 6. Final selection
|
||||
|
||||
После ранжирования кандидаты делятся на:
|
||||
After ranking, candidates are split into:
|
||||
|
||||
- `novel` — не были рекомендованы ранее и не в лайках
|
||||
- `reused` — уже были рекомендованы или (fallback) уже лайкнуты
|
||||
- `novel` - not previously recommended and not in likes
|
||||
- `reused` - previously recommended or (fallback case) already liked
|
||||
|
||||
Далее бот:
|
||||
Then the bot:
|
||||
|
||||
- сначала пытается набрать минимум по `min_new_ratio`
|
||||
- соблюдает artist caps (ограничение количества треков одного артиста)
|
||||
- если новых треков недостаточно, ослабляет ограничения
|
||||
- затем дозаполняет повторными кандидатами
|
||||
- first tries to satisfy `min_new_ratio`
|
||||
- enforces artist caps (limit tracks per artist)
|
||||
- relaxes caps if there are not enough new tracks
|
||||
- fills the remainder with reused candidates
|
||||
|
||||
Результат:
|
||||
Result includes:
|
||||
|
||||
- `tracks` — финальный порядок треков
|
||||
- `tracks` - final ordered playlist tracks
|
||||
- `new_count` / `reused_count`
|
||||
- `notes` — пояснение, если не удалось выдержать target по новым трекам
|
||||
- `notes` - explanation if the target new ratio could not be met
|
||||
|
||||
### 7. Создание плейлиста и запись истории
|
||||
### 7. Playlist creation and history persistence
|
||||
|
||||
После сборки списка бот:
|
||||
After the final track list is selected, the bot:
|
||||
|
||||
- создает Spotify playlist
|
||||
- добавляет треки
|
||||
- записывает run в `playlist_runs` и `playlist_run_tracks`
|
||||
- обновляет `recommendation_history`
|
||||
- сохраняет `latest_playlist_url` у пользователя
|
||||
- creates a Spotify playlist
|
||||
- adds tracks to it
|
||||
- writes the run to `playlist_runs` and `playlist_run_tracks`
|
||||
- updates `recommendation_history`
|
||||
- stores `latest_playlist_url` for the user
|
||||
|
||||
## Как работает анти-повтор
|
||||
## Anti-repeat behavior
|
||||
|
||||
Бот хранит:
|
||||
The bot stores:
|
||||
|
||||
- все треки, которые уже рекомендовал раньше
|
||||
- все ваши лайкнутые треки (кэш обновляется)
|
||||
- all tracks it has recommended before
|
||||
- all your liked tracks (cached and refreshed)
|
||||
|
||||
При сборке нового плейлиста:
|
||||
When building a new playlist:
|
||||
|
||||
- сначала исключает лайкнутые треки (если это возможно)
|
||||
- отдает приоритет трекам, которых не было в рекомендациях ранее
|
||||
- если новых треков не хватает, дозаполняет повторами из истории
|
||||
- если кандидаты есть только среди лайкнутых, может использовать liked fallback вместо полного фейла run
|
||||
- пишет статистику в БД (`new / reused`)
|
||||
- it first excludes liked tracks (when possible)
|
||||
- prioritizes tracks that have not been recommended before
|
||||
- fills with history repeats only if there are not enough new tracks
|
||||
- may use a liked-track fallback instead of failing the run if all candidates are already liked
|
||||
- stores `new / reused` stats in the DB
|
||||
|
||||
Если в доступном пуле не хватает новых треков для `80%`, бот сообщит об этом в статусе run.
|
||||
If there are not enough new tracks to satisfy the `80%` target, the run status includes a note explaining that.
|
||||
|
||||
## Cron (ночной запуск)
|
||||
## Cron (nightly run)
|
||||
|
||||
По умолчанию `cron` отключен (manual-first режим: запускаете `/generate` вручную в Telegram).
|
||||
`cron` is disabled by default (manual-first mode: run `/generate` manually in Telegram).
|
||||
|
||||
В `docker-compose.yml` сервис `cron` помечен профилем `cron`, поэтому он не стартует при обычном:
|
||||
In `docker-compose.yml`, the `cron` service is under profile `cron`, so it does not start with a normal:
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
Если хотите включить ночной запуск, поднимите его отдельно:
|
||||
To enable nightly runs:
|
||||
|
||||
```bash
|
||||
docker compose --profile cron up -d cron
|
||||
```
|
||||
|
||||
`cron` по `CRON_SCHEDULE` вызывает внутренний endpoint:
|
||||
`cron` calls the internal endpoint on schedule:
|
||||
|
||||
- `POST /internal/jobs/nightly`
|
||||
|
||||
Измените время через `.env`:
|
||||
Change time via `.env`:
|
||||
|
||||
```env
|
||||
CRON_SCHEDULE=15 2 * * *
|
||||
TZ=Europe/Moscow
|
||||
TZ=Europe/Amsterdam
|
||||
```
|
||||
|
||||
Отключить обратно:
|
||||
Disable again:
|
||||
|
||||
```bash
|
||||
docker compose stop cron
|
||||
```
|
||||
|
||||
## Хранилище данных
|
||||
## Data storage
|
||||
|
||||
- SQLite БД: `./data/app.db`
|
||||
- SQLite DB: `./data/app.db`
|
||||
|
||||
Эта папка примонтирована как volume, поэтому данные переживают перезапуск контейнеров.
|
||||
This folder is mounted as a Docker volume, so data persists across container restarts.
|
||||
|
||||
## Проверка работы
|
||||
## Health check / verification
|
||||
|
||||
- `GET /health` должен вернуть `{"ok": true}`
|
||||
- после `/generate` в Telegram появится ссылка на Spotify playlist
|
||||
- `GET /health` should return `{"ok": true}`
|
||||
- after `/generate`, Telegram should send a Spotify playlist link
|
||||
|
||||
## Типичный деплой
|
||||
## Typical deployment
|
||||
|
||||
- VPS + Docker Compose
|
||||
- `APP_BASE_URL` = публичный URL сервиса
|
||||
- `APP_BASE_URL` = public service URL
|
||||
- `SPOTIFY_REDIRECT_URI` = `${APP_BASE_URL}/auth/spotify/callback`
|
||||
- Telegram работает через polling (webhook не нужен)
|
||||
- `cron` можно не включать совсем, если генерация только вручную
|
||||
- Telegram runs via polling (no webhook required)
|
||||
- `cron` can remain disabled if you only want manual generation
|
||||
|
||||
## Архитектура
|
||||
## Architecture
|
||||
|
||||
Подробное описание архитектуры приложения, потоков данных и таблиц БД вынесено в `DESIGN.md`.
|
||||
Detailed architecture, data flow, and DB table docs are in `DESIGN.md`.
|
||||
|
||||
## Feature Plans
|
||||
|
||||
Ниже план ближайших улучшений (roadmap), которые хорошо ложатся на текущую архитектуру.
|
||||
Roadmap items that fit the current architecture well:
|
||||
|
||||
- Явный feedback loop:
|
||||
- команды вроде `/ban`, `/unban`, `/prefer`
|
||||
- отдельная blacklist-таблица, чтобы "не понравилось" != "просто не лайкнул"
|
||||
- Настройки anti-repeat:
|
||||
- жесткий запрет повторов на N дней/недель
|
||||
- отдельные правила для liked / previously recommended
|
||||
- Explicit feedback loop:
|
||||
- commands like `/ban`, `/unban`, `/prefer`
|
||||
- separate blacklist table so "didn't like it" != "just didn't save it"
|
||||
- Anti-repeat controls:
|
||||
- hard no-repeat window (N days/weeks)
|
||||
- separate rules for liked / previously recommended tracks
|
||||
- Explainability / debug:
|
||||
- why-this-track (показать источник, score, причины попадания)
|
||||
- dry-run endpoint/команда без создания плейлиста
|
||||
- Тонкая настройка алгоритма:
|
||||
- веса источников (Spotify / Last.fm / search fallback)
|
||||
- режимы генерации (explore / familiar / mixed)
|
||||
- Улучшение источников кандидатов:
|
||||
- дополнительные музыкальные источники / метаданные
|
||||
- более умная работа с жанрами/артист-кластерами
|
||||
- Персональный scheduler:
|
||||
- per-user timezone и per-user cron schedule
|
||||
- выбор дней недели / времени генерации
|
||||
- Наблюдаемость:
|
||||
- структурированные логи по source coverage и причинам фильтрации
|
||||
- простые метрики по ошибкам Spotify/Last.fm и latency
|
||||
- Хранилище / масштабирование:
|
||||
- миграции (Alembic)
|
||||
- Postgres вместо SQLite для multi-user сценариев
|
||||
- why-this-track (source, score, reasons)
|
||||
- dry-run endpoint/command without creating a playlist
|
||||
- Fine-tuning the algorithm:
|
||||
- source weights (Spotify / Last.fm / search fallback)
|
||||
- generation modes (explore / familiar / mixed)
|
||||
- Better candidate sources:
|
||||
- additional music metadata sources
|
||||
- smarter genre/artist clustering
|
||||
- Personal scheduler:
|
||||
- per-user timezone and per-user cron schedule
|
||||
- weekday / time selection
|
||||
- Observability:
|
||||
- structured logs for source coverage and filtering reasons
|
||||
- basic metrics for Spotify/Last.fm errors and latency
|
||||
- Storage / scaling:
|
||||
- migrations (Alembic)
|
||||
- Postgres instead of SQLite for multi-user usage
|
||||
|
||||
## Ограничения / улучшения (если захотите дальше)
|
||||
## Limitations / future improvements
|
||||
|
||||
- Персонификация по timezone на пользователя (сейчас cron общий, но user-specific generation поддерживается вручную)
|
||||
- Больше источников похожих треков (например, MusicBrainz/Discogs mapping)
|
||||
- Выделенный Postgres вместо SQLite для multi-user нагрузки
|
||||
- Per-user timezone support is only partially used today (cron is global, though manual per-user generation is supported)
|
||||
- More candidate sources could improve quality (e.g. MusicBrainz/Discogs mapping)
|
||||
- Postgres would be better than SQLite for higher multi-user load
|
||||
|
||||
Reference in New Issue
Block a user