Files
pipu-music/docs/playback.md
T
mrleo1nidandCursor 066ba24834
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
feat: finish the upload, playback, and test gaps from the docs
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:37:53 +03:00

94 lines
7.3 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 с до конца текущего) —
на сервере это прогревает транскод.
- Фоновая подготовка: выдача пачки волны вызывает `playback.Prewarm` — для качества `high` транскод
строится сразу, не дожидаясь первого `Range` клиента.
- Кэш транскодов — 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.