A kind of initial commit
This commit is contained in:
363
DESIGN.md
Normal file
363
DESIGN.md
Normal file
@@ -0,0 +1,363 @@
|
||||
# Design / Architecture
|
||||
|
||||
## Цель
|
||||
|
||||
Сервис генерирует Spotify playlist "daily vibe" на основе:
|
||||
|
||||
- recent listening пользователя
|
||||
- кэша liked tracks
|
||||
- истории уже выданных рекомендаций
|
||||
|
||||
Управление идет через Telegram-бота (`/generate`, `/connect`, `/status` и т.д.), а также опционально через nightly cron trigger.
|
||||
|
||||
## Высокоуровневая схема
|
||||
|
||||
Компоненты:
|
||||
|
||||
- `FastAPI` приложение
|
||||
- healthcheck
|
||||
- Spotify OAuth start/callback
|
||||
- internal endpoint для cron (`/internal/jobs/nightly`)
|
||||
- `TelegramBotRunner` (polling)
|
||||
- принимает команды пользователей
|
||||
- запускает генерацию и отправляет статусы
|
||||
- `PlaylistJobService`
|
||||
- orchestration одного run (token -> sync likes -> candidates -> playlist -> persist)
|
||||
- `RecommendationEngine`
|
||||
- строит seed profile
|
||||
- собирает candidate pool
|
||||
- ранжирует и отбирает треки
|
||||
- `SpotifyClient` / `LastFmClient`
|
||||
- внешние API вызовы
|
||||
- `SQLite` (через SQLAlchemy async)
|
||||
- пользователи, кэш лайков, история рекомендаций, run log
|
||||
|
||||
## Runtime / Lifecycle
|
||||
|
||||
Точка входа: `app/main.py`.
|
||||
|
||||
На startup:
|
||||
|
||||
1. Загружается `Settings` (`app/config.py`)
|
||||
2. Создается async SQLAlchemy engine и session factory (`app/db/session.py`)
|
||||
3. Выполняется `create_all` (автосоздание таблиц)
|
||||
4. Создается общий `httpx.AsyncClient`
|
||||
5. Создаются клиенты:
|
||||
- `SpotifyClient`
|
||||
- `LastFmClient`
|
||||
6. Создаются сервисы:
|
||||
- `SpotifyAuthService`
|
||||
- `RecommendationEngine`
|
||||
- `PlaylistJobService`
|
||||
7. Инициализируется `TelegramBotRunner` и запускается polling
|
||||
8. Все объекты складываются в `app.state.runtime` и `app.state.services`
|
||||
|
||||
На shutdown:
|
||||
|
||||
- останавливается Telegram polling
|
||||
- закрывается `httpx.AsyncClient`
|
||||
- закрывается DB engine
|
||||
|
||||
## Контейнеры / Deployment
|
||||
|
||||
`docker-compose.yml`:
|
||||
|
||||
- `app` (основной сервис, FastAPI + Telegram polling)
|
||||
- `cron` (опциональный сервис с `supercronic`)
|
||||
|
||||
Важно:
|
||||
|
||||
- `cron` помечен `profiles: ["cron"]` и по умолчанию не стартует
|
||||
- manual-first режим: пользователь генерирует плейлисты через Telegram `/generate`
|
||||
|
||||
`cron` выполняет `scripts/run_nightly.sh`, который вызывает:
|
||||
|
||||
- `POST /internal/jobs/nightly` с `Authorization: Bearer <INTERNAL_JOB_TOKEN>`
|
||||
|
||||
## Слои приложения
|
||||
|
||||
### 1. API Layer (`app/api/routes.py`)
|
||||
|
||||
Назначение:
|
||||
|
||||
- HTTP endpoints для OAuth и internal jobs
|
||||
|
||||
Endpoints:
|
||||
|
||||
- `GET /health`
|
||||
- `GET /auth/spotify/start`
|
||||
- `GET /auth/spotify/callback`
|
||||
- `POST /internal/jobs/nightly`
|
||||
|
||||
Особенности:
|
||||
|
||||
- OAuth callback после успеха отправляет сообщение в Telegram пользователю
|
||||
- internal nightly endpoint защищен `INTERNAL_JOB_TOKEN`
|
||||
|
||||
### 2. Bot Layer (`app/bot/telegram_bot.py`)
|
||||
|
||||
Назначение:
|
||||
|
||||
- пользовательский интерфейс через Telegram команды
|
||||
|
||||
Поддерживаемые команды:
|
||||
|
||||
- `/start`
|
||||
- `/help`
|
||||
- `/connect`
|
||||
- `/status`
|
||||
- `/generate`
|
||||
- `/latest`
|
||||
- `/setsize`
|
||||
- `/setratio`
|
||||
- `/sync`
|
||||
|
||||
Особенности:
|
||||
|
||||
- `/generate` запускает `PlaylistJobService.generate_for_user(..., force=True, notify=False)`
|
||||
- `/sync` только обновляет кэш лайков
|
||||
- у каждой команды свой короткий DB session через `session_factory`
|
||||
|
||||
### 3. Service Layer
|
||||
|
||||
#### `SpotifyAuthService` (`app/services/spotify_auth.py`)
|
||||
|
||||
Роли:
|
||||
|
||||
- создание OAuth state
|
||||
- обмен `code` на токены
|
||||
- refresh access token
|
||||
- защита от истекшего access token
|
||||
|
||||
Особенности:
|
||||
|
||||
- сравнение дат нормализуется в UTC (важно для SQLite naive datetime)
|
||||
- сохраняет scopes и expiry в таблице `users`
|
||||
|
||||
#### `RecommendationEngine` (`app/services/recommendation.py`)
|
||||
|
||||
Роли:
|
||||
|
||||
- sync liked tracks в локальный кэш
|
||||
- build seed profile
|
||||
- collect candidates из нескольких источников
|
||||
- rank/select итоговый список
|
||||
|
||||
Текущие источники кандидатов:
|
||||
|
||||
- Spotify recommendations
|
||||
- Spotify artist top tracks
|
||||
- Spotify search (seed artist fallback)
|
||||
- Last.fm track similar -> Spotify search
|
||||
- Last.fm artist similar -> Spotify search
|
||||
|
||||
Ключевые особенности:
|
||||
|
||||
- соблюдение лимита Spotify recommendations: максимум `5` seed'ов на запрос
|
||||
- мягкая деградация при частичных ошибках источников
|
||||
- liked fallback (если весь пул оказался уже в лайках)
|
||||
|
||||
#### `PlaylistJobService` (`app/services/playlist_job.py`)
|
||||
|
||||
Роли:
|
||||
|
||||
- orchestration полного run
|
||||
- создание Spotify playlist и добавление треков
|
||||
- запись run и треков в БД
|
||||
- обновление recommendation history
|
||||
- отправка уведомления в Telegram (если задан notifier)
|
||||
|
||||
Порядок выполнения run:
|
||||
|
||||
1. Проверка пользователя / Spotify connection
|
||||
2. Создание записи `playlist_runs` со статусом `running`
|
||||
3. Получение valid access token
|
||||
4. Sync liked tracks
|
||||
5. Сборка плейлиста через `RecommendationEngine`
|
||||
6. Создание playlist в Spotify
|
||||
7. Добавление треков в playlist
|
||||
8. Сохранение run-трека/истории/метаданных
|
||||
9. Commit и возврат `JobOutcome`
|
||||
|
||||
При ошибке:
|
||||
|
||||
- `playlist_runs.status = failed`
|
||||
- в `notes` записывается сообщение ошибки
|
||||
|
||||
## Client Layer
|
||||
|
||||
### `SpotifyClient` (`app/clients/spotify.py`)
|
||||
|
||||
Инкапсулирует Spotify Web API.
|
||||
|
||||
Что важно в текущей реализации:
|
||||
|
||||
- `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`
|
||||
|
||||
### `LastFmClient` (`app/clients/lastfm.py`)
|
||||
|
||||
Используется как optional enrichment layer.
|
||||
|
||||
- может быть отключен (если `LASTFM_API_KEY` пустой)
|
||||
- ошибки Last.fm не должны ломать весь run, если другие источники работают
|
||||
|
||||
## Persistence Layer (SQLite + SQLAlchemy)
|
||||
|
||||
### Таблицы (`app/db/models.py`)
|
||||
|
||||
#### `users`
|
||||
|
||||
Хранит:
|
||||
|
||||
- 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`)
|
||||
|
||||
#### `auth_states`
|
||||
|
||||
Временные OAuth state для callback:
|
||||
|
||||
- `state`
|
||||
- `telegram_chat_id`
|
||||
- `expires_at`
|
||||
|
||||
#### `saved_tracks`
|
||||
|
||||
Локальный кэш `Liked Songs` пользователя:
|
||||
|
||||
- `spotify_track_id`
|
||||
- название/артисты/album/popularity
|
||||
- `added_at`
|
||||
|
||||
#### `recommendation_history`
|
||||
|
||||
История ранее рекомендованных треков:
|
||||
|
||||
- `spotify_track_id`
|
||||
- `first_recommended_at`
|
||||
- `last_recommended_at`
|
||||
- `times_recommended`
|
||||
|
||||
#### `playlist_runs`
|
||||
|
||||
Run log генерации:
|
||||
|
||||
- статус (`running/success/failed`)
|
||||
- метаданные Spotify playlist
|
||||
- статистика (`total/new/reused`)
|
||||
- `notes`
|
||||
|
||||
#### `playlist_run_tracks`
|
||||
|
||||
Снимок состава конкретного run:
|
||||
|
||||
- track id / name / artists
|
||||
- source (из какого источника пришел)
|
||||
- позиция
|
||||
- `is_new_to_bot`
|
||||
|
||||
### Repository Layer (`app/db/repositories.py`)
|
||||
|
||||
Паттерн:
|
||||
|
||||
- thin repositories над SQLAlchemy AsyncSession
|
||||
- изолируют CRUD/query-логику от service layer
|
||||
|
||||
Примеры:
|
||||
|
||||
- `UserRepository`
|
||||
- `AuthStateRepository`
|
||||
- `SavedTrackRepository`
|
||||
- `RecommendationHistoryRepository`
|
||||
- `PlaylistRunRepository`
|
||||
|
||||
## Потоки данных
|
||||
|
||||
### OAuth Flow
|
||||
|
||||
1. Telegram `/connect`
|
||||
2. `SpotifyAuthService.create_connect_url()`
|
||||
3. Пользователь идет в Spotify auth page
|
||||
4. `GET /auth/spotify/callback`
|
||||
5. `SpotifyAuthService.handle_callback()`
|
||||
6. Токены и Spotify profile сохраняются в `users`
|
||||
7. Пользователю отправляется сообщение в Telegram
|
||||
|
||||
### Manual Generate 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
|
||||
5. Persist run/history
|
||||
6. Ответ пользователю в Telegram
|
||||
|
||||
### Nightly Cron Flow (optional)
|
||||
|
||||
1. `supercronic` в `cron` контейнере
|
||||
2. `scripts/run_nightly.sh`
|
||||
3. `POST /internal/jobs/nightly`
|
||||
4. `PlaylistJobService.generate_for_all_connected_users()`
|
||||
|
||||
## Concurrency / Consistency
|
||||
|
||||
- Генерация защищена одним `asyncio.Lock` (`generate_lock`) в `PlaylistJobService`
|
||||
- предотвращает одновременные run'ы и гонки обновления history
|
||||
- Большинство операций run выполняются в одной DB session
|
||||
- Ошибки внутри run переводят запись run в `failed`
|
||||
|
||||
## Алгоритм рекомендаций (кратко)
|
||||
|
||||
Подробно см. `README.md`, но архитектурно pipeline такой:
|
||||
|
||||
1. Seed profile (recent + liked)
|
||||
2. Candidate pool (Spotify + Last.fm + fallback search)
|
||||
3. Dedupe
|
||||
4. Rank (score penalties/boosts)
|
||||
5. Select (min_new_ratio + artist caps)
|
||||
6. Persist stats/history
|
||||
|
||||
## Конфигурация
|
||||
|
||||
Основные env-переменные (`app/config.py`):
|
||||
|
||||
- `TELEGRAM_BOT_TOKEN`
|
||||
- `SPOTIFY_CLIENT_ID`
|
||||
- `SPOTIFY_CLIENT_SECRET`
|
||||
- `SPOTIFY_REDIRECT_URI`
|
||||
- `SPOTIFY_DEFAULT_MARKET`
|
||||
- `LASTFM_API_KEY` (optional)
|
||||
- `INTERNAL_JOB_TOKEN`
|
||||
- `DB_PATH`
|
||||
- `DEFAULT_PLAYLIST_SIZE`
|
||||
- `MIN_NEW_RATIO`
|
||||
- `RECENT_DAYS_WINDOW`
|
||||
- `PLAYLIST_VISIBILITY`
|
||||
|
||||
## Диагностика / Наблюдаемость
|
||||
|
||||
Сейчас:
|
||||
|
||||
- основной feedback идет через Telegram сообщения и `playlist_runs.notes`
|
||||
- HTTP `/health` для liveness
|
||||
- тесты покрывают критичные Spotify routes и части recommendation pipeline
|
||||
|
||||
Что можно улучшить:
|
||||
|
||||
- структурированные логи по source coverage (сколько кандидатов из каждого источника)
|
||||
- метрики latency/ошибок Spotify/Last.fm
|
||||
- отдельный debug endpoint для dry-run (без создания playlist)
|
||||
|
||||
## Известные ограничения
|
||||
|
||||
- SQLite подходит для small-scale / single-node сценария
|
||||
- Telegram polling + FastAPI живут в одном процессе/контейнере
|
||||
- per-user timezone используется ограниченно (cron общий)
|
||||
- внешние API ограничения (Spotify/Last.fm) могут различаться между приложениями/аккаунтами
|
||||
Reference in New Issue
Block a user