# Воспроизведение: стриминг, транскодинг, состояние плеера, офлайн ## Качество | Ключ | Формат | Назначение | |---|---|---| | `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 -map 0:a -c:a libopus -b:a 256k -vbr on -f ogg //high.opus.tmp (атомарный rename; single-flight через Redis-лок, чтобы два запроса не транскодировали одно и то же) → 200 с заголовками: X-Accel-Redirect: /_internal/library// (или /_internal/cache//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.