Files
spotify_vibe/DESIGN.md
2026-02-26 19:33:05 +00:00

364 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) могут различаться между приложениями/аккаунтами