- IDEA.md: product vision from the user's perspective (input for design)
- Architecture, ADRs, data model, REST API overview
- Auth and roles, library storage (NFS, path regexes, scanner, search),
playback, recommendation system ("My Wave"), track upload queue
- Web, Android (offline, self-update), i18n, design system
- Testing strategy, Gitea Actions CI/CD, deployment, conventions, roadmap
- Clickable web/Android design prototypes and wave button variants
- .claude/launch.json to serve the prototypes locally
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
7.2 KiB
Markdown
93 lines
7.2 KiB
Markdown
# Воспроизведение: стриминг, транскодинг, состояние плеера, офлайн
|
||
|
||
## Качество
|
||
|
||
| Ключ | Формат | Назначение |
|
||
|---|---|---|
|
||
| `original` | исходный файл | Веб по умолчанию в локальной сети, «без потерь» |
|
||
| `high` | Opus 256 kbps (Ogg) | Мобильное по Wi-Fi, загрузки по умолчанию |
|
||
| `medium` | Opus 128 kbps | Мобильное по сотовой сети |
|
||
| `low` | Opus 64 kbps | Экономия трафика |
|
||
| `auto` | клиент выбирает по типу сети / настройкам | |
|
||
|
||
Если исходник — lossy с битрейтом ниже целевого (например, MP3 192 при запросе `high`), отдаётся оригинал
|
||
(перекодирование вверх бессмысленно). Браузерные исключения: исходный формат, который браузер не играет
|
||
(APE, WV, AIFF в некоторых браузерах), автоматически транскодируется в `high`.
|
||
|
||
## Отдача файла
|
||
|
||
```
|
||
GET /api/v1/stream/{trackId}?q=high&exp=1760900000&sig=…
|
||
→ api: проверка sig/exp, пользователь не заблокирован, трек доступен
|
||
→ если нужен транскод и его нет в кэше:
|
||
ffmpeg -i <src> -map 0:a -c:a libopus -b:a 256k -vbr on -f ogg <cache>/<trackId>/high.opus.tmp
|
||
(атомарный rename; single-flight через Redis-лок, чтобы два запроса не транскодировали одно и то же)
|
||
→ 200 с заголовками:
|
||
X-Accel-Redirect: /_internal/library/<source_id>/<rel_path> (или /_internal/cache/<trackId>/high.opus)
|
||
Content-Type: audio/flac | audio/ogg; codecs=opus | ...
|
||
Cache-Control: private, max-age=43200
|
||
```
|
||
|
||
nginx:
|
||
```nginx
|
||
location /_internal/library/ { internal; alias /library/; sendfile on; aio threads; }
|
||
location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; }
|
||
```
|
||
|
||
- Транскод трека 4 мин в Opus занимает ~1–2 с на одном ядре, поэтому делаем синхронно при первом запросе.
|
||
Клиенты **предзагружают следующий трек очереди** (запрос `HEAD`/`Range: bytes=0-0` за 20 с до конца текущего) —
|
||
на сервере это прогревает транскод.
|
||
- Фоновая задача `pretranscode` для треков из выданных пачек волны (они почти наверняка будут сыграны).
|
||
- Кэш транскодов — LRU с лимитом `TRANSCODE_CACHE_LIMIT_GB` (по умолчанию 50 ГБ), чистка по `atime` раз в час.
|
||
- Пул ffmpeg ограничен (`TRANSCODE_CONCURRENCY`, по умолчанию = число ядер / 2).
|
||
|
||
## Нормализация громкости
|
||
|
||
`track_features.loudness_lufs` отдаётся в DTO; клиенты применяют ReplayGain-подобное усиление к целевым −14 LUFS
|
||
(веб — `GainNode`, Android — `react-native-track-player` volume на трек). Включается в настройках, по умолчанию вкл.
|
||
|
||
## Состояние плеера («запомнить трек и момент»)
|
||
|
||
Сервер хранит одну запись `playback_state` на пользователя.
|
||
|
||
Клиент отправляет `PUT /me/playback-state`:
|
||
- каждые 10 с во время воспроизведения;
|
||
- на паузе, переключении трека, перемотке (с debounce 1 с);
|
||
- при закрытии вкладки / сворачивании приложения (`navigator.sendBeacon` на вебе, `AppState` на Android).
|
||
|
||
При старте клиента:
|
||
1. Берётся локальная копия (localStorage / MMKV) — плеер появляется мгновенно, без ожидания сети.
|
||
2. Параллельно `GET /me/playback-state`; если серверная запись новее (`updated_at`) и с другого устройства —
|
||
применяется серверная (плеер в состоянии паузы на сохранённой позиции; автозапуск не делаем).
|
||
3. Если трек из сохранённой очереди стал недоступен — пропускается.
|
||
|
||
Конфликты: last-write-wins по `updated_at`. Для волны в `context` хранится `wave_session_id`: при восстановлении
|
||
волна продолжает ту же сессию (если ей < 12 ч) или начинает новую с теми же занятием / настроением.
|
||
|
||
## События прослушивания
|
||
|
||
Клиентский модуль `packages/core/playback-events` формирует события и буферизует их:
|
||
|
||
| Событие | Когда | Ключевые поля |
|
||
|---|---|---|
|
||
| `play_start` | Начало воспроизведения трека | `context_type`, `context_id`, `wave_session_id` |
|
||
| `play_end` | Трек доигран до конца (или осталось < 5 с) | `played_ms` |
|
||
| `skip` | Переключение до конца | `position_ms`, `played_ms` |
|
||
| `seek` | Перемотка | `from_ms`, `position_ms` |
|
||
| `pause` / `resume` | — (только для аналитики сессии, в обучении не используются) | |
|
||
| `like` / `unlike` / `dislike` / `undislike` | Реакция | `position_ms` |
|
||
|
||
- `played_ms` считается как реально прослушанное время (перемотки вперёд не засчитываются).
|
||
- Отправка: пачкой раз в 30 с, при смене трека и при уходе в фон; при ошибке — повтор с экспоненциальной задержкой.
|
||
- Каждое событие имеет `id` (UUIDv7) — сервер дедуплицирует.
|
||
- **Засчитанное прослушивание** (для `play_count`, истории и популярности): `played_ms ≥ min(50% длительности, 4 мин)`
|
||
либо `play_end`. В историю попадают все `play_start`, у которых `played_ms ≥ 30 с` или трек короче 30 с.
|
||
|
||
## Офлайн (Android)
|
||
|
||
Подробности клиента — [mobile.md](mobile.md#офлайн). Серверная сторона:
|
||
- `GET /tracks/{id}/download-url?q=high` → подписанная ссылка на 1 ч (тот же `/stream`, но с `dl=1`:
|
||
`Content-Disposition: attachment`, засчитывается не как прослушивание).
|
||
- Офлайн-события приходят позже с `offline=true` и реальным `occurred_at`; сервер принимает события давностью до 30 дней.
|
||
- Лимит: не более 2 параллельных загрузок на пользователя (rate limit в Redis), чтобы не забить канал NFS.
|