Files
pipu-music/docs/playback.md
T
mrleo1nidandClaude Opus 5.5 9eb1c25147 docs: add product idea, design prototypes and development documentation
- 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>
2026-10-19 23:21:31 +03:00

93 lines
7.2 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.
# Воспроизведение: стриминг, транскодинг, состояние плеера, офлайн
## Качество
| Ключ | Формат | Назначение |
|---|---|---|
| `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.