Files
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

7.3 KiB
Raw Permalink Blame History

Воспроизведение: стриминг, транскодинг, состояние плеера, офлайн

Качество

Ключ Формат Назначение
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:

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. Серверная сторона:

  • GET /tracks/{id}/download-url?q=high → подписанная ссылка на 1 ч (тот же /stream, но с dl=1: Content-Disposition: attachment, засчитывается не как прослушивание).
  • Офлайн-события приходят позже с offline=true и реальным occurred_at; сервер принимает события давностью до 30 дней.
  • Лимит: не более 2 параллельных загрузок на пользователя (rate limit в Redis), чтобы не забить канал NFS.