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>
This commit is contained in:
mrleo1nidandClaude Opus 5.5 committed 2026-10-19 23:21:31 +03:00
1 parent 36e99fe960
commit 9eb1c25147
30 files changed
+7538

No files matched your search

+92
View File
@@ -0,0 +1,92 @@
# Воспроизведение: стриминг, транскодинг, состояние плеера, офлайн
## Качество
| Ключ | Формат | Назначение |
|---|---|---|
| `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.