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

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "design",
"runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"],
"port": 8765
}
]
}
+6
View File
@@ -1,2 +1,8 @@
# pipu-music
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация для разработки: [docs/README.md](docs/README.md)
- План: [docs/roadmap.md](docs/roadmap.md)
+169
View File
@@ -0,0 +1,169 @@
# Pipu Music — исходная идея продукта
> Документ описывает **что** должно быть в приложении с точки зрения пользователя.
> Используется как входные данные для создания дизайна. Технические решения — в [README документации](README.md).
## Суть
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
Интерфейс двуязычный — **русский и английский**, с возможностью добавить другие языки позже.
Переключатель языка — в профиле / настройках (и на экране входа).
## Доступ и учётные записи
- При первом открытии приложение показывает **экран входа** (логин + пароль).
В мобильном приложении на этом экране также указывается **адрес сервера**.
- **Регистрации нет.** Учётные записи создаёт администрация.
- Роли:
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
и управлять хранилищами музыки.
- **Модератор** (заместитель администратора) — доступ к админке, может создавать обычных пользователей
и предлагать новые треки на добавление в библиотеку.
- **Пользователь** — слушает музыку.
- **Первый вход администратора:** система сразу предлагает сменить стандартные логин и пароль администратора
(обязательный шаг, пока он не пройден, дальше не пустит).
- **Любой пользователь** может сменить свой пароль (в профиле / настройках).
- Если администратор сбросил пароль пользователю, тот при следующем входе обязан задать новый.
## Каркас интерфейса (на всех экранах)
```
┌──────────────────────────────────────────────────────────────┐
│ [Логотип] [ 🔍 Поиск ...................... ] [👤] │ ← верхняя панель
├────────────┬─────────────────────────────────────────────────┤
│ Главная │ │
│ Коллекция │ Основной контент │
│ История │ │
│ Лайки │ │
│ Дизлайки │ │
│ Скачанное│ │
│ │ │
│ Админка │ │
├────────────┴─────────────────────────────────────────────────┤
│ [обл.] Трек — Исполнитель ⏮ ⏯ ⏭ ━━━━●━━━━ 1:23/3:45 🔊 ♥ 👎 │ ← плеер
└──────────────────────────────────────────────────────────────┘
```
### Поиск (всегда виден вверху)
- При вводе появляется **выпадающий список** результатов, сгруппированный по типам:
**исполнители, альбомы, треки**. Клик по результату сразу открывает исполнителя / альбом / запускает трек.
- По нажатию **Enter** открывается **полная страница поиска** со всеми результатами.
- Поиск прощает опечатки и неправильную раскладку.
### Левая панель навигации
- **Главная**
- **Коллекция** — с подкатегориями:
- **История** — прослушанные треки;
- **Лайки** — понравившиеся треки;
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
- **Админка** — видна только администраторам и модераторам.
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
### Плеер (всегда внизу)
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь.
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
и даже на другом устройстве можно продолжить с того же места.
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
- Дизлайк в волне сразу переключает на следующий трек.
## Главная страница
- В центре — **большая кнопка «Моя волна»**. Это главный элемент приложения:
- **анимированная** — живая, «дышащая» волновая анимация; во время воспроизведения пульсирует
в такт музыке (темп и энергия текущего трека);
- **интерактивная** — реагирует на наведение и нажатие; видно, играет волна или нет; повторное нажатие — пауза.
- Рядом с волной — выбор **занятия** и **настроения**, по которым волна корректируется:
- занятие: бегаю, просыпаюсь, работаю / фокус, тренировка, в дороге, отдыхаю, засыпаю;
- настроение: энергичное, весёлое, спокойное, грустное;
- можно не выбирать ничего — тогда волна просто «под вас».
- Волна постоянно учится: лайки, дизлайки, пропуски, сколько раз и насколько долго слушался трек,
жанры и звучание — всё влияет на то, что будет дальше.
## Страницы
| Страница | Содержимое |
|---|---|
| Вход | (мобильное: адрес сервера), логин, пароль, «Войти», выбор языка. Без регистрации. |
| Смена учётных данных админа | Обязательный экран при первом входе: новый логин, новый пароль, подтверждение. |
| Обязательная смена пароля | После сброса пароля администрацией. |
| Главная | Кнопка «Моя волна», выбор занятия / настроения. |
| Коллекция → История | Прослушанные треки, сгруппированные по дням; можно запустить любой. |
| Коллекция → Лайки | Понравившиеся треки, можно запустить все / перемешать. |
| Коллекция → Дизлайки | Скрытые из рекомендаций треки, можно снять дизлайк. |
| Коллекция → Скачанное | (мобильное) офлайн-треки, занимаемое место, удалить. |
| Поиск | Полные результаты: исполнители, альбомы, треки. |
| Исполнитель | Фото / обложка, альбомы, популярные треки, «волна по исполнителю». |
| Альбом | Обложка, год, список треков, «скачать альбом» (мобильное). |
| Профиль / настройки | Смена пароля, язык, качество звука (мобильное: для сети и для скачивания), проверка обновлений (мобильное), выход. |
| Админка → Пользователи | Список, создание, роли, сброс пароля, блокировка. |
| Админка → Хранилища | (только админ) Подключение хранилищ музыки, шаблоны структуры папок, запуск сканирования, статус. |
| Админка → Библиотека | Статистика: число треков, ход сканирования и анализа звука, ошибки. |
| Админка → Предложить треки | (модератор и админ) Загрузка файлов и папок перетаскиванием, индикатор заполненности очереди, список своих предложений со статусами. |
| Админка → Очередь на добавление | (только админ) Рассмотрение предложенных треков: прослушать, поправить данные, увидеть, куда файл ляжет, одобрить / отклонить. |
| Админка → Настройки | (только админ) Лимит очереди на добавление, хранилище для новых треков. |
## Админка
- **Модератор**: видит пользователей, создаёт обычных пользователей, сбрасывает им пароль, блокирует.
- **Администратор**: всё то же + создание модераторов, назначение / снятие роли модератора,
**управление хранилищами**.
- **Хранилища** (только админ):
- добавить хранилище: сетевая папка NFS (адрес сервера + путь) или уже подключённая папка на сервере;
- проверить подключение;
- задать **один или несколько шаблонов (регулярных выражений)**, по которым из пути к файлу
извлекаются исполнитель, альбом, год, номер трека, название — например
`Исполнитель/Год - Альбом/01 - Название.flac`;
- **предпросмотр**: показать, как шаблоны разберут несколько реальных файлов, до сохранения;
- выбрать, что важнее — теги файла или структура папок;
- запустить пересканирование, видеть прогресс и ошибки.
- **Предложение треков** (модератор и админ):
- модератор загружает новые треки (файлы или целые папки альбомов) в **очередь на добавление**;
- у очереди есть **общий лимит размера** (например, 10 ГБ), его задаёт админ; виден индикатор «занято X из Y ГБ»;
- треки, которые **уже есть** в библиотеке (или уже лежат в очереди), **сразу отклоняются** — ещё до загрузки,
если это можно понять заранее, — и **не занимают место** в очереди; модератор видит, с каким треком совпало;
- модератор видит свои предложения и их статус (загружается, на рассмотрении, одобрено, добавлено, отклонено + причина),
может отозвать предложение до рассмотрения.
- **Очередь на добавление** (только админ):
- список предложенных треков, сгруппированных по альбомам: можно прослушать, посмотреть качество,
поправить исполнителя / альбом / название / год / обложку;
- пометка «возможно, уже есть» для похожих треков (другая версия, ремастер) — решает админ;
- показан **путь, по которому файл ляжет** в хранилище, — в том же виде, как уже разложена музыка на сервере;
- одобрить / отклонить (с причиной), в том числе пачкой;
- после одобрения файл сам укладывается на файловый сервер и появляется в библиотеке.
- Небольшая, утилитарная — не главная часть продукта.
## Рекомендации («Моя волна»)
Максимально умная система, которая учитывает:
- жанры, а также **звучание** трека (темп, энергия, настроение) — даже если трек никто не слушал;
- лайки и дизлайки;
- сколько раз и как долго слушался каждый трек, пропуски;
- выбранное занятие / настроение;
- вкусы похожих пользователей;
- постепенно обучается на поведении пользователей.
## Мобильное приложение (Android)
- Тот же функционал, что и веб: вход, главная с волной, поиск, коллекция, плеер.
- Фоновое воспроизведение, управление с экрана блокировки / шторки / наушников.
- **Офлайн по выбору пользователя**: скачать трек / альбом / лайки; прослушивания без сети
учитываются и отправляются на сервер позже.
- **Самообновление**: приложение проверяет новые релизы в репозитории, предлагает обновиться
и скачивает новую версию.
## Инфраструктура (для контекста)
- Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки.
- Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок.
- Один репозиторий: веб, мобильное приложение, бэкенд, тесты, CI/CD.
- CI/CD на Gitea: по тегу собирается релиз.
+34
View File
@@ -0,0 +1,34 @@
# Документация Pipu Music
Pipu Music — self-hosted музыкальный сервис (веб + Android) с умной бесконечной лентой «Моя волна».
## Карта документов
| Документ | О чём |
|---|---|
| [IDEA.md](IDEA.md) | Исходная идея продукта глазами пользователя (вход для дизайна) |
| [design.md](design.md) | Дизайн-система (токены, шрифты, анимация), кнопка волны, решения по вариантам прототипа, ссылки на макеты |
| [architecture.md](architecture.md) | Общая архитектура, компоненты, потоки данных, структура репозитория |
| [decisions.md](decisions.md) | ADR — принятые архитектурные решения и их обоснование |
| [data-model.md](data-model.md) | Схема БД PostgreSQL |
| [api.md](api.md) | REST API: соглашения, эндпоинты, ошибки |
| [auth-and-roles.md](auth-and-roles.md) | Аутентификация, токены, роли и права, первый запуск |
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент |
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [testing.md](testing.md) | Стратегия тестирования |
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [deployment.md](deployment.md) | Docker Compose, конфигурация, бэкапы |
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
## Стек в одном абзаце
Backend API — **Go**; рекомендации и аудиоанализ — **Python**; БД — **PostgreSQL + pgvector**;
поиск — **Meilisearch**; кэш / очереди событий — **Redis**; раздача аудио — **nginx (X-Accel-Redirect)**;
веб — **React + TypeScript + Vite**; мобильное — **React Native (Expo, Android)**;
монорепо — **pnpm + Turborepo**; CI/CD — **Gitea Actions**; деплой — **Docker Compose**.
+164
View File
@@ -0,0 +1,164 @@
# REST API
Источник истины — `api/openapi.yaml` (OpenAPI 3.1). Этот документ — обзор и соглашения.
## Соглашения
- Базовый путь: `/api/v1`. Формат — JSON, `snake_case`.
- Аутентификация: `Authorization: Bearer <access_jwt>` (кроме `/auth/login`, `/auth/refresh`, `/healthz`,
`/stream/*` и `/covers/*` — у них подписанные ссылки).
- Пагинация — курсорная: `?limit=50&cursor=<opaque>` → `{ "items": [...], "next_cursor": "..." | null }`.
- Ошибки — `application/problem+json` (RFC 9457):
```json
{ "type": "about:blank", "status": 422, "code": "validation.password_too_short",
"detail": "password must be at least 8 characters", "params": { "min": 8 },
"errors": [{ "field": "new_password", "code": "validation.password_too_short" }] }
```
Клиент показывает перевод `code` (с подстановкой `params`), `detail` — только для логов.
- Мутирующие запросы с клиента могут нести `Idempotency-Key` (UUID); повтор в течение 24 ч вернёт тот же ответ.
- Время — ISO 8601 UTC. Длительности — миллисекунды (`*_ms`).
- Rate limit: `429` с `Retry-After`. `/auth/login` — 5 попыток / мин на IP+логин.
## Основные DTO
```jsonc
// TrackDto
{ "id": "…", "title": "…", "duration_ms": 215000,
"artists": [{ "id": "…", "name": "…" }],
"album": { "id": "…", "title": "…", "year": 2019, "cover_url": "/api/v1/covers/…?sig=…",
"cover_colors": ["#F2A23A", "#1C3B6E", "#35D3FF"] },
"genres": ["indie rock"],
"reaction": "like" | "dislike" | null,
"stream_url": "/api/v1/stream/…?q=auto&exp=…&sig=…",
"features": { "bpm": 128, "energy": 0.72 } // для анимации волны, опционально
}
```
## Эндпоинты
### Служебные
| Метод | Путь | Описание |
|---|---|---|
| GET | `/healthz`, `/readyz` | Живость / готовность |
| GET | `/api/v1/server-info` | Версия сервера, мин. версия клиента, поддерживаемые локали, `bootstrap_required` |
### Аутентификация — [auth-and-roles.md](auth-and-roles.md)
| Метод | Путь | Кто | Описание |
|---|---|---|---|
| POST | `/auth/login` | все | `{username, password, client_type, device_name}` → `{access_token, expires_in, refresh_token?, user}`; веб получает refresh в cookie |
| POST | `/auth/refresh` | все | Ротация refresh → новая пара |
| POST | `/auth/logout` | auth | Отзыв текущей семьи токенов |
| POST | `/auth/logout-all` | auth | Отзыв всех сессий пользователя |
| GET | `/me` | auth | Текущий пользователь, флаги `must_change_*` |
| PATCH | `/me` | auth | `display_name`, `locale`, `ui_prefs` (тема, вид волны и плеера, ambient, кроссфейд, нормализация) |
| POST | `/me/password` | auth | `{current_password, new_password}`; отзывает прочие сессии |
| POST | `/me/bootstrap-credentials` | admin c `must_change_credentials` | `{new_username, new_password}` |
| GET | `/me/sessions` · DELETE `/me/sessions/{id}` | auth | Активные устройства |
### Каталог
| Метод | Путь | Описание |
|---|---|---|
| GET | `/artists/{id}` | Исполнитель + счётчики |
| GET | `/artists/{id}/albums` | Альбомы |
| GET | `/artists/{id}/top-tracks` | Популярные (по прослушиваниям всех пользователей) |
| GET | `/albums/{id}` | Альбом с треками |
| GET | `/tracks/{id}` | Трек |
| POST | `/tracks/batch` | `{ids:[]}` → треки (для восстановления очереди) |
| GET | `/covers/{albumId}?size=64|300|1200&sig=` | Обложка (WebP, X-Accel) |
### Поиск — [library-and-storage.md](library-and-storage.md#поиск)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/search/suggest?q=` | Выпадающий список: до 3 исполнителей, 3 альбомов, 5 треков; < 50 мс |
| GET | `/search?q=&type=all|artists|albums|tracks&limit=&cursor=` | Полная страница |
| POST | `/search/click` | `{q, entity_type, entity_id}` — сигнал для ранжирования / рекомендаций |
### Воспроизведение — [playback.md](playback.md)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/stream/{trackId}?q=auto|original|high|medium|low&exp&sig` | Аудио (X-Accel, Range) |
| GET | `/tracks/{id}/download-url?q=` | Подписанная ссылка для офлайн-загрузки |
| GET | `/me/playback-state` | Последний трек, позиция, очередь |
| PUT | `/me/playback-state` | Сохранить (клиент шлёт раз в 10 с и на паузе / закрытии; `If-Unmodified-Since` для конфликтов) |
| POST | `/events` | Пачка событий `{events:[{id, type, track_id, occurred_at, ...}]}` до 500 шт. → `{accepted, duplicates}` |
### Коллекция
| Метод | Путь | Описание |
|---|---|---|
| GET | `/me/history?cursor=` | История (сгруппирована по дням на клиенте) |
| DELETE | `/me/history` | Очистить историю (события остаются для статистики, помечаются скрытыми) |
| GET | `/me/likes?cursor=` · `/me/dislikes?cursor=` | Списки |
| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` |
| DELETE | `/me/reactions/tracks/{id}` | Снять реакцию |
| PUT/DELETE | `/me/reactions/artists/{id}` | Реакция на исполнителя |
### Моя волна — [recommendations.md](recommendations.md)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/wave/options` | Доступные занятия / настроения (ключи для i18n, иконки) |
| POST | `/wave/sessions` | `{activity?, mood?, seed?: {type, id}}` → `{session_id, tracks:[5 TrackDto]}` |
| POST | `/wave/sessions/{id}/next` | `{feedback:[{track_id, outcome}]}` → следующая пачка |
| PATCH | `/wave/sessions/{id}` | Сменить занятие / настроение на лету |
| DELETE | `/wave/sessions/{id}` | Завершить |
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
| Метод | Путь | Описание |
|---|---|---|
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
### Админка — права см. [auth-and-roles.md](auth-and-roles.md#матрица-прав)
| Метод | Путь | Кто |
|---|---|---|
| GET | `/admin/users?q=&role=&cursor=` | admin, moderator |
| POST | `/admin/users` `{username, display_name, role}` → `{user, temporary_password}` (пароль генерирует сервер, `must_change_password = true`) | moderator: только `role=user`; admin: любые |
| PATCH | `/admin/users/{id}` `{display_name?, role?, is_blocked?}` | role — только admin |
| POST | `/admin/users/{id}/reset-password` → `{temporary_password}` | moderator — только для user |
| DELETE | `/admin/users/{id}` | admin |
| GET/POST | `/admin/sources` | admin |
| GET/PATCH/DELETE | `/admin/sources/{id}` | admin |
| POST | `/admin/sources/{id}/test` | admin — проверить монтирование и доступ |
| PUT | `/admin/sources/{id}/patterns` `{patterns:[{regex, is_enabled}]}` | admin |
| POST | `/admin/sources/{id}/patterns/preview` `{patterns, sample?:50}` | admin — разбор реальных путей без сохранения |
| POST | `/admin/sources/{id}/scan` `{full?: bool}` | admin |
| GET | `/admin/library/status` | admin, moderator — счётчики, прогресс скана / анализа, ошибки |
| GET | `/admin/library/errors?cursor=` | admin |
| POST | `/admin/search/reindex` | admin |
| GET | `/admin/recommender/status` | admin — версии моделей, метрики |
| GET | `/admin/settings` · PATCH `/admin/settings` | admin — лимиты очереди, хранилище по умолчанию и пр. |
| POST | `/admin/sources/{id}/write-template/suggest` → `{template, matched_ratio}` | admin — автоподбор шаблона раскладки |
| POST | `/admin/sources/{id}/write-template/verify` `{template}` → `{ok, roundtrip_ratio, mismatches[]}` | admin — проверка обратимости |
### Предложение треков — [uploads.md](uploads.md)
| Метод | Путь | Кто | Описание |
|---|---|---|---|
| GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` |
| POST | `/uploads/precheck` | moderator, admin | `{files:[{client_id, size, sha256, tags:{artist,title,album,duration_ms}}]}` → для каждого `accepted` / `duplicate` (+ `track`) / `too_large`; ничего не резервирует |
| POST | `/uploads` | moderator, admin | Создать пачку `{comment, files:[{client_id, name, rel_path, size, sha256}]}` → резерв квоты; `{batch_id, files:[{client_id, upload_file_id, tus_url} | {client_id, rejected: code}]}`; при нехватке места — `uploads.queue_full` для не поместившихся |
| tus | `/uploads/files/{upload_file_id}` | автор | Resumable-загрузка (`HEAD`/`PATCH`) |
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения |
| DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения |
| GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам |
| GET | `/admin/uploads/files/{id}/stream?sig=` | admin | Прослушивание из staging |
| PATCH | `/admin/uploads/files/{id}` | admin | Правка `metadata`, `target_source_id`; ответ содержит пересчитанный `target_rel_path` |
| PATCH | `/admin/uploads/batches/{id}` | admin | Правка общих полей для всех файлов пачки (исполнитель, альбом, год, обложка) |
| POST | `/admin/uploads/review` | admin | `{file_ids:[], decision: "approve"|"reject", reason?}` — массовое решение |
| POST | `/admin/uploads/files/{id}/retry` | admin | Повторить импорт после `import_failed` |
| GET | `/admin/audit?cursor=` | admin |
## Внутренние API
`api → recommender` (`internal-recommender.yaml`):
- `POST /internal/wave/batch` `{user_id, session_id, activity, mood, seed, exclude_ids, feedback, n}` → `{tracks:[{id, source, score}], model_ver}`
- `GET /internal/similar/tracks/{id}`, `GET /internal/similar/artists/{id}`
- `GET /internal/status`
`api → storage-agent` (`internal-storage.yaml`):
- `POST /internal/mounts` `{source_id, type, server, export, options}` → `{status, message}`
- `DELETE /internal/mounts/{source_id}`
- `GET /internal/mounts` — состояние всех точек
- `POST /internal/files` `{source_id, rel_path, staging_path, sha256}` — безопасная запись файла в хранилище
(tmp → fsync → проверка хэша → atomic rename); только для `accepts_uploads` источников
- `GET /internal/files/exists?source_id=&rel_path=` — проверка конфликта пути
Внутренние вызовы защищены общим секретом (`INTERNAL_TOKEN`) и тем, что сервисы не публикуют порты наружу.
+138
View File
@@ -0,0 +1,138 @@
# Архитектура
## 1. Контекст
- До ~100 пользователей, библиотека от 11k треков (рассчитываем на рост до 100k без смены архитектуры).
- Всё разворачивается на одном сервере через Docker Compose.
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
- Клиенты: веб (SPA) и Android-приложение.
## 2. Компоненты
```
┌──────────────┐ ┌────────────────┐
Web SPA ─────┐ │ │ │ │
├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
│ X-Accel) │ │ │──► Meilisearch
└──────┬───────┘ └───┬────────┬───┘
│ read-only │ HTTP │ HTTP (internal)
▼ ▼ ▼
/library/<source> recommender storage-agent
/cache/transcode (Python) (Go, CAP_SYS_ADMIN)
▲ │ │ mount -t nfs
│ ▼ ▼
│ analyzer /library/<source>
└────────── (Python, (mount propagation
Essentia) rshared → остальным)
```
| Сервис | Язык | Ответственность |
|---|---|---|
| `nginx` | — | TLS, раздача SPA, reverse proxy на `api`, отдача аудиофайлов и обложек через `X-Accel-Redirect` (internal locations), gzip/brotli, кэш статики. |
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи (сканер, транскодинг) через очередь River (на Postgres). |
| `storage-agent` | Go | Единственный привилегированный контейнер: монтирует / размонтирует NFS-шары в `/library/<source_id>`, проверяет доступность, **записывает одобренные загрузки** в принимающие хранилища (только создание новых файлов). Принимает команды только от `api` по внутренней сети. |
| `recommender` | Python | Онлайн-сервис рекомендаций: генерация кандидатов, ранжирование, пачки для «Моей волны», обработка обратной связи в реальном времени; ночное обучение моделей (ALS, LightGBM). |
| `analyzer` | Python | Воркер аудиоанализа: Essentia — BPM, энергия, танцевальность, настроение, эмбеддинги. Тот же образ, что `recommender`, другая команда запуска. |
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, очередь задач River. |
| `redis` | — | Rate limiting, кэш, Redis Streams для событий в `recommender`, сессионные векторы волны. |
| `meilisearch` | — | Полнотекстовый поиск с опечатками и автокомплитом. |
## 3. Ключевые потоки
### 3.1 Воспроизведение
1. Клиент получает трек (DTO содержит `stream_url` — подписанную ссылку со сроком жизни).
2. `GET /api/v1/stream/{trackId}?q=high&exp=…&sig=…` → nginx → `api`.
3. `api` проверяет подпись / срок / пользователя, при необходимости готовит транскод в кэше,
отвечает `X-Accel-Redirect: /_internal/library/<source>/<path>` (или `/_internal/cache/...`).
4. nginx сам отдаёт файл с поддержкой `Range` (перемотка), `sendfile`, без участия Go.
Подробно — [playback.md](playback.md).
### 3.2 События и обучение
1. Клиент шлёт события (`play_start`, `progress`, `play_end`, `skip`, `like`, …) пачками в `POST /api/v1/events`.
2. `api` валидирует, пишет в `play_events` (источник истины) и публикует в Redis Stream `events`.
3. `recommender` читает стрим: обновляет сессионный вектор волны и вектор вкуса пользователя онлайн.
4. Ночью `recommender` переобучает ALS и LightGBM по `play_events` и пересчитывает агрегаты.
Подробно — [recommendations.md](recommendations.md).
### 3.3 Сканирование библиотеки
1. Админ добавляет хранилище → `api` → `storage-agent` монтирует NFS.
2. `api` ставит задачу `scan_source` в River → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
применение шаблонов путей, upsert в БД, индексирование в Meilisearch.
3. Для новых / изменённых треков создаётся задача анализа (таблица `analysis_jobs`) → `analyzer`.
Подробно — [library-and-storage.md](library-and-storage.md).
### 3.4 Предложение треков
1. Модератор перетаскивает файлы → клиент считает SHA-256 и читает теги → `POST /uploads/precheck`:
известные дубли отбрасываются без загрузки.
2. `POST /uploads` резервирует квоту очереди → загрузка по tus в staging (`/srv/pipu/uploads`).
3. Задача `process_upload_file`: ffprobe, хэш, Chromaprint → дубль (удаление, квота освобождена) или `pending_review`.
4. Админ правит метаданные и одобряет → `import_upload_file`: целевой путь по `write_template` хранилища →
`storage-agent` записывает файл → `scan_path` → трек в каталоге, поиске и очереди анализа.
Подробно — [uploads.md](uploads.md).
## 4. Структура репозитория
```
pipu-music/
├── apps/
│ ├── web/ # React + Vite SPA
│ └── mobile/ # React Native (Expo) Android
├── packages/ # общий TypeScript-код (pnpm workspaces)
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
│ ├── core/ # бизнес-логика клиентов: очередь плеера, события, сторы
│ ├── i18n/ # словари ru/en и настройка i18next
│ └── design-tokens/ # цвета, типографика, отступы → CSS vars и TS
├── services/
│ ├── api/ # Go
│ │ ├── cmd/api/ # main сервиса
│ │ ├── cmd/storage-agent/ # main агента хранилищ
│ │ ├── internal/
│ │ │ ├── auth/ users/ library/ catalog/ search/
│ │ │ ├── playback/ collection/ events/ wave/ admin/ uploads/
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
│ │ ├── migrations/ # goose SQL-миграции
│ │ └── queries/ # sqlc SQL
│ └── recommender/ # Python (uv)
│ ├── pipu_rec/
│ │ ├── api/ # FastAPI
│ │ ├── analysis/ # Essentia
│ │ ├── candidates/ ranking/ wave/ training/
│ │ └── storage/ # доступ к Postgres / Redis
│ └── tests/
├── api/openapi.yaml # единый контракт публичного API
├── deploy/
│ ├── compose.yaml
│ ├── compose.dev.yaml
│ ├── nginx/
│ └── .env.example
├── tests/
│ ├── e2e/ # Playwright
│ └── fixtures/music/ # генерируемые тестовые аудиофайлы
├── .gitea/workflows/ # ci.yml, release.yml
├── docs/
├── turbo.json pnpm-workspace.yaml package.json
└── README.md
```
## 5. Архитектурный стиль
- **Модульный монолит** в `api`: каждый модуль в `internal/<module>` — `handler` (HTTP) → `service`
(бизнес-логика, интерфейсы) → `repository` (sqlc). Модули общаются через интерфейсы сервисов,
не лезут в чужие таблицы напрямую.
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
CI падает, если сгенерированный код не совпадает с контрактом.
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
описан отдельными `internal-*.yaml` спеками.
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
что важно для офлайн-синхронизации мобильного.
- **Деградация**: если `recommender` недоступен, `api` отдаёт волну по упрощённому fallback-алгоритму
(похожие по жанрам + лайки + случайное), приложение продолжает работать.
- **Наблюдаемость**: структурированные JSON-логи (`slog`, `structlog`), `/healthz` и `/readyz` у каждого сервиса,
метрики Prometheus `/metrics` (опционально подключаются Grafana / Prometheus).
+88
View File
@@ -0,0 +1,88 @@
# Аутентификация, роли, первый запуск
## Роли
| Роль | Назначение |
|---|---|
| `admin` | Владелец системы. Может быть несколько, но минимум один всегда (последнего админа нельзя понизить / удалить / заблокировать). |
| `moderator` | Заместитель администратора: управление обычными пользователями. |
| `user` | Слушатель. |
## Матрица прав
| Действие | user | moderator | admin |
|---|:-:|:-:|:-:|
| Слушать, поиск, коллекция, волна | ✅ | ✅ | ✅ |
| Сменить свой пароль, язык, сессии | ✅ | ✅ | ✅ |
| Открыть админку | — | ✅ | ✅ |
| Видеть список пользователей | — | ✅ | ✅ |
| Создать пользователя с ролью `user` | — | ✅ | ✅ |
| Сбросить пароль / заблокировать `user` | — | ✅ | ✅ |
| Создать `moderator` / `admin`, менять роли | — | — | ✅ |
| Сбросить пароль / заблокировать `moderator` | — | — | ✅ |
| Удалить пользователя | — | — | ✅ |
| Хранилища, сканирование, переиндексация | — | — | ✅ |
| Статус библиотеки (только просмотр) | — | ✅ | ✅ |
| Предлагать треки (загрузка в очередь), видеть свои предложения | — | ✅ | ✅ |
| Рассматривать очередь, одобрять / отклонять, править метаданные | — | — | ✅ |
| Лимит очереди, целевое хранилище, шаблон раскладки | — | — | ✅ |
| Статус рекомендательной системы, аудит | — | — | ✅ |
Реализация: в Go — политики в `internal/auth/policy.go` вида `CanManageUser(actor, target, action) bool`,
проверяются в сервисном слое (не только в middleware), покрываются табличными тестами по этой матрице.
Все админские действия пишутся в `audit_log`.
## Первый запуск (bootstrap)
1. При старте `api` проверяет `app_settings['bootstrap.completed']`. Если нет и в `users` нет ни одного админа —
создаёт пользователя:
- `username = BOOTSTRAP_ADMIN_USERNAME` (по умолчанию `admin`);
- пароль = `BOOTSTRAP_ADMIN_PASSWORD` из env, а если не задан — генерируется случайный (20 символов)
и **печатается в лог один раз**;
- `must_change_credentials = true`.
2. `GET /server-info` возвращает `bootstrap_required: true` — клиент может показать подсказку на экране входа.
3. После входа с `must_change_credentials = true` любые эндпоинты, кроме `/me`, `/me/bootstrap-credentials`,
`/auth/logout`, отвечают `403 { code: "auth.credentials_change_required" }`. Клиент показывает
экран «Смените логин и пароль администратора».
4. `POST /me/bootstrap-credentials` — новый логин (может остаться `admin`, но пароль обязан отличаться),
новый пароль (≥ 12 символов для админа) → флаг снимается, `bootstrap.completed = true`,
все прочие токены отзываются, выдаётся новая пара.
Аналогично для `must_change_password` (после сброса пароля админом / модератором): разрешён только
`POST /me/password` (без `current_password`, если вход был по временному паролю).
## Пароли
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
- Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по
встроенному списку 10k самых частых паролей.
- Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**;
у пользователя ставится `must_change_password = true`.
- Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч),
плюс rate limit по IP в Redis. Ответ всегда одинаковый (`auth.invalid_credentials`) — не раскрываем,
существует ли логин. Время ответа выравнивается (хэш с фиктивным паролем для несуществующих).
## Токены
| | Access | Refresh |
|---|---|---|
| Формат | JWT, EdDSA (Ed25519) | 32 случайных байта, base64url; в БД — SHA-256 |
| Срок | 15 мин | 30 дней скользящий (каждая ротация продлевает), абсолютный максимум 180 дней |
| Claims | `sub`, `role`, `sid` (family_id), `ver` (token_version пользователя), `exp`, `iat` | — |
| Веб | в памяти (JS) | cookie `pipu_rt`: `HttpOnly; Secure; SameSite=Strict; Path=/api/v1/auth` |
| Android | в памяти | `expo-secure-store` |
- **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`.
Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи.
- **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version`
(в Redis-кэше), middleware сравнивает с `ver` в access-токене.
- Ключи подписи: `JWT_PRIVATE_KEY` из env / файла; поддержка `kid` для ротации ключей.
- CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок,
поэтому CSRF к ним неприменим.
## Клиентская логика
- Один общий модуль в `packages/core/auth`: хранит access в памяти, при `401 auth.token_expired`
делает **единственный** параллельный refresh (single-flight) и повторяет запросы.
- Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`;
админские разделы — по `role`.
+78
View File
@@ -0,0 +1,78 @@
# CI/CD (Gitea Actions)
Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner`
с доступом к Docker.
## Версионирование
- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
предлагает только при включённой настройке «бета-версии»).
- Одна версия на весь продукт: образы, APK и веб собираются с одним номером.
- Android `versionCode = MAJOR·10000 + MINOR·100 + PATCH` (для rc: `… ·100 + rc` с отдельной схемой, см. скрипт
`scripts/version.ts`).
- Changelog — `git-cliff` из Conventional Commits.
## `ci.yml` — на каждый push и PR
```yaml
on:
push: { branches: [main] }
pull_request:
jobs:
changes: # фильтр путей: api / recommender / web / mobile / packages / docs
contract: # spectral lint openapi.yaml; регенерация Go + TS клиентов; git diff --exit-code
api: # go vet, golangci-lint, go test -race ./... (testcontainers), покрытие
recommender: # uv sync; ruff; mypy --strict; pytest (вкл. оффлайн-оценку качества)
ts: # pnpm install --frozen-lockfile; turbo lint typecheck test; i18n:check
web-build: # vite build, проверка размера бандла
mobile-check: # tsc, jest, expo prebuild + ./gradlew assembleDebug (только если менялся mobile/)
e2e: # needs: api, web-build; docker compose -f deploy/compose.test.yaml up; playwright test
```
- Кэши: Go modules / build cache, pnpm store, uv cache, Gradle, сгенерированные тестовые аудио.
- Ветка `main` защищена: обязательны `contract`, `api`, `recommender`, `ts`, `e2e`.
- Ночью (`schedule`) — Maestro E2E на эмуляторе и полный прогон без фильтров путей.
## `release.yml` — по тегу
```yaml
on:
push:
tags: ['v*.*.*']
jobs:
verify: # все проверки ci.yml (reusable workflow)
images: # needs: verify
strategy: { matrix: { service: [api, storage-agent, recommender, web] } }
# docker buildx build --push
# ${REGISTRY}/${OWNER}/pipu-music-${service}:${VERSION} и :latest (кроме pre-release)
# web-образ = nginx + собранная SPA + конфиг
android: # needs: verify
# setup-java 17, Android SDK, pnpm install
# VERSION / VERSION_CODE из тега → app.config.ts
# expo prebuild --platform android --clean
# ./gradlew assembleRelease (signingConfig из секретов)
# apksigner verify; sha256sum > pipu-music-${VERSION}.apk.sha256
release: # needs: [images, android]
# git-cliff --latest > CHANGELOG_RELEASE.md
# создать Gitea Release через API (POST /repos/{owner}/{repo}/releases), prerelease по суффиксу
# загрузить ассеты: APK, .sha256, deploy/compose.yaml, deploy/.env.example
```
## Секреты
| Секрет | Назначение |
|---|---|
| `REGISTRY_USER`, `REGISTRY_TOKEN` | Push в Gitea Container Registry |
| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов |
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. |
## Деплой
Автодеплой не обязателен. Рекомендуемый вариант: на сервере `deploy/update.sh <version>` —
`docker compose pull && docker compose up -d` с `PIPU_VERSION=<version>`; миграции применяются при старте `api`.
Опционально — job `deploy` в `release.yml` по SSH на сервер для не-pre-release тегов (с ручным approval через environment).
+42
View File
@@ -0,0 +1,42 @@
# Соглашения
## Git
- Trunk-based: короткие ветки от `main`, merge через PR (squash).
- Имена веток: `feat/wave-moods`, `fix/scan-rename`, `chore/ci-cache`.
- Conventional Commits: `feat(wave): add running profile`, `fix(api): ...`. Скоупы: `api`, `agent`, `rec`, `web`,
`mobile`, `core`, `i18n`, `ci`, `deploy`, `docs`. Breaking change — `!` и раздел `BREAKING CHANGE:`.
- Изменение API = изменение `openapi.yaml` + регенерация клиентов в том же PR.
- Существенные архитектурные решения — новый ADR в [decisions.md](decisions.md).
## Go (`services/api`)
- `gofumpt`, `golangci-lint` (конфиг в репо). Ошибки оборачиваются `fmt.Errorf("...: %w", err)`;
доменные ошибки — типизированные (`ErrForbidden`, `ErrNotFound`) и маппятся в problem+json в одном месте.
- Контекст первым аргументом, без глобального состояния, зависимости через конструкторы (`NewService(repo, clock, ...)`).
- `clock` и генератор ID — интерфейсы (детерминированные тесты).
- SQL — только в `queries/*.sql` (sqlc), без ORM.
## Python (`services/recommender`)
- `uv`, `ruff` (lint + format), `mypy --strict`, Pydantic v2 для схем.
- Тяжёлые вычисления — `numpy`, без циклов Python по трекам.
- Конфиги контекстов и весов — YAML, валидируются Pydantic при старте.
## TypeScript
- `strict`, ESLint (flat config) + Prettier, без `any` (кроме сгенерированного кода).
- Общая логика — в `packages/core`, платформенная — только адаптеры в `apps/*`.
- Все строки UI — через i18n, хардкод текста ловит `eslint-plugin-i18next`.
## API
- `snake_case` в JSON, курсорная пагинация, problem+json с `code`.
- Новые поля — только дополнительные; удаление / переименование — через новую версию эндпоинта или депрекацию на один минорный релиз.
## Безопасность
- Секреты только через env / docker secrets, никогда в репозитории.
- Любой ввод, попадающий в команды (`mount`, `ffmpeg`), — только как отдельные аргументы `exec.Command`, без shell,
с whitelist-валидацией.
- Пути файлов — проверка, что итоговый путь внутри корня источника (`filepath.Rel` без `..`).
+411
View File
@@ -0,0 +1,411 @@
# Модель данных (PostgreSQL 17)
Соглашения:
- первичные ключи — `uuid` (UUIDv7, генерируются в приложении — сортируемы по времени);
- время — `timestamptz`, UTC;
- мягкое удаление только там, где явно указано (`deleted_at`);
- миграции — `services/api/migrations`, goose, только вперёд (down-миграции для dev).
- Расширения: `pgvector`, `pg_trgm`, `citext`.
## Пользователи и доступ
```sql
CREATE TYPE user_role AS ENUM ('admin', 'moderator', 'user');
CREATE TABLE users (
id uuid PRIMARY KEY,
username citext NOT NULL UNIQUE, -- 3..32, [a-z0-9._-]
display_name text,
password_hash text NOT NULL, -- argon2id PHC string
role user_role NOT NULL DEFAULT 'user',
locale text NOT NULL DEFAULT 'ru', -- BCP 47: ru, en, ...
ui_prefs jsonb NOT NULL DEFAULT '{}', -- {theme: dark|light|system, wave_variant: orb|liquid,
-- web_player: bar|island, mobile_player: cover|wave,
-- ambient: bool, crossfade_s: 0..6, normalize: bool}
must_change_password boolean NOT NULL DEFAULT false,
must_change_credentials boolean NOT NULL DEFAULT false, -- только для bootstrap-админа: логин + пароль
is_blocked boolean NOT NULL DEFAULT false,
token_version int NOT NULL DEFAULT 0, -- инкремент = мгновенный отзыв access-токенов
failed_login_count int NOT NULL DEFAULT 0,
locked_until timestamptz,
created_by uuid REFERENCES users(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
last_login_at timestamptz
);
CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family_id uuid NOT NULL, -- цепочка ротаций одного входа
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
device_name text, -- "Chrome / Windows", "Pixel 8"
client_type text NOT NULL, -- 'web' | 'android'
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used_at timestamptz, -- ротирован
revoked_at timestamptz
);
CREATE INDEX ON refresh_tokens (user_id) WHERE revoked_at IS NULL;
CREATE TABLE audit_log (
id uuid PRIMARY KEY,
actor_id uuid REFERENCES users(id),
action text NOT NULL, -- 'user.create', 'user.role_change', 'source.create', ...
target_type text,
target_id uuid,
payload jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
```
## Хранилища и файлы
```sql
CREATE TYPE source_type AS ENUM ('nfs', 'local');
CREATE TYPE source_status AS ENUM ('pending', 'mounted', 'error', 'disabled');
CREATE TYPE tag_priority AS ENUM ('tags_first', 'path_first');
CREATE TABLE library_sources (
id uuid PRIMARY KEY,
name text NOT NULL,
type source_type NOT NULL,
nfs_server text, -- host / IP (для type=nfs)
nfs_export text, -- /export/music
nfs_options text NOT NULL DEFAULT 'ro,nfsvers=4.1,soft,timeo=150,retrans=3',
local_path text, -- для type=local (внутри /library-host)
tag_priority tag_priority NOT NULL DEFAULT 'tags_first',
accepts_uploads boolean NOT NULL DEFAULT false, -- монтируется на запись, принимает одобренные загрузки
write_template text, -- шаблон раскладки: '{albumartist}/{year} - {album}/{track:02} - {title}.{ext}'
scan_interval interval, -- NULL = только вручную
status source_status NOT NULL DEFAULT 'pending',
status_message text,
last_scan_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE path_patterns ( -- упорядоченный список регэкспов источника
id uuid PRIMARY KEY,
source_id uuid NOT NULL REFERENCES library_sources(id) ON DELETE CASCADE,
position int NOT NULL, -- порядок применения, первый совпавший побеждает
regex text NOT NULL, -- RE2 с именованными группами
is_enabled boolean NOT NULL DEFAULT true,
UNIQUE (source_id, position)
);
CREATE TABLE media_files (
id uuid PRIMARY KEY,
source_id uuid NOT NULL REFERENCES library_sources(id) ON DELETE CASCADE,
rel_path text NOT NULL, -- путь относительно корня источника
size_bytes bigint NOT NULL,
mtime timestamptz NOT NULL,
content_hash bytea, -- xxhash первых/последних 1 МБ + размер: быстро и устойчиво к переименованию
sha256 bytea, -- полный хэш файла (для проверки дублей при загрузке; считается фоном)
upload_file_id uuid, -- если файл попал через очередь на добавление
format text NOT NULL, -- flac, mp3, m4a, ogg, opus, wav
codec text, bitrate int, sample_rate int, bit_depth int, channels int,
duration_ms int NOT NULL,
track_id uuid, -- FK ставится после создания tracks
missing_since timestamptz, -- файл пропал при скане
scan_error text,
UNIQUE (source_id, rel_path)
);
CREATE INDEX ON media_files (content_hash);
CREATE INDEX ON media_files (sha256);
CREATE TABLE track_fingerprints ( -- Chromaprint, для поиска дублей при загрузке
file_id uuid PRIMARY KEY REFERENCES media_files(id) ON DELETE CASCADE,
track_id uuid,
duration_ms int NOT NULL,
fingerprint int[] NOT NULL, -- raw-отпечаток fpcalc
fp_prefix bigint NOT NULL -- первые 64 бита — грубый индекс для отбора кандидатов
);
CREATE INDEX ON track_fingerprints (fp_prefix);
CREATE INDEX ON track_fingerprints (duration_ms);
```
## Очередь на добавление (загрузки)
```sql
CREATE TYPE upload_status AS ENUM (
'uploading', 'processing', 'pending_review', 'approved', 'importing', 'imported',
'import_failed', 'duplicate', 'invalid', 'rejected', 'withdrawn', 'expired');
CREATE TABLE upload_batches ( -- одна «пачка» загрузки (обычно альбом / папка)
id uuid PRIMARY KEY,
author_id uuid NOT NULL REFERENCES users(id),
comment text, -- комментарий автора для админа
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE upload_files (
id uuid PRIMARY KEY,
batch_id uuid NOT NULL REFERENCES upload_batches(id) ON DELETE CASCADE,
author_id uuid NOT NULL REFERENCES users(id),
status upload_status NOT NULL,
original_name text NOT NULL,
client_rel_path text, -- относительный путь из перетащенной папки
size_bytes bigint NOT NULL, -- заявленный, затем фактический
sha256 bytea,
staging_path text,
format text, codec text, bitrate int, sample_rate int, bit_depth int,
duration_ms int,
tags jsonb, -- исходные теги файла
metadata jsonb, -- итоговые поля {artist, albumartist, album, year, disc, track, title, genre}
metadata_edited boolean NOT NULL DEFAULT false,
matched_artist_id uuid REFERENCES artists(id),
matched_album_id uuid REFERENCES albums(id),
cover_upload_id uuid, -- отдельная картинка обложки из той же пачки
duplicate_of_track_id uuid REFERENCES tracks(id),
duplicate_level text, -- sha256 | metadata | fingerprint
possible_duplicate boolean NOT NULL DEFAULT false,
target_source_id uuid REFERENCES library_sources(id),
target_rel_path text,
status_reason text, -- код причины: uploads.duplicate, uploads.invalid_audio, текст отклонения
reviewed_by uuid REFERENCES users(id),
reviewed_at timestamptz,
imported_file_id uuid REFERENCES media_files(id),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON upload_files (status);
CREATE INDEX ON upload_files (author_id, created_at DESC);
CREATE INDEX ON upload_files (sha256);
-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса;
-- ночная задача сверяет его с SUM(size_bytes) по «занимающим» статусам.
CREATE TABLE upload_quota (
id boolean PRIMARY KEY DEFAULT true CHECK (id),
used_bytes bigint NOT NULL DEFAULT 0
);
```
## Каталог
```sql
CREATE TABLE artists (
id uuid PRIMARY KEY,
name text NOT NULL,
sort_name text NOT NULL,
name_norm text NOT NULL UNIQUE, -- нормализованное имя: lower, ё→е, без пунктуации
image_path text, -- кэш изображения
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE albums (
id uuid PRIMARY KEY,
title text NOT NULL,
title_norm text NOT NULL,
album_artist_id uuid REFERENCES artists(id),
year int,
cover_path text, -- путь в кэше обложек (/cache/covers/<hash>.webp)
cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона,
-- цветов кнопки волны и плейсхолдера обложки
UNIQUE (album_artist_id, title_norm)
);
CREATE TABLE tracks (
id uuid PRIMARY KEY,
title text NOT NULL,
album_id uuid REFERENCES albums(id),
disc_no int, track_no int,
year int,
duration_ms int NOT NULL,
primary_file_id uuid REFERENCES media_files(id), -- лучший по качеству файл, если дубли
explicit boolean,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE media_files ADD FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL;
CREATE TABLE track_artists (
track_id uuid REFERENCES tracks(id) ON DELETE CASCADE,
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
role text NOT NULL DEFAULT 'main', -- main | feat
position int NOT NULL,
PRIMARY KEY (track_id, artist_id, role)
);
CREATE TABLE genres (
id serial PRIMARY KEY,
name text NOT NULL,
name_norm text NOT NULL UNIQUE,
parent_id int REFERENCES genres(id) -- иерархия: "metal" → "death metal"
);
CREATE TABLE track_genres (
track_id uuid REFERENCES tracks(id) ON DELETE CASCADE,
genre_id int REFERENCES genres(id),
source text NOT NULL, -- 'tag' | 'analysis'
weight real NOT NULL DEFAULT 1, -- для analysis — вероятность модели
PRIMARY KEY (track_id, genre_id, source)
);
```
## Аудиопризнаки (заполняет analyzer)
```sql
CREATE TABLE track_features (
track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
analyzer_ver text NOT NULL, -- версия пайплайна, для пересчёта
bpm real, bpm_confidence real,
key text, scale text, -- 'A', 'minor'
loudness_lufs real,
energy real, -- 0..1
danceability real, -- 0..1
valence real, -- 0..1 (грустно → весело)
arousal real, -- 0..1 (спокойно → возбуждённо)
acousticness real, instrumentalness real,
mood_happy real, mood_sad real, mood_relaxed real, mood_aggressive real, mood_party real,
intro_ms int, -- для мягкого старта / кроссфейда
analyzed_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE track_embeddings (
track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
audio vector(256), -- аудио-эмбеддинг (Discogs-EffNet → PCA 256)
cf vector(64), -- item-фактор ALS (обновляется ночью)
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON track_embeddings USING hnsw (audio vector_cosine_ops);
CREATE INDEX ON track_embeddings USING hnsw (cf vector_cosine_ops);
CREATE TABLE analysis_jobs (
track_id uuid PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
status text NOT NULL DEFAULT 'queued', -- queued | running | done | failed
attempts int NOT NULL DEFAULT 0,
error text,
locked_at timestamptz,
updated_at timestamptz NOT NULL DEFAULT now()
);
```
## Пользовательские данные
```sql
CREATE TYPE reaction AS ENUM ('like', 'dislike');
CREATE TABLE track_reactions ( -- Коллекция → Лайки / Дизлайки
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
track_id uuid REFERENCES tracks(id) ON DELETE CASCADE,
reaction reaction NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, track_id)
);
CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC);
CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п.
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
reaction reaction NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, artist_id)
);
-- Сырые события — источник истины для истории и обучения. Партиционирование по месяцам.
CREATE TABLE play_events (
id uuid NOT NULL, -- event_id от клиента (идемпотентность)
user_id uuid NOT NULL,
track_id uuid NOT NULL,
type text NOT NULL, -- play_start | play_end | skip | seek | like | unlike | dislike | undislike
occurred_at timestamptz NOT NULL, -- время на клиенте (важно для офлайна)
received_at timestamptz NOT NULL DEFAULT now(),
position_ms int, -- где произошло
played_ms int, -- реально прослушано в этой попытке (для play_end/skip)
context_type text, -- wave | album | artist | search | collection | history | queue
context_id text, -- id альбома / wave_session и т.п.
wave_session_id uuid,
client_type text NOT NULL, -- web | android
offline boolean NOT NULL DEFAULT false,
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
CREATE INDEX ON play_events (user_id, occurred_at DESC);
-- Агрегаты для быстрого доступа (обновляются триггерно в api и пересчитываются ночью)
CREATE TABLE user_track_stats (
user_id uuid, track_id uuid,
play_count int NOT NULL DEFAULT 0, -- засчитанные прослушивания (≥ 50% или ≥ 4 мин)
complete_count int NOT NULL DEFAULT 0,
skip_count int NOT NULL DEFAULT 0,
early_skip_count int NOT NULL DEFAULT 0, -- скип < 30 с
total_played_ms bigint NOT NULL DEFAULT 0,
last_played_at timestamptz,
implicit_score real NOT NULL DEFAULT 0, -- см. recommendations.md
PRIMARY KEY (user_id, track_id)
);
CREATE TABLE playback_state ( -- «запомнить трек и момент»
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
track_id uuid REFERENCES tracks(id) ON DELETE SET NULL,
position_ms int NOT NULL DEFAULT 0,
queue jsonb NOT NULL DEFAULT '[]', -- [{track_id, source}] до 500 элементов
queue_index int NOT NULL DEFAULT 0,
context jsonb, -- {type:'wave', wave_session_id, activity, mood}
shuffle boolean NOT NULL DEFAULT false,
repeat_mode text NOT NULL DEFAULT 'off', -- off | one | all
volume real NOT NULL DEFAULT 1,
device_id text, -- кто записал последним
updated_at timestamptz NOT NULL DEFAULT now()
);
```
## Рекомендации
```sql
CREATE TABLE wave_sessions (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity text, -- running | waking_up | focus | workout | commute | chill | sleep
mood text, -- energetic | happy | calm | sad
seed_type text, -- null | artist | track | album
seed_id uuid,
started_at timestamptz NOT NULL DEFAULT now(),
ended_at timestamptz,
tracks_served int NOT NULL DEFAULT 0
);
CREATE TABLE wave_impressions ( -- что выдали и откуда (для обучения ранжировщика и бандита)
wave_session_id uuid REFERENCES wave_sessions(id) ON DELETE CASCADE,
position int NOT NULL,
track_id uuid NOT NULL,
candidate_source text NOT NULL, -- audio_sim | cf | artist | genre | fresh | rediscover | popular
score real,
model_ver text,
served_at timestamptz NOT NULL DEFAULT now(),
outcome text, -- complete | skip_early | skip | like | dislike (заполняется по событиям)
PRIMARY KEY (wave_session_id, position)
);
CREATE TABLE user_profiles_rec ( -- векторы вкуса и параметры бандита
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
taste_audio vector(256), -- взвешенное среднее эмбеддингов положительных треков
taste_cf vector(64), -- user-фактор ALS
genre_prefs jsonb NOT NULL DEFAULT '{}',-- {genre_id: weight}
bandit jsonb NOT NULL DEFAULT '{}',-- {source: {alpha, beta}}
exploration real NOT NULL DEFAULT 0.15,
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE model_versions (
id text PRIMARY KEY, -- 'ranker-2026-10-19T03:00'
kind text NOT NULL, -- als | ranker
metrics jsonb, -- ndcg@10, recall@50, ...
is_active boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
```
## Прочее
```sql
CREATE TABLE app_settings (key text PRIMARY KEY, value jsonb NOT NULL);
-- 'bootstrap.completed', 'transcode.cache_limit_gb', 'update.gitea',
-- 'uploads.queue_limit_bytes' (10 ГБ), 'uploads.per_user_limit_bytes' (null), 'uploads.max_file_bytes' (2 ГБ),
-- 'uploads.default_source_id', 'uploads.allow_quality_upgrade' (false), ...
```
## Очистка и хранение
- `play_events` — хранить бессрочно (объём маленький: 100 польз. × ~300 событий/день ≈ 11 млн/год, ~2 ГБ),
партиции старше 2 лет можно архивировать.
- `wave_impressions` — 180 дней.
- `refresh_tokens` — удалять истёкшие через 7 дней после `expires_at`.
+157
View File
@@ -0,0 +1,157 @@
# Архитектурные решения (ADR)
Формат: контекст → решение → последствия. Новое решение — новый раздел с номером; старые не переписываются,
а помечаются `Заменено ADR-NNN`.
---
## ADR-001. Монорепозиторий
**Контекст.** Веб, мобильное, бэкенд, тесты и CI должны жить в одном репозитории; у веба и мобильного много общего
(API-клиент, логика очереди и событий, словари i18n).
**Решение.** Монорепо: pnpm workspaces + Turborepo для TS-части; Go-модуль в `services/api`; Python-проект
(uv) в `services/recommender`. Единый `api/openapi.yaml`.
**Последствия.** Атомарные изменения контракта и клиентов в одном PR. CI использует фильтры путей,
чтобы не гонять всё на каждый коммит.
---
## ADR-002. Backend API на Go
**Контекст.** Нагрузка — IO (стриминг, NFS, БД), десятки одновременных слушателей; важна простота деплоя и
низкое потребление памяти.
**Решение.** Go 1.23+: роутер `chi`, `oapi-codegen` (strict server), `pgx` + `sqlc`, миграции `goose`,
фоновые задачи `River` (очередь в Postgres), логирование `log/slog`, конфиг через env (`caarlos0/env`).
**Последствия.** Один статический бинарник на сервис, быстрые холодные старты, тайпсейф SQL.
ML-код в Go не пишем — для этого отдельный сервис (ADR-003).
---
## ADR-003. Рекомендации и аудиоанализ — отдельный Python-сервис
**Контекст.** Нужны: аудиоанализ (Essentia и её предобученные модели), коллаборативная фильтрация (implicit ALS),
learning-to-rank (LightGBM). Экосистема есть только в Python.
**Решение.** `services/recommender`: FastAPI (онлайн-инференс), отдельный процесс `analyzer` (тот же образ),
обучение по расписанию (APScheduler внутри сервиса). Python 3.12, `uv`, `numpy`, `implicit`, `lightgbm`,
`essentia-tensorflow`, `psycopg`, `redis`.
**Последствия.** Два языка на бэкенде. Граница чёткая: Python не обслуживает пользователей напрямую,
только внутренние вызовы `api`. При падении сервиса работает fallback в `api`.
⚠️ Предобученные модели Essentia распространяются под CC BY-NC-SA 4.0 — допустимо для частного
некоммерческого self-hosted использования. При коммерческом использовании нужно заменить модели.
---
## ADR-004. Мобильное приложение — React Native (Expo), только Android
**Контекст.** Только Android, самообновление APK из релизов, фоновое воспроизведение, офлайн, анимированная кнопка волны.
Рассматривались Flutter и React Native.
**Решение.** React Native + Expo SDK (prebuild / dev client, не Expo Go), `expo-router`,
`react-native-track-player` (ExoPlayer под капотом, MediaSession, уведомление, кэш), `@shopify/react-native-skia`
+ `react-native-reanimated` для волны, `expo-sqlite` для офлайн-данных, `expo-file-system` для загрузок.
**Почему не Flutter.** Flutter не хуже по возможностям, но React Native даёт общий TypeScript-код с вебом:
API-клиент, типы, логику очереди / событий / офлайн-синхронизации, словари i18n, дизайн-токены.
Для команды из одного-двух человек это существенно сокращает объём работы.
**Последствия.** iOS не поддерживается (самообновление в обход стора там невозможно); при необходимости
iOS добавляется позже через TestFlight без смены стека.
---
## ADR-005. Раздача аудио через nginx X-Accel-Redirect
**Контекст.** Нужна перемотка (HTTP Range), высокая пропускная способность, минимум нагрузки на бэкенд.
**Решение.** Клиент обращается к `api`, `api` проверяет права и подпись ссылки и отвечает заголовком
`X-Accel-Redirect` на `internal` location nginx. Файл отдаёт nginx.
**Последствия.** Go не держит долгих соединений со стримами. Тома библиотеки и кэша транскодов
должны быть смонтированы и в `api`, и в `nginx` по одинаковым путям.
---
## ADR-006. Подписанные ссылки на стрим
**Контекст.** `<audio>` в браузере и ExoPlayer не всегда умеют передавать заголовок `Authorization`.
**Решение.** В DTO трека отдаётся `stream_url` с параметрами `exp` и `sig` = HMAC-SHA256(user_id, track_id, quality, exp).
Срок жизни — 12 ч. Для офлайн-загрузки — отдельная ссылка со сроком 1 ч.
**Последствия.** Ссылки нельзя использовать после блокировки пользователя дольше срока жизни — при блокировке
дополнительно проверяется статус пользователя в `api` (кэш в Redis, 60 с).
---
## ADR-007. Поиск — Meilisearch
**Контекст.** Мгновенный автокомплит по трём типам сущностей, опечатки, русский и английский, 11k–100k документов.
**Решение.** Meilisearch, три индекса (`artists`, `albums`, `tracks`), `multi-search` для выпадающего списка.
Postgres остаётся источником истины, индексы перестраиваемы.
**Альтернатива.** `pg_trgm` в Postgres — меньше компонентов, но хуже автокомплит и ранжирование. Оставлено как fallback.
---
## ADR-008. Подключение NFS из админки через storage-agent
**Контекст.** Админ должен подключать хранилища из UI. Монтирование требует `CAP_SYS_ADMIN`,
давать его публичному `api` небезопасно.
**Решение.** Отдельный минимальный сервис `storage-agent` с `CAP_SYS_ADMIN`, без публичных портов.
Монтирует в `/library/<source_id>` внутри общего bind-тома с `propagation: rshared`;
`api`, `nginx`, `analyzer` видят точки монтирования через `rslave`. Поддерживаются типы
`nfs` (монтирует агент) и `local` (папка, уже смонтированная на хосте).
**Последствия.** Требуется Linux-хост (mount propagation) и `nfs-common` в образе агента.
При рестарте агент перемонтирует все активные источники из БД. Дополнено ADR-012 (запись).
---
## ADR-009. Хранение векторов — pgvector
**Контекст.** Эмбеддинги треков (~11k–100k × 256 float) и векторы вкусов (~100). Нужен kNN.
**Решение.** pgvector, HNSW-индекс, косинусное расстояние. Отдельная векторная БД не нужна при таком объёме.
---
## ADR-010. Аутентификация — свои JWT + ротируемые refresh-токены
**Решение.** Пароли — argon2id. Access JWT (EdDSA, 15 мин), refresh — непрозрачный токен (хэш в БД), 30 дней,
ротация при каждом использовании с детекцией повторного использования (отзыв всей «семьи»).
Веб хранит refresh в `HttpOnly; Secure; SameSite=Strict` cookie, мобильное — в `expo-secure-store` (Android Keystore).
Подробно — [auth-and-roles.md](auth-and-roles.md).
---
## ADR-011. Ошибки API — RFC 9457 + коды для i18n
**Решение.** Ответы об ошибках — `application/problem+json` с машинным полем `code` (`auth.invalid_credentials`,
`validation.password_too_short`, …). Клиенты переводят `code` через словари; сервер не возвращает локализованных текстов.
---
## ADR-012. Очередь на добавление треков и запись в хранилище
**Контекст.** Модераторы предлагают новые треки; очередь ограничена по размеру (настраивает админ); дубли отсекаются;
одобренные файлы должны лечь на файловый сервер в принятой там структуре папок. До этого библиотека была строго read-only.
**Решение.**
- Загрузка по протоколу **tus** (resumable) в локальный staging, квота — счётчик в Postgres, резервируется до загрузки.
- Дубли — три уровня: SHA-256 (предпроверка на клиенте), нормализованные метаданные, акустический отпечаток **Chromaprint**.
- Запись в хранилище — **только `storage-agent`**, только в источники с `accepts_uploads`, через отдельную `rw`-точку
монтирования, невидимую другим контейнерам; только создание новых файлов (tmp → fsync → hash → rename).
- Путь — по `write_template` хранилища; шаблон автоподбирается по существующей структуре и проверяется на обратимость
регэкспами хранилища.
**Последствия.** Публичный `api` по-прежнему не может изменить библиотеку. Нужны права на запись в NFS-экспорт
для UID агента. Подробно — [uploads.md](uploads.md).
+144
View File
@@ -0,0 +1,144 @@
# Развёртывание
## Требования к серверу
- Linux x86_64 (нужен mount propagation для storage-agent), Docker 24+, Docker Compose v2.
- 4+ ядра, 8+ ГБ RAM (аудиоанализ при первом запуске ест CPU; TensorFlow-модели ~1–2 ГБ RAM).
- Диск: ~5 ГБ под систему и БД + кэш транскодов (по умолчанию 50 ГБ) + обложки (~1 ГБ на 10k альбомов).
- Сетевой доступ к NFS-серверам.
## Каталоги на хосте
```
/srv/pipu/
├── library/ # точки монтирования источников (storage-agent, rshared)
├── library-host/ # опционально: заранее смонтированные папки для type=local
├── cache/
│ ├── transcode/
│ └── covers/
├── postgres/
├── meili/
├── redis/
├── models/ # модели Essentia (скачиваются при первом старте analyzer)
├── updates/ # кэш APK релизов
├── uploads/ # staging очереди на добавление (размер ≈ лимит очереди, по умолчанию 10 ГБ)
└── backups/
```
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
## `deploy/compose.yaml` (сокращённо)
```yaml
x-library-ro: &library-ro
type: bind
source: /srv/pipu/library
target: /library
read_only: true
bind: { propagation: rslave }
services:
nginx:
image: ${REGISTRY}/pipu-music-web:${PIPU_VERSION}
ports: ["443:443", "80:80"]
volumes:
- *library-ro
- /srv/pipu/cache:/cache:ro
- /srv/pipu/uploads:/uploads:ro # прослушивание очереди админом (internal location)
- ./certs:/etc/nginx/certs:ro
depends_on: [api]
api:
image: ${REGISTRY}/pipu-music-api:${PIPU_VERSION}
env_file: .env
volumes:
- *library-ro
- /srv/pipu/cache:/cache
- /srv/pipu/updates:/updates
- /srv/pipu/uploads:/uploads
depends_on: [postgres, redis, meilisearch, storage-agent]
storage-agent:
image: ${REGISTRY}/pipu-music-storage-agent:${PIPU_VERSION}
cap_add: [SYS_ADMIN]
security_opt: ["apparmor:unconfined"]
volumes:
- type: bind
source: /srv/pipu/library
target: /library
bind: { propagation: rshared }
- /srv/pipu/uploads:/uploads:ro # читает staging при импорте
# /library-rw/<id> монтируется агентом внутри своего контейнера, наружу не пробрасывается
environment: [INTERNAL_TOKEN, DATABASE_URL, PIPU_FILE_UID, PIPU_FILE_GID]
recommender:
image: ${REGISTRY}/pipu-music-recommender:${PIPU_VERSION}
command: ["serve"]
env_file: .env
depends_on: [postgres, redis]
analyzer:
image: ${REGISTRY}/pipu-music-recommender:${PIPU_VERSION}
command: ["analyze", "--workers", "4"]
env_file: .env
volumes: [*library-ro, /srv/pipu/models:/models]
deploy: { resources: { limits: { cpus: "4" } } }
postgres:
image: pgvector/pgvector:pg17
volumes: [/srv/pipu/postgres:/var/lib/postgresql/data]
redis:
image: redis:7-alpine
command: ["redis-server", "--appendonly", "yes"]
volumes: [/srv/pipu/redis:/data]
meilisearch:
image: getmeili/meilisearch:v1.11
environment: [MEILI_MASTER_KEY, MEILI_ENV=production]
volumes: [/srv/pipu/meili:/meili_data]
```
Наружу открыт только `nginx`. Все остальные — во внутренней сети compose.
## Конфигурация (`.env`)
| Переменная | Пример / по умолчанию | Описание |
|---|---|---|
| `PIPU_VERSION` | `1.0.0` | Версия образов |
| `PUBLIC_URL` | `https://music.home.lan` | Внешний адрес |
| `DATABASE_URL` | `postgres://pipu:…@postgres:5432/pipu` | |
| `REDIS_URL` | `redis://redis:6379/0` | |
| `MEILI_URL`, `MEILI_MASTER_KEY` | | |
| `JWT_PRIVATE_KEY_FILE` | `/run/secrets/jwt_ed25519.pem` | Генерируется `install.sh` |
| `STREAM_SIGNING_KEY` | 32 байта base64 | HMAC подписанных ссылок |
| `INTERNAL_TOKEN` | случайный | Межсервисная аутентификация |
| `BOOTSTRAP_ADMIN_USERNAME` | `admin` | |
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
| `TRANSCODE_CONCURRENCY` | `2` | |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | `https://git.home.lan`, `owner/pipu-music`, read-only токен | Для самообновления Android |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `LOG_LEVEL` | `info` | |
## TLS
- Свой домен — Let's Encrypt (через отдельный certbot / acme.sh или Caddy перед nginx).
- Локальная сеть — свой CA; сертификат CA нужно установить на устройства или включить пиннинг в мобильном (см. mobile.md).
## Бэкапы
- `pg_dump` ежедневно (cron-контейнер `backup`), хранить 14 дней + еженедельные 8 недель → `/srv/pipu/backups`.
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
- Сама музыка — на NFS, вне зоны ответственности приложения.
## Обновление
```bash
cd /opt/pipu && ./update.sh 1.3.0 # pull образов, up -d, миграции при старте api, проверка /readyz
```
Откат: `./update.sh 1.2.4` (миграции пишутся обратно-совместимыми в пределах минорной версии: сначала добавить,
потом — в следующем релизе — удалить).
+165
View File
@@ -0,0 +1,165 @@
# Дизайн и дизайн-система
Источник — кликабельные прототипы в [`Разработка дизайна приложения/`](Разработка%20дизайна%20приложения/):
| Файл | Что внутри |
|---|---|
| `Pipu Web.dc.html` | Веб: вход, setup-экраны, главная, поиск, исполнитель, альбом, коллекция, профиль, вся админка, диалоги, очередь плеера |
| `Pipu Android.dc.html` | Android: подключение к серверу, вход, главная, поиск, коллекция (со «Скачанным»), альбом, исполнитель, профиль, полноэкранный плеер, листы (качество, пароль, очередь, обновление), офлайн-режим |
| `Wave Options.dc.html` | Три варианта кнопки «Моя волна» |
| `wave.js` | Реализация кнопки волны (Canvas 2D, React) — **референс для продакшен-компонента** |
| `pipu-data.js` | Демо-данные прототипа |
| `support.js`, `android-frame.jsx` | Рантайм прототипа и рамка устройства — в продукт не переносятся |
Просмотр прототипов: прототипам нужен HTTP-сервер (из `file://` скрипты не работают).
В репозитории есть конфиг `.claude/launch.json` (`python -m http.server 8765 --directory docs`),
вручную — та же команда, затем `http://localhost:8765/`.
## 1. Принятые решения по вариантам прототипа
| Развилка | Решение |
|---|---|
| Кнопка «Моя волна» | **Сфера с шумом (`orb`)** и **Жидкая форма (`liquid`)** — пользователь выбирает в профиле, по умолчанию `orb`. Вариант «Кольца» не реализуем. |
| Каркас веба | **Classic**: сайдбар 240 px, топбар 60 px, разделители-линии, без «парящих» панелей |
| Плеер веба | **Оба**: `bar` (полоса 88 px внизу, по умолчанию) и `island` (плавающий остров) — выбор в профиле |
| Главная | **Center**: волна по центру, под ней чипы «Занятие» и «Настроение» |
| Очередь на добавление (админ) | `grouped` — группировка по альбомам (вариант прототипа по умолчанию) |
| Полноэкранный плеер Android | `cover` (крупная обложка) по умолчанию, `wave` (визуализация волны) — переключатель в профиле |
| Тема | Тёмная (по умолчанию) и светлая, переключатель в профиле; `system` — следовать ОС |
| Ambient-фон | Включён: размытые дрейфующие пятна, третье пятно окрашено в цвет обложки текущего трека; отключается в профиле и при `prefers-reduced-motion` |
Пользовательские настройки внешнего вида хранятся в `users.ui_prefs` и синхронизируются между устройствами
(см. [data-model.md](data-model.md)).
## 2. Токены
Источник истины — `packages/design-tokens/tokens.json`; из него генерируются CSS-переменные (веб) и TS-объект (React Native).
Значения взяты из прототипа без изменений.
### Цвета
| Токен | Тёмная | Светлая | Назначение |
|---|---|---|---|
| `bg` | `#05070D` | `#F3F5FA` | Фон приложения |
| `bg2` | `#080B14` | `#EBEEF5` | Вторичный фон |
| `surface` | `#0B0F1A` | `#FFFFFF` | Карточки, панели |
| `surface2` | `#121827` | `#F0F3F9` | Поля ввода, вложенные блоки |
| `surface3` | `#1A2236` | `#E4E9F3` | Чипы, кнопки второго уровня |
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
| `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении |
| `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки |
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#737F9C` | `#0A0F1E` / `#4B5675` / `#677290` | Текст: основной / вторичный / подписи |
| `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки |
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
| `warn` / `warn-soft` | `#FFB547` | `#A86200` | Предупреждение |
| `danger` / `danger-soft` | `#FF5C70` | `#D32F47` | Ошибка, дизлайк, отклонение |
| `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов |
| `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` |
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — радиальный градиент `#9CC6FF → #2F80FF → #3A1FA8`.
### Типографика
| Роль | Шрифт | Где |
|---|---|---|
| Заголовки, логотип, надпись на волне | **Unbounded** 500–700, `letter-spacing: -0.01em` | Заголовки страниц 24–28 px, «Моя волна» |
| Основной текст | **Onest** 400–700 | Всё остальное; базовый размер 15 px, подписи 12–13 px |
| Моноширинный | **JetBrains Mono** 400/600 | Пути файлов, регэкспы, шаблоны, временные пароли, хэши |
| Иконки | **Material Symbols Rounded** (вариативный, `FILL 0..1`) | Залитые иконки — активное состояние |
Все шрифты — OFL / Apache 2.0. **Встраиваем в сборку** (`@fontsource/*`, сабсеты latin + cyrillic) — self-hosted
сервис не должен зависеть от Google Fonts. Иконки — сабсет Material Symbols только с используемыми глифами
(генерируется скриптом при сборке).
### Формы и тени
| Токен | Значение |
|---|---|
| Радиусы | 8 (мелкие кнопки), 10–12 (поля, кнопки), 16 (карточки), 24 (крупные карточки, диалоги), 999 (чипы, бейджи) |
| Высота поля / основной кнопки | 48 px |
| `shadow` | `0 24px 64px rgba(0,0,0,.55)` (светлая: `rgba(20,30,60,.18)`) |
| `glow` | `0 8px 28px rgba(47,128,255,.38)` — основная кнопка |
| Фокус | `border-color: accent` + `box-shadow: 0 0 0 3px accent-soft` |
### Анимация
| Что | Как |
|---|---|
| Появление контента | `pipuIn`: opacity + translateY(6px), 200 мс |
| Эквалайзер текущего трека | `pipuEq`: scaleY 0.25 → 1 |
| Ambient-фон | 3 пятна 55–70vmax, blur 30 px, дрейф 16 / 21 / 26 с, `hue-rotate` ±35° за 30 с, прозрачность `amb-op` (0.8 тёмная / 0.45 светлая) |
| Пружины (волна, нажатия) | Демпфированная пружина как в `wave.js` (`v += (target − x)·0.35; v *= 0.62`) |
| `prefers-reduced-motion` | Ambient статичен, волна замедлена ×0.15 без пульса |
## 3. Кнопка «Моя волна»
Продакшен-компонент — порт `wave.js` на TypeScript, **Canvas 2D** (прототип показал, что этого достаточно; WebGL не нужен).
```ts
interface WaveButtonProps {
variant: 'orb' | 'liquid';
playing: boolean;
bpm?: number; // из TrackDto.features, по умолчанию 100
energy?: number; // 0..1, по умолчанию 0.6
colors?: [string, string, string]; // палитра обложки текущего трека, иначе палитра волны
theme: 'dark' | 'light';
reduced?: boolean; // prefers-reduced-motion
interactive?: boolean; // false — декоративный фон экрана входа
size: number; // 260–360 px на главной, 620 px на фоне входа
onClick?(): void;
}
```
Поведение (как в прототипе):
- **Пульс в такт**: фаза растёт со скоростью `bpm/60`, импульс `(1 − phase)^4 · (0.35 + 0.65·energy)`; в покое — медленное «дыхание».
- **Hover**: скорость анимации ×(1 + 1.4·hover), формы смещаются к курсору; **press**: сжатие до 0.93 с отскоком;
клик / Enter / Space — запуск / пауза.
- Внутри — иконка `play_arrow` / `pause` (44 px) и надпись «Моя волна» (Unbounded 18 px), подпись контекста («Под ваш вкус», «Бегаю · Энергичное»).
- Канвас масштабируется по `devicePixelRatio`; анимация останавливается, когда вкладка скрыта.
- Android: тот же алгоритм на `@shopify/react-native-skia` (API Skia Canvas близок к Canvas 2D), общие формулы —
в `packages/core/wave-anim`.
## 4. Каркас и экраны
### Веб (Classic)
- Сетка: `grid-template-rows: 60px 1fr 88px` (плеер `bar`) или `60px 1fr` + остров снизу (`island`, отступ контента 132 px).
- Топбар: логотип, поиск по центру (выпадающий список с группами «Исполнители / Альбомы / Треки» и подсказкой
исправленной раскладки), аватар с меню профиля (тема, язык, выход).
- Сайдбар: Главная · Коллекция (История, Лайки + счётчик, Дизлайки + счётчик) · Админка (Пользователи, Хранилища,
Библиотека, Предложить треки, Очередь + бейдж, Настройки) — пункты по ролям.
- Плеер: обложка, название / исполнитель, лайк / дизлайк, назад / play (круглая белая кнопка) / вперёд, прогресс со временем,
очередь, громкость. Очередь — выезжающая справа панель; в режиме волны внизу пометка «Волна бесконечна…».
- Диалоги: стеклянные карточки по центру поверх `overlay`. Тосты — внизу над плеером.
Экраны, которые есть в прототипе, и документы, где описана логика:
| Экран | Логика |
|---|---|
| Вход, смена учётных данных админа, обязательная смена пароля | [auth-and-roles.md](auth-and-roles.md) |
| Главная, поиск, исполнитель, альбом | [web.md](web.md), [library-and-storage.md](library-and-storage.md#поиск) |
| Коллекция: История (по дням) / Лайки / Дизлайки | [api.md](api.md) |
| Профиль: пароль, язык, тема, кроссфейд, выравнивание громкости (EBU R128), вид волны, вид плеера, выход | [playback.md](playback.md) |
| Пользователи: таблица, создание (пароль генерируется автоматически), временный пароль один раз, меню действий | [auth-and-roles.md](auth-and-roles.md) |
| Хранилища: NFS / local, опции, «Принимает новые треки», регэкспы с перетаскиванием, предпросмотр, шаблон раскладки с автоподбором и проверкой | [library-and-storage.md](library-and-storage.md) |
| Библиотека: состояние, скан, аудиоанализ, качество волны за 7 дней, ошибки | [recommendations.md](recommendations.md#1-цели-и-как-измеряем-успех) |
| Предложить треки, Очередь на добавление, Настройки очереди | [uploads.md](uploads.md) |
### Android
- Нижняя навигация: Главная · Поиск · Коллекция · Профиль; мини-плеер над ней; полноэкранный плеер (`cover` / `wave`).
- Подключение: адрес сервера → проверка (`/server-info`: версия, HTTPS, совместимость) → список сохранённых серверов.
- Поиск: недавние запросы (локально), подсказка про опечатки и раскладку.
- Коллекция → Скачанное: занято / свободно, «Скачивать новые лайки», «Скачать все лайки», очередь загрузок «по Wi-Fi».
- Профиль: пароль, язык, качество звука (лист), только Wi-Fi, кэш недавних треков (очистить), проверка обновлений
(точка-индикатор), сервер, «Админка — откроется в браузере» (для персонала), выход.
- Лист обновления: «Что нового» → разрешение на установку (один раз) → загрузка → проверка SHA-256 → «Установить».
- Офлайн-баннер при отсутствии сети.
## 5. Пробелы дизайна (дорабатываем при реализации)
- **Адаптивность веба**: прототип рассчитан на десктоп (≥ 1200 px). Для < 1024 px сайдбар сворачивается в иконки,
для < 768 px — нижняя навигация как в Android. Макетов нет — делаем по образцу Android-версии.
- **Пустые состояния и ошибки** есть не везде (например, хранилище недоступно, нет сети в вебе) — по образцу существующих
(иконка + короткий текст + действие).
- **Онбординг вкуса** (выбор жанров / исполнителей при первом входе) в прототипе отсутствует — макет нужен до M6.
- **Английская локализация**: в прототипе только русские строки; при вёрстке проверять длинные строки на обоих языках.
+36
View File
@@ -0,0 +1,36 @@
# Локализация
## Принципы
- Поддерживаемые языки на старте: **ru**, **en**. Язык по умолчанию — язык системы / браузера, если поддерживается, иначе `en`.
- Выбор языка: на экране входа (сохраняется локально) и в профиле (сохраняется в `users.locale` — синхронизируется между устройствами).
- **Сервер не возвращает пользовательских текстов**: только машинные коды ошибок и ключи (`activity.running`).
Исключение — метаданные музыки (названия треков / исполнителей) и changelog релиза; они не переводятся.
- Добавление языка = добавить папку словаря + строку в список `SUPPORTED_LOCALES`, без изменения кода.
## Структура `packages/i18n`
```
packages/i18n/
├── locales/
│ ├── ru/ common.json auth.json player.json wave.json collection.json admin.json errors.json
│ └── en/ ...
├── src/
│ ├── index.ts # инициализация i18next, SUPPORTED_LOCALES, детекция
│ └── types.d.ts # типизация ключей (resources → TS), опечатка в ключе = ошибка компиляции
└── scripts/check.ts # проверка: все ключи есть во всех языках, нет лишних, плейсхолдеры совпадают
```
- Неймспейсы грузятся лениво (веб — по страницам; админка отдельно).
- Плюрализация — ICU / i18next plural (`ru` — one / few / many / other): «1 трек», «3 трека», «5 треков».
- Даты, длительности, числа — `Intl.*` с текущей локалью (`Intl.RelativeTimeFormat` для «вчера», группировка истории по дням).
- Ошибки API: `errors.json` содержит переводы по `code` с подстановкой `params`
(`"validation.password_too_short": "Пароль должен быть не короче {{min}} символов"`).
Неизвестный код → общий текст «Что-то пошло не так» + код в деталях.
## Процесс
- CI запускает `pnpm i18n:check` — отсутствующий ключ в любом языке ломает сборку.
- Новые строки сначала добавляются в `en` и `ru` одновременно в том же PR.
- В будущем — подключение платформы переводов (Weblate, self-hosted) к папке `locales/`, формат JSON совместим.
- Тесты UI запускаются минимум на двух локалях (длинные русские строки ловят проблемы вёрстки).
+152
View File
@@ -0,0 +1,152 @@
# Библиотека: хранилища, шаблоны путей, сканер, поиск
## Хранилища (sources)
Источник — корень, из которого берутся файлы. Управляет только `admin`.
| Тип | Как подключается |
|---|---|
| `nfs` | Админ указывает `server`, `export`, опции. `storage-agent` выполняет `mount -t nfs -o <opts> server:export /library/<id>`. |
| `local` | Папка, уже смонтированная на хосте в `/srv/pipu/library-host` (например, через fstab). Админ выбирает подпапку. Нужен как запасной путь и для dev. |
### Монтирование (storage-agent)
- Общий каталог хоста `/srv/pipu/library` монтируется:
- в `storage-agent` — `bind`, `propagation: rshared`;
- в `api`, `nginx`, `analyzer` — `bind`, `read_only`, `propagation: rslave`.
- Опции NFS по умолчанию: `ro,nfsvers=4.1,soft,timeo=150,retrans=3,noatime`. `ro` принудительно добавляется для всех
источников, кроме отмеченных `accepts_uploads` (см. [ниже](#запись-в-хранилище)).
- Валидация ввода: `server` — hostname/IP, `export` — абсолютный путь без `..`, опции — только из whitelist
(`nfsvers, port, timeo, retrans, soft, hard, rsize, wsize, noatime, nolock, sec`). Никаких произвольных строк в команду.
- `POST /admin/sources/{id}/test`: агент монтирует во временную точку, проверяет чтение листинга и
одного файла, возвращает число найденных аудиофайлов в первых N каталогах.
- Health-check: агент раз в минуту делает `stat` с таймаутом; при сбое — `status=error`, сообщение в админке;
треки источника помечаются недоступными (не выдаются в волну, в UI серые), но **не удаляются**.
- При рестарте агента — перемонтирование всех `status != disabled` источников.
### Запись в хранилище
Единственный сценарий записи — импорт одобренных треков из [очереди на добавление](uploads.md).
- Источник с `accepts_uploads = true` агент монтирует **дважды**:
- `/library/<id>` — `ro`, как обычно (его видят `api`, `nginx`, `analyzer`);
- `/library-rw/<id>` — `rw`, виден **только самому агенту** (каталог без propagation наружу).
- Пишет только `storage-agent` по команде `api` (`POST /internal/files`): создание каталогов, запись во временный файл,
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет запись: создаёт и удаляет
`.pipu-write-test` в корне.
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
## Шаблоны путей (регэкспы)
Для каждого источника — упорядоченный список регулярных выражений (синтаксис RE2 / Go `regexp`)
с именованными группами. Применяются к пути **относительно корня источника**, с `/` как разделителем.
Первое совпавшее выражение побеждает.
Поддерживаемые группы:
| Группа | Поле |
|---|---|
| `artist` | Исполнитель трека |
| `albumartist` | Исполнитель альбома |
| `album` | Альбом |
| `year` | Год (4 цифры) |
| `disc` | Номер диска |
| `track` | Номер трека |
| `title` | Название |
| `genre` | Жанр |
| `skip` | Если совпала — файл игнорируется (например, `^(?P<skip>_incoming)/`) |
Примеры:
```regex
# Исполнитель/2019 - Альбом/01 - Название.flac
^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$
# Жанр/Исполнитель/Альбом (2019)/03. Название.mp3
^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$
# Сборники: Compilations/Альбом/01 - Исполнитель - Название.mp3
^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$
```
### Слияние с тегами
`tag_priority` источника:
- `tags_first` (по умолчанию): значение из тега, если тег пуст — из шаблона;
- `path_first`: значение из шаблона, если группа не совпала — из тега.
Если нет ни тега, ни шаблона: `title` = имя файла без расширения и номера, `artist` = «Unknown artist»,
`album` = имя родительской папки.
Нормализация: обрезка пробелов, `_` → пробел, Unicode NFC; для ключей сопоставления (`name_norm`) —
lower-case, `ё → е`, удаление пунктуации, схлопывание пробелов. Множественные исполнители разбираются по
разделителям `;`, ` / `, ` feat. `, ` ft. `, ` & ` (последний — только если такого составного имени нет в библиотеке).
### Предпросмотр
`POST /admin/sources/{id}/patterns/preview` берёт случайную выборку (по умолчанию 50) путей из
`media_files` (или из листинга, если скана ещё не было) и возвращает для каждого: какое выражение совпало,
извлечённые поля, значения из тегов, итоговый результат после слияния. UI показывает таблицу с подсветкой
групп и счётчик «не совпало ни одно выражение». Регэксп валидируется при вводе (компиляция на сервере,
ошибка с позицией). Ограничение длины выражения — 1000 символов (RE2 линейный, ReDoS невозможен).
## Сканер
Задачи River в `api`:
| Задача | Когда |
|---|---|
| `scan_source(full=false)` | По расписанию источника, вручную, после изменения шаблонов (с `full=true`) |
| `scan_source(full=true)` | Перечитать теги / шаблоны у всех файлов |
| `scan_path(source_id, rel_path)` | Точечный скан одного файла после импорта из очереди |
| `extract_cover(album_id)` | Новый альбом |
| `fingerprint_file(file_id)` | Новый / изменённый файл: полный SHA-256 + Chromaprint (`fpcalc`) — для проверки дублей при загрузке; низкий приоритет |
| `reindex_search` | Вручную / после крупного скана |
Алгоритм инкрементального скана:
1. Обход дерева (`filepath.WalkDir`), фильтр по расширениям `flac mp3 m4a aac ogg opus wav aiff wv ape`.
Параллелизм — 8 воркеров (NFS любит параллельные запросы, но не сотни).
2. Для каждого файла: если `(rel_path, size, mtime)` не изменились и `full=false` — пропуск.
3. Иначе — `ffprobe` (длительность, кодек, битрейт) + чтение тегов, применение шаблонов, `content_hash`.
4. Сопоставление: по `content_hash` ищем «пропавший» файл — если нашли, это переименование / перенос:
обновляем путь, `track_id` сохраняется (лайки и история не теряются).
5. Upsert исполнителей / альбомов / треков; дубли (одинаковые artist+album+title+duration ±2 с) сводятся к
одному треку, `primary_file_id` — файл лучшего качества (lossless > битрейт).
6. Файлы, не встреченные при полном обходе, получают `missing_since`; через 30 дней без появления
трек удаляется (лайки / история сохраняются в событиях, в UI — «трек недоступен»).
7. Новые треки → `analysis_jobs`, батчевое обновление Meilisearch (по 1000 документов).
8. Прогресс публикуется в Redis (`scan:<source_id>`), админка опрашивает `GET /admin/library/status` раз в 2 с
(или SSE `/admin/library/events`).
Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов).
### Обложки
Источники по приоритету: встроенная в файл → `cover.jpg|folder.jpg|front.*` в папке альбома → плейсхолдер,
сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в `/srv/pipu/cache/covers/`, вычисляется
палитра из 3 цветов (k-means по пикселям; `cover_colors`) — для ambient-фона, цветов кнопки волны и плейсхолдеров.
## Поиск
Meilisearch, индексы:
| Индекс | Поля поиска (по убыванию веса) | Фильтры / сортировка |
|---|---|---|
| `artists` | `name`, `name_translit`, `aliases` | `popularity` |
| `albums` | `title`, `artist_name`, `title_translit` | `year`, `popularity` |
| `tracks` | `title`, `artist_names`, `album_title`, `title_translit` | `genre`, `year`, `popularity`, `available` |
- `*_translit` — транслитерация (кириллица ↔ латиница), плюс поле с **исправлением раскладки**
(`ghbdtn` → `привет`): при пустом результате клиентский запрос повторяется в альтернативной раскладке
на стороне `api`.
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
отменяет предыдущий запрос (`AbortController`).
- Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше
в выдаче `suggest` (re-rank первых 20 на стороне `api`).
- Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача
`reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).
+94
View File
@@ -0,0 +1,94 @@
# Android-приложение (`apps/mobile`)
## Стек
| Задача | Выбор |
|---|---|
| Фреймворк | React Native (New Architecture) + Expo SDK, **prebuild / dev client** (нативный код под контролем) |
| Навигация | `expo-router` (файловые маршруты) |
| Аудио | `react-native-track-player` v4 — ExoPlayer, MediaSession, уведомление, экран блокировки, Android Auto-совместимость |
| Анимация волны | `@shopify/react-native-skia` (порт `wave.js`, общие формулы в `packages/core/wave-anim`) + `react-native-reanimated` |
| Серверное состояние | TanStack Query (+ persist в MMKV) |
| Клиентское состояние | Zustand (общие сторы из `packages/core`) |
| Хранилище | `react-native-mmkv` (настройки, кэш), `expo-sqlite` (офлайн-каталог, очередь событий), `expo-secure-store` (refresh-токен) |
| Загрузки | `expo-file-system` (фоновые загрузки, resumable) |
| Сеть | `@react-native-community/netinfo` (Wi-Fi / сотовая → качество) |
| i18n | i18next, словари из `packages/i18n` |
| Минимальная версия | Android 8.0 (API 26), target — актуальный API |
## Экраны
Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
- **Подключение и вход**: адрес сервера (проверка `GET /server-info`: версия сервера, HTTPS, поддерживается ли эта версия
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
прогресс, лайк / дизлайк, скачать, очередь (нижний лист).
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
выбор занятия / настроения.
- **Коллекция**: История, Лайки, Дизлайки, **Скачанное**.
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
очередь загрузок с пометкой «по Wi-Fi».
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
«только Wi-Fi для загрузок», кэш недавних треков (размер + «Очистить»), проверка обновлений (точка, если есть новая версия),
текущий сервер, «Админка — откроется в браузере» (для admin / moderator), выход.
- **Офлайн-баннер** вверху, когда нет связи с сервером.
- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
## Воспроизведение
- `packages/core` содержит общую логику очереди, событий и сохранения позиции; мобильный реализует адаптер
`AudioEngine` поверх `react-native-track-player`.
- Фоновый сервис плеера (`PlaybackService`) обрабатывает кнопки уведомления / гарнитуры и шлёт события,
даже когда UI выгружен.
- Если трек скачан — играется локальный файл, иначе стрим с качеством по типу сети.
- Встроенный кэш ExoPlayer (`maxCacheSize` 500 МБ) для недавних стримов.
- Аудиофокус: пауза при звонке, приглушение при навигационных подсказках.
## Офлайн
- Пользователь скачивает трек, альбом, «все лайки» (опционально — авто-загрузка новых лайков).
- Файлы — во внутреннем хранилище приложения (`documentDirectory/offline/<trackId>.opus`), не видны другим приложениям.
- SQLite хранит метаданные скачанных треков (название, исполнитель, обложка-файл, длительность, features) —
раздел «Скачанное» и плеер работают полностью без сети.
- Очередь загрузок: не более 2 параллельно, возобновление после обрыва, «только по Wi-Fi» по умолчанию.
- Без сети:
- доступны «Скачанное» и офлайн-волна (локальный упрощённый алгоритм: перемешивание скачанных с учётом лайков
и энергии под выбранный контекст);
- события пишутся в SQLite-очередь и отправляются при появлении сети (`POST /events`, идемпотентно);
- реакции (лайк / дизлайк) — оптимистично локально + очередь мутаций.
- Подписанные ссылки не кэшируются — при восстановлении сети запрашиваются заново.
## Самообновление
Приложение распространяется только через Gitea Releases (не Google Play).
**Поток:**
1. При запуске (не чаще раза в 6 ч) и вручную в профиле: `GET /api/v1/app/android/latest` на **сервере Pipu**.
Сервер сам опрашивает Gitea API (`GET /api/v1/repos/{owner}/{repo}/releases/latest`) с серверным токеном,
кэширует ответ на 10 мин и APK на диске. Так:
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
- версия приложения согласована с версией сервера (`min_supported` из `server-info`).
2. Если `version_code` новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже».
Если текущая версия ниже `min_supported` — обновление обязательно.
3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза).
4. Установка: `Intent.ACTION_VIEW` с `content://` URI через `FileProvider` и флагом `FLAG_GRANT_READ_URI_PERMISSION`
(небольшой локальный Expo-модуль `modules/apk-installer` на Kotlin; при необходимости — `PackageInstaller` session API).
5. Если нет разрешения «установка из неизвестных источников» — открываем `Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES`
для нашего пакета с пояснением.
**Требования:**
- В манифесте: `REQUEST_INSTALL_PACKAGES`, `FileProvider`.
- Все релизы подписываются **одним и тем же ключом** (keystore в секретах CI) — иначе Android откажет в обновлении.
- `versionCode` монотонно растёт: `major·10000 + minor·100 + patch` (см. [ci-cd.md](ci-cd.md)).
- Ассеты релиза: `pipu-music-<version>.apk`, `pipu-music-<version>.apk.sha256`.
## Безопасность
- Refresh-токен — `expo-secure-store` (Android Keystore).
- HTTPS обязателен; для self-signed сертификатов — возможность добавить пользовательский CA через
`network_security_config` (собирается в CI по флагу) или пиннинг отпечатка, введённого при подключении к серверу.
- `android:allowBackup="false"` для данных с токенами.
+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.
+239
View File
@@ -0,0 +1,239 @@
# Рекомендательная система и «Моя волна»
## 1. Цели и как измеряем успех
Волна должна: (а) почти не включать то, что пользователь пропустит; (б) регулярно находить новое,
что ему понравится; (в) мгновенно реагировать на реакции и выбранный контекст.
| Метрика (онлайн, в админке) | Что показывает | Цель |
|---|---|---|
| Listen-through rate | Доля треков волны, прослушанных ≥ 50% | > 70% |
| Early skip rate | Доля пропусков в первые 30 с | < 20% |
| Like rate | Лайков на 100 треков волны | растёт |
| Discovery rate | Доля впервые услышанных треков, прослушанных ≥ 50% | 20–40% |
| Session length | Медианная длительность сессии волны | растёт |
Офлайн-метрики при обучении: NDCG@10, Recall@50 на отложенной по времени выборке, AUC предсказания раннего скипа.
## 2. Масштаб и следствия
~100 пользователей × ≥ 11k треков. Матрица взаимодействий разрежена: коллаборативная фильтрация
полезна, но **не может быть основой** — особенно для новых пользователей и треков, которые никто не слушал.
Поэтому система гибридная:
1. **Звучание трека** (аудиоанализ) — работает с первого дня для любого трека.
2. **Личный вкус** — по собственной истории пользователя.
3. **Коллективный опыт** — ALS и обученный на всех пользователях ранжировщик; вес растёт вместе с объёмом данных.
Весь объём (эмбеддинги 11k–100k × 256, векторы ~100 пользователей) помещается в память одного процесса —
кандидаты считаются за миллисекунды, без распределённой инфраструктуры.
## 3. Сигналы и неявная оценка
Из `play_events` для каждой пары (пользователь, трек) считается `implicit_score` — сумма вкладов
с экспоненциальным затуханием по времени (полупериод 90 дней):
| Сигнал | Вклад |
|---|---|
| Доиграл до конца | +1.0 |
| Засчитанное прослушивание (≥ 50%) без доигрывания | +0.7 |
| Скип в первые 10 с | −1.0 |
| Скип 10–30 с | −0.7 |
| Скип 30 с – 50% | −0.2 |
| Лайк | +3.0 |
| Дизлайк | −5.0 и исключение из рекомендаций |
| Повтор по своей инициативе (не волна) за 24 ч | +0.5 |
| Нашёл через поиск и включил | +0.5 |
Поправка на контекст: скип внутри волны — сильный сигнал (×1.0); скип при прослушивании альбома / исполнителя —
слабее (×0.5), потому что человек мог просто листать. Перемотка к концу трека засчитывается как скип в точке начала перемотки.
Производные агрегаты:
- **artist affinity** — сглаженное среднее `implicit_score` треков исполнителя (с байесовским приором к 0);
- **genre prefs** — то же по жанрам (тегам и предсказанным моделью);
- **familiarity** — сколько раз трек слышан, давность последнего прослушивания.
## 4. Представление трека
Заполняет сервис `analyzer` (Essentia + предобученные модели Essentia Models):
| Признак | Модель / алгоритм |
|---|---|
| Аудио-эмбеддинг | `discogs-effnet-bs64` (1280-d, среднее по патчам) → PCA до 256, L2-нормализация |
| Жанры (400 стилей Discogs) | `genre_discogs400` поверх эмбеддинга, топ-5 с вероятностями → `track_genres(source='analysis')` |
| Настроение | `mood_happy`, `mood_sad`, `mood_relaxed`, `mood_aggressive`, `mood_party` |
| Valence / arousal | `deam` / `emomusic` регрессия |
| Танцевальность, вокал / инструментал | `danceability`, `voice_instrumental` |
| BPM | `TempoCNN` (+ уверенность) |
| Тональность | `KeyExtractor` |
| Громкость | EBU R128 (`LoudnessEBUR128`) → `loudness_lufs` |
| Energy | Комбинация RMS / spectral flux / arousal, нормирована по библиотеке в 0..1 |
| Длина вступления | Онсеты + энергия → `intro_ms` (для мягкого старта) |
Производительность: анализируются 3 фрагмента по 30 с (из начала, середины и конца) — ~2–4 с на трек на CPU.
11k треков ≈ 3–6 часов при 4 воркерах при первом запуске; новые треки — в течение минут после скана.
Пайплайн версионируется (`analyzer_ver`); смена версии ставит фоновый пересчёт.
Плюс метаданные: исполнители, год, теги-жанры, глобальная популярность.
## 5. Профиль пользователя
**Долгосрочный** (`user_profiles_rec`, пересчитывается онлайн после каждого события и полностью ночью):
- `taste_audio` — взвешенное среднее аудио-эмбеддингов треков с положительным `implicit_score`
(веса = score × затухание). Хранится также **несколько центроидов** (k-means, k = 3–8 в зависимости от объёма истории):
у людей обычно несколько «вкусов», одно среднее их размазывает;
- `taste_cf` — пользовательский фактор ALS;
- `genre_prefs`, artist affinity;
- параметры бандита по источникам кандидатов.
**Сессионный** (Redis, на время сессии волны):
- `session_vec` — экспоненциальное скользящее среднее эмбеддингов треков сессии с учётом реакций:
`s ← normalize(s + η · r · e_track)`, где `r` = +1 доиграл, +2 лайк, −1 ранний скип, −2 дизлайк, η = 0.3;
- счётчик подряд идущих скипов, использованные исполнители, история выдачи.
## 6. Конвейер «Моей волны»
```
контекст (занятие, настроение, seed) + профиль + сессия
│
1. Кандидаты (≈ 500) ← несколько источников
│
2. Фильтры (дизлайки, недоступные, недавно игравшие)
│
3. Ранжирование (v1: формула → v2: LightGBM)
│
4. Соответствие контексту (занятие / настроение)
│
5. Переранжирование (разнообразие, правила, исследование)
│
пачка из 5 треков → клиент
```
### 6.1 Источники кандидатов
| Источник | Как | Сколько |
|---|---|---|
| `audio_sim` | kNN по `audio` к сессионному вектору и центроидам вкуса | 150 |
| `cf` | kNN по `cf`-факторам к `taste_cf` (ALS) | 100 |
| `item_cf` | «С этим треком слушают»: соседи последних лайков по ALS item-item | 60 |
| `artist` | Треки любимых исполнителей + похожих исполнителей (по эмбеддингу исполнителя = среднее треков) | 60 |
| `genre` | Популярные внутри предпочитаемых жанров | 40 |
| `fresh` | Ещё не слышанные пользователем треки, близкие к вкусу (с ослабленным порогом) | 50 |
| `rediscover` | Любимые треки, не игравшие > 60 дней | 30 |
| `popular` | Глобально популярные за 30 дней (для холодного старта) | 30 |
| `seed` | Если волна запущена от исполнителя / трека — его соседи | 100 |
Пулы кандидатов кэшируются в Redis на 10 минут; пересчитываются, если сильно сдвинулся сессионный вектор.
### 6.2 Фильтры
- дизлайкнутые треки и исполнители — исключены навсегда (пока не снята реакция);
- трек недоступен (источник офлайн, файл пропал) или не проанализирован — исключён из `audio_sim`;
- сыгран в последние 3 часа — исключён; лайкнутый сыгран в последние 24 ч — штраф;
- уже выдан в этой сессии — исключён.
### 6.3 Ранжирование
**v1 (эвристика, с первого дня):**
```
score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artist_affinity
+ 0.10·genre_pref + 0.10·cf_score + 0.05·popularity − penalties
```
**v2 (LightGBM, `lambdarank`)** — обучается на `wave_impressions` всех пользователей.
Метка: 3 — лайк, 2 — доиграл, 1 — ≥ 50%, 0 — скип после 30 с, −1 → 0 с весом ×2 — ранний скип / дизлайк.
Признаки:
- сходства: с `session_vec`, с каждым центроидом (max / mean), с последними 5 треками сессии, `cf` dot-product;
- трек: аудиопризнаки, популярность, возраст в библиотеке, длительность;
- пользователь × трек: `implicit_score`, play / skip counts, давность, artist affinity, genre pref;
- контекст: занятие, настроение, час суток, день недели, позиция в сессии, число скипов подряд,
`context_fit` (см. 6.4), источник кандидата;
- взаимодействия «контекст × аудиопризнаки» модель находит сама.
Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%.
Пока размеченных показов меньше ~20k, используется v1 (переключение автоматическое по порогу).
### 6.4 Занятия и настроения
Каждый контекст — профиль целевых значений аудиопризнаков (`services/recommender/config/contexts.yaml`,
меняется без перекомпиляции). `context_fit` = произведение гауссовых «колоколов» по признакам профиля;
трек с `context_fit` ниже порога выпадает, остальные домножаются. Если после фильтра осталось < 50 кандидатов,
порог ослабляется (лучше чуть мимо, чем повторы).
| Контекст | Профиль |
|---|---|
| 🏃 Бегаю | BPM 150–185 (или половина в 75–92 — двойной темп), energy ≥ 0.7, danceability ≥ 0.5, стабильный темп |
| 💪 Тренировка | energy ≥ 0.75, arousal ≥ 0.7, aggressive допускается |
| ☀️ Просыпаюсь | **траектория**: energy 0.3 → 0.7 за первые 20 минут сессии, valence ≥ 0.5, без aggressive |
| 🎧 Работаю / фокус | energy 0.3–0.6, instrumentalness ≥ 0.6 (предпочтительно), низкая вариативность, без резких переходов |
| 🚗 В дороге | energy 0.4–0.8, ближе к ядру вкуса (меньше исследования) |
| 🛋 Отдыхаю | energy ≤ 0.5, relaxed ≥ 0.5 |
| 🌙 Засыпаю | **траектория**: energy 0.35 → 0.15, BPM ≤ 95, instrumental предпочтительно, громкость ровная |
| ⚡ Энергичное | energy ≥ 0.7, arousal ≥ 0.65 |
| 😊 Весёлое | valence ≥ 0.6, mood_happy ≥ 0.5 |
| 🌿 Спокойное | arousal ≤ 0.4, mood_relaxed ≥ 0.5 |
| 🌧 Грустное | valence ≤ 0.4, mood_sad ≥ 0.5 |
Занятие и настроение комбинируются (пересечение профилей). Если не выбрано ничего — `context_fit = 1`.
Ранжировщик v2 дополнительно выучивает, как конкретный пользователь реагирует в каждом контексте.
### 6.5 Переранжирование и правила
- **Разнообразие** — MMR: `λ·score − (1−λ)·max_sim(с уже выбранными)`, λ = 0.75;
- не более 1 трека исполнителя в пачке и не более 2 на 10 последовательных треков;
- **переходы**: штраф за резкий скачок energy / BPM между соседними треками (кроме «Тренировки»);
- **исследование**: доля «новых» треков = `exploration` пользователя (по умолчанию 15%, адаптируется бандитом);
- **3 скипа подряд** ⇒ «смена курса»: сессионный вектор сбрасывается к ближайшему другому центроиду вкуса,
следующая пачка собирается из другого кластера.
### 6.6 Бандит по источникам
Для каждого пользователя и источника — Beta(α, β); награда = трек прослушан ≥ 50% или лайкнут.
При сборке пачки слоты распределяются по источникам семплированием Томпсона. Так система сама узнаёт,
что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают
(×0.99 в день), чтобы подстраиваться под смену вкуса.
### 6.7 Реакция в реальном времени
- Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
- Запрос `next` несёт реакции на уже выданные треки → сессионный вектор обновляется **до** расчёта пачки.
- Лайк / дизлайк / ранний скип дополнительно вызывают немедленный пересчёт: клиент отбрасывает ещё не начатые
треки очереди волны и берёт свежую пачку.
- Смена занятия / настроения (`PATCH /wave/sessions/{id}`) — очередь волны пересобирается сразу.
## 7. Холодный старт
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
## 8. Обучение на всех пользователях
Ночной пайплайн (03:00, APScheduler в `recommender`):
1. Пересчёт `user_track_stats.implicit_score` (затухание), artist / genre affinity.
2. **ALS** (`implicit`, 64 фактора, confidence `c = 1 + 20·max(score, 0)`, отрицательные — через исключение
и штраф в ранжировании) → `track_embeddings.cf`, `user_profiles_rec.taste_cf`.
3. Кластеризация вкусов (центроиды) для каждого пользователя.
4. Обучение LightGBM-ранжировщика на показах за 90 дней (time-split: последние 7 дней — валидация).
5. Проверка метрик, запись `model_versions`, горячая подмена моделей в памяти сервиса.
6. Пересчёт глобальной популярности, «похожих исполнителей», top-tracks.
Время на ~100 пользователей и ~100k треков — минуты.
## 9. Отказоустойчивость
- Таймаут вызова `api → recommender` — 300 мс. При ошибке `api` отдаёт **fallback-волну** (Go): лайки и треки
любимых исполнителей + случайные треки из предпочитаемых жанров, с теми же фильтрами дизлайков.
- Если анализ трека не завершён — трек участвует только в источниках без эмбеддинга.
## 10. Требования к производительности
- `POST /wave/sessions/{id}/next`: p95 < 150 мс (включая вызов recommender).
- Онлайн-обновление профиля по событию: < 50 мс, асинхронно из Redis Stream.
## 11. Эволюция
| Версия | Содержимое |
|---|---|
| v1 | Аудиоанализ, эвристическое ранжирование, контексты, сессионный вектор, правила, fallback |
| v2 | ALS, LightGBM-ранжировщик, бандит, центроиды вкуса, онбординг |
| v3 | Последовательная модель по сессиям (SASRec / небольшой трансформер) как дополнительный источник кандидатов и признак — когда накопится ≥ 1 млн событий; «волна по исполнителю / треку», ежедневные персональные подборки |
+59
View File
@@ -0,0 +1,59 @@
# План разработки
Каждый этап заканчивается рабочей версией с тегом и релизом.
## M0 — Скелет (v0.1)
- Монорепо: pnpm + Turborepo, Go-модуль, Python-проект, `openapi.yaml` с `server-info`.
- `deploy/compose.dev.yaml`, nginx, Postgres, Redis, Meilisearch.
- `ci.yml` и `release.yml` (пока собирают пустые образы и debug-APK «hello»), генерация тестовой музыки.
- **Готово, когда:** тег `v0.1.0` создаёт релиз в Gitea с образами и APK.
## M1 — Доступ (v0.2)
- Bootstrap-админ, вход, refresh-ротация, смена пароля / учётных данных, сессии.
- Роли и политика прав, админка «Пользователи», аудит.
- Веб: каркас (топбар, сайдбар, пустой плеер), экраны входа и setup, i18n ru / en.
- **Готово, когда:** E2E-сценарии 1, 2, 8, 9, 10 зелёные.
## M2 — Библиотека и поиск (v0.3)
- storage-agent, источники NFS / local, тест подключения.
- Шаблоны путей с предпросмотром, сканер (инкрементальный, переименования, дубли), обложки.
- Meilisearch, suggest и страница поиска, страницы исполнителя и альбома.
- **Готово, когда:** реальная библиотека 11k треков сканируется, поиск < 50 мс, сценарии 3, 4.
## M3 — Воспроизведение (v0.4)
- Стриминг через X-Accel, подписанные ссылки, транскодинг и кэш.
- Плеер в вебе (очередь, Media Session, горячие клавиши, нормализация), сохранение состояния.
- События, история, лайки / дизлайки, раздел «Коллекция».
- **Готово, когда:** сценарии 5, 7; перемотка мгновенная; позиция восстанавливается на другом устройстве.
## M4 — Моя волна v1 (v0.5)
- analyzer (Essentia), признаки и эмбеддинги, прогресс в админке.
- recommender: кандидаты, эвристическое ранжирование, контексты (занятия / настроения), сессионный вектор,
правила разнообразия; fallback в Go.
- Анимированная кнопка волны и MoodPicker в вебе.
- **Готово, когда:** сценарий 6; метрики волны видны в админке.
## M5 — Android (v0.6)
- Вход с адресом сервера, главная с волной, поиск, коллекция, плеер с фоновым воспроизведением.
- Офлайн: загрузки, «Скачанное», очередь событий.
- Самообновление через сервер.
- **Готово, когда:** Maestro-сценарии зелёные; обновление 0.6.0 → 0.6.1 проходит на реальном устройстве.
## M5.5 — Очередь на добавление (v0.7)
- Отпечатки (SHA-256, Chromaprint) для существующей библиотеки.
- Запись через storage-agent, `accepts_uploads`, `write_template` с автоподбором и проверкой обратимости.
- tus-загрузка, предпроверка дублей, квота, обработка, экраны «Предложить треки», «Очередь на добавление», «Настройки».
- **Готово, когда:** E2E-сценарии 11, 12; импорт реального альбома ложится рядом с существующими по той же структуре.
## M6 — Моя волна v2 (v0.8)
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
- Персонализация suggest.
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
## M7 — Стабилизация (v1.0)
- Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора,
доступность, полировка анимаций, багфиксы.
## Дальше (идеи)
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
Android Auto, iOS (TestFlight), Last.fm-скробблинг, импорт лайков.
+52
View File
@@ -0,0 +1,52 @@
# Тестирование
## Пирамида
| Уровень | Где | Инструменты | Что покрываем |
|---|---|---|---|
| Unit | Go | `testing`, `testify`, табличные тесты | Политики прав (вся матрица из auth-and-roles.md), парсинг шаблонов путей и слияние с тегами, нормализация имён, подпись ссылок, расчёт `implicit_score`, рендер `write_template` и очистка имён, обратимость шаблон ↔ регэксп, учёт квоты при конкурентных резервах, переходы статусов загрузки |
| Unit | Python | `pytest`, `hypothesis` | Формулы скоринга, context_fit, MMR, бандит, обновление сессионного вектора, правила переранжирования |
| Unit | TS | `vitest` | `packages/core`: очередь, события (подсчёт `played_ms`, засчитанное прослушивание), single-flight refresh, офлайн-очередь |
| Компоненты | Web | `vitest` + Testing Library | Поиск (клавиатура, Enter / выбор), плеер, MoodPicker, формы админки |
| Компоненты | Mobile | `jest` + RNTL | Экраны входа, коллекции, диалог обновления |
| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер на тестовой библиотеке, API-хендлеры через `httptest` |
| Интеграция | Python | `testcontainers` | Чтение событий из Redis Stream, kNN в pgvector, ночной пайплайн на синтетических данных |
| Контракт | — | `schemathesis` против запущенного `api` | Ответы соответствуют `openapi.yaml`, нет 500 на фаззинге |
| E2E | Web | Playwright (Chromium + Firefox) | Сценарии ниже, на `deploy/compose.test.yaml` |
| E2E | Mobile | Maestro (эмулятор) | Вход, волна, фоновое воспроизведение, скачивание, офлайн — ночной прогон, не на каждый PR |
| Качество рекомендаций | Python | офлайн-оценка | NDCG / Recall на синтетическом датасете с известными «вкусами»; регрессия качества ломает тест |
## Тестовая музыка
Реальную музыку в репозиторий не кладём. `tests/fixtures/music/generate.sh` генерирует через `ffmpeg`
~200 коротких (5–20 с) файлов разных форматов (FLAC, MP3, Opus, M4A) с синусами / шумом разного темпа и громкости,
с полными, частичными и отсутствующими тегами, в нескольких структурах папок — для проверки шаблонов,
дублей, переименований, кириллицы в путях. Генерация детерминирована (фиксированные параметры), результат кэшируется в CI.
Для аудиоанализа — отдельный набор из нескольких клипов с известным BPM (метроном 90 / 128 / 170).
## E2E-сценарии (обязательные)
1. Первый запуск: вход bootstrap-админа → принудительная смена логина / пароля → главная.
2. Админ создаёт модератора; модератор создаёт пользователя; модератор **не может** создать модератора (UI и API).
3. Админ добавляет `local`-источник, задаёт два шаблона, предпросмотр, скан → треки появились в поиске.
4. Поиск: выпадающий список → переход к исполнителю; Enter → страница поиска.
5. Воспроизведение: запуск трека, перемотка, перезагрузка страницы → трек и позиция восстановлены.
6. Моя волна: запуск с занятием «Бегаю» → в выдаче треки с BPM из профиля; дизлайк → следующий трек, трек в «Дизлайках».
7. Коллекция: лайк → в «Лайках»; история содержит прослушанное.
8. Смена пароля пользователем → старые сессии разлогинены.
9. Переключение языка ru ↔ en.
10. Сброс пароля пользователю → обязательная смена при входе.
11. Очередь на добавление: модератор загружает папку из 5 файлов, где 1 — точная копия трека из библиотеки,
1 — тот же трек в другом формате → первый отброшен на предпроверке, второй — по отпечатку; оба не учтены в квоте;
оставшиеся 3 — `pending_review`. Админ правит альбом, одобряет → файлы появились в хранилище по `write_template`,
трек найден поиском; квота освобождена.
12. Квота: лимит 50 МБ, загрузка 80 МБ → часть файлов отклонена `uploads.queue_full`; модератор не может
одобрить собственную загрузку, а обычный пользователь — открыть загрузку вообще (UI и API).
## Правила
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
- Тест на каждый исправленный баг.
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
- Нагрузочный smoke (k6, вручную перед релизом): 100 одновременных слушателей, 20 rps на suggest — p95 в рамках целей.
+181
View File
@@ -0,0 +1,181 @@
# Предложение треков (загрузка с модерацией)
Модераторы (и админы) предлагают новые треки. Файлы попадают в **очередь на добавление** с общим лимитом размера,
дубли отбрасываются сразу, админ одобряет или отклоняет, одобренные файлы раскладываются в хранилище
**по той же структуре папок, что принята в этом хранилище**.
## 1. Роли
| Действие | user | moderator | admin |
|---|:-:|:-:|:-:|
| Загрузить треки в очередь | — | ✅ | ✅ |
| Видеть свои предложения и их статус, отозвать до рассмотрения | — | ✅ | ✅ |
| Видеть всю очередь | — | — | ✅ |
| Править метаданные, слушать, одобрять / отклонять | — | — | ✅ |
| Настраивать лимит очереди, целевое хранилище и шаблон раскладки | — | — | ✅ |
Загрузки админа проходят ту же проверку на дубли; в очередь они тоже попадают, но админ может одобрить их сразу.
## 2. Жизненный цикл загрузки
```
предпроверка (хэш + теги на клиенте)
│ дубль → отказ сразу, файл не загружается, квота не тратится
▼
uploading ──► processing ──► pending_review ──► approved ──► importing ──► imported
│ │ │ │
│ ├─► duplicate ├─► rejected (причина) └─► import_failed
│ └─► invalid └─► withdrawn (отозвал автор) (можно повторить)
└─► expired (не докачан за 24 ч)
```
| Статус | Занимает квоту | Файл в staging |
|---|:-:|:-:|
| `uploading` | ✅ (резерв по заявленному размеру) | частично |
| `processing` | ✅ | ✅ |
| `pending_review` | ✅ | ✅ |
| `approved`, `importing` | ✅ (до окончания копирования) | ✅ |
| `imported`, `import_failed`→после ретрая | — | удаляется |
| `duplicate`, `invalid`, `rejected`, `withdrawn`, `expired` | — | удаляется сразу |
## 3. Квота очереди
- Глобальный лимит `uploads.queue_limit_bytes` (по умолчанию **10 ГБ**), меняет только админ в настройках админки.
- Опционально — лимит на одного модератора `uploads.per_user_limit_bytes` (по умолчанию выключен) и максимальный
размер одного файла `uploads.max_file_bytes` (по умолчанию 2 ГБ).
- **Занято** = сумма `size_bytes` загрузок в статусах, отмеченных в таблице выше.
Дубли и отклонённые не учитываются никогда.
- Перед загрузкой клиент запрашивает резерв (`POST /uploads` со списком файлов и размерами). Сервер в транзакции
(`SELECT … FOR UPDATE` строки счётчика) проверяет `занято + запрошено ≤ лимит` и резервирует место.
Не помещающиеся файлы отклоняются с кодом `uploads.queue_full` (клиент показывает, сколько места осталось).
- Снижение лимита ниже занятого не удаляет существующие загрузки — просто новые не принимаются, пока очередь не освободится.
- Индикатор заполненности очереди виден модераторам и админу («6.2 из 10 ГБ»).
## 4. Проверка на дубли
Три уровня, от дешёвого к дорогому. Дубль на любом уровне = статус `duplicate`, файл удаляется,
квота освобождается, автор видит, с каким существующим треком совпало (ссылка на трек).
| Уровень | Когда | Как |
|---|---|---|
| 1. Точная копия файла | **До загрузки** (на клиенте) и после | SHA-256 всего файла: клиент считает в браузере (Web Crypto, потоково в Web Worker) и отправляет в предпроверке. Сравнение с `media_files.sha256` и с другими загрузками в очереди. |
| 2. Совпадение метаданных | **До загрузки** (теги читаются на клиенте библиотекой `music-metadata`) и после (на сервере по `ffprobe`) | Нормализованные `artist + title` (+ `album`, если есть) и длительность ±2 с против `tracks`. |
| 3. Акустический отпечаток | После загрузки | Chromaprint (`fpcalc`), сравнение с `track_fingerprints` библиотеки и очереди. Ловит тот же трек в другом формате / битрейте / с другими тегами. |
Правила:
- Совпадение уровня 1 или 3 — дубль безусловно.
- Совпадение только уровня 2 (метаданные совпали, отпечаток — нет: ремастер, live, другая версия) — **не отбрасывается**,
а помечается флагом `possible_duplicate` для решения админа.
- Если загружаемый файл того же трека **лучшего качества** (lossless вместо MP3) — дубль всё равно отбрасывается автоматически,
но в настройках можно включить режим `uploads.allow_quality_upgrade`: тогда такой файл идёт на рассмотрение
с пометкой «замена на лучшее качество».
- Дубли внутри одной пачки загрузки (два одинаковых файла) — второй отбрасывается.
- Отпечатки для существующей библиотеки считает сканер (задача `fingerprint_track`, ~0.3 с на трек) —
выполняется при первом скане и для новых файлов.
## 5. Загрузка
- Протокол **tus** (resumable upload): сервер — `tusd` как библиотека внутри `api` (`/api/v1/uploads/files/`),
клиент — `tus-js-client`. Докачка после обрыва, чанки по 8 МБ.
- Веб: drag-and-drop файлов и **папок** (сохраняются относительные пути — помогают угадать альбом),
до 4 параллельных загрузок, прогресс по каждому файлу.
- Мобильный клиент загрузку не поддерживает (первая версия).
- Staging — локальный диск сервера `/srv/pipu/uploads/<upload_id>/<file_id>` (не NFS).
- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Проверяется не расширение, а содержимое (`ffprobe`):
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины.
- Допускается отдельная картинка обложки (`cover.jpg|png`) в той же пачке — привязывается к файлам той же папки.
## 6. Обработка (`processing`)
Задача River `process_upload_file`:
1. `ffprobe` → формат, длительность, битрейт, теги.
2. SHA-256, `content_hash`, `fpcalc` → проверки дублей (раздел 4).
3. Метаданные-кандидаты: теги → если пусто, разбор относительного пути из браузера шаблонами целевого хранилища
→ если пусто, имя файла.
4. Нормализация и сопоставление с существующими исполнителями / альбомами каталога
(чтобы «Кино» не превратилось во второго исполнителя «КИНО»): найденные id подставляются, админ видит
«будет добавлено к существующему альбому» или «новый альбом».
5. Расчёт **предварительного целевого пути** (раздел 7).
6. Статус `pending_review`.
## 7. Раскладка в хранилище «как принято на сервере»
### Целевое хранилище
- Админ отмечает одно или несколько хранилищ как **принимающие загрузки** (`accepts_uploads = true`).
Только они монтируются на запись (см. [library-and-storage.md](library-and-storage.md#запись-в-хранилище)).
- Хранилище по умолчанию — в настройках; при одобрении админ может выбрать другое.
### Шаблон раскладки
Регэкспы умеют только *разбирать* путь, поэтому для *записи* у хранилища есть **шаблон пути** (`write_template`):
```
{albumartist}/{year} - {album}/{disc:1-}{track:02} - {title}.{ext}
```
| Плейсхолдер | Значение |
|---|---|
| `{artist}`, `{albumartist}` | Исполнитель трека / альбома (если нет — исполнитель трека) |
| `{album}`, `{title}`, `{genre}` | |
| `{year}` | 4 цифры |
| `{track:02}` | Номер с дополнением нулями |
| `{disc:1-}` | Номер диска с суффиксом `-`, выводится только если дисков > 1 |
| `{ext}` | Расширение по реальному формату |
| `[ ... ]` | Необязательный фрагмент: опускается целиком, если любой плейсхолдер внутри пуст, например `[{year} - ]{album}` |
**Как получить шаблон:**
- **Автоподбор**: система берёт выборку существующих путей хранилища, определяет, какой регэксп совпадает чаще всего,
и предлагает шаблон, порождающий такие же пути (группы регэкспа → плейсхолдеры, литералы между группами → текст шаблона).
Админ подтверждает или правит.
- **Проверка обратимости** (обязательна при сохранении): шаблон применяется к 50 существующим трекам хранилища,
полученные пути прогоняются через регэкспы этого хранилища — поля должны распознаться обратно без изменений,
а в ≥ 90% случаев путь должен совпасть с реальным путём файла. Иначе — предупреждение с примерами расхождений.
Так гарантируется, что новые файлы лежат так же, как старые, и сканер разберёт их правильно.
### Очистка имён
- Запрещённые символы `/ \ : * ? " < > |` и управляющие → `_`; точки и пробелы в конце сегмента удаляются.
- Длина сегмента ≤ 200 байт UTF-8, полный путь ≤ 1000 байт (обрезка по границе символа с сохранением расширения).
- Unicode NFC. Регистр не меняется.
- Существующая папка исполнителя / альбома переиспользуется, если её имя совпадает с нормализованным значением
(`Кино` ↔ `КИНО`) — берётся **уже существующее написание**, чтобы не плодить соседние папки.
### Конфликты
- Если файл по целевому пути уже существует — добавляется суффикс ` (2)`, а в задаче — предупреждение.
- Путь проверяется на выход за корень хранилища (`..`, абсолютные пути) — ошибка.
## 8. Рассмотрение (админ)
Экран «Очередь на добавление»:
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
- Для каждого файла: прослушивание (стрим из staging), метаданные с возможностью правки, обложка, качество
(формат / битрейт / sample rate), флаги (возможный дубль — со ссылкой на похожий трек), **итоговый путь в хранилище**
(пересчитывается live при правке метаданных).
- Действия: одобрить / отклонить (с причиной — видна автору), массовые действия для выбранных, правка «на весь альбом»
(исполнитель, альбом, год, обложка одной операцией).
- Если админ изменил метаданные — они **записываются в теги файла** при импорте (`ffmpeg -map 0 -c copy -metadata …`
без перекодирования; для FLAC/MP3/M4A/Ogg), чтобы теги и путь не противоречили друг другу.
## 9. Импорт (`importing`)
Задача River `import_upload_file` (повторяемая, идемпотентная):
1. Пересчитать целевой путь с финальными метаданными.
2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`.
Агент:
- создаёт каталоги;
- копирует во временный файл `.<name>.pipu-tmp` в целевой папке, `fsync`;
- сверяет SHA-256 копии со staging;
- атомарно переименовывает в итоговое имя.
3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ.
В `media_files` сохраняется `upload_id` — история происхождения.
4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`.
При ошибке (NFS недоступен, нет места) — `import_failed` с сообщением, квота не освобождается, файл остаётся в staging;
админ может повторить или отклонить.
## 10. Уведомления
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
- Админ видит бейдж «N на рассмотрении» в сайдбаре.
## 11. Очистка
- `uploading` старше 24 ч → `expired`, файлы удаляются.
- Записи завершённых загрузок (`imported`, `rejected`, …) хранятся 180 дней для истории, без файлов.
- При старте `api` — сверка staging-каталога с БД: осиротевшие файлы удаляются.
+103
View File
@@ -0,0 +1,103 @@
# Веб-клиент (`apps/web`)
## Стек
| Задача | Выбор |
|---|---|
| Сборка | Vite, TypeScript (strict) |
| UI | React 19 |
| Роутинг | TanStack Router (типобезопасные пути и search-параметры) |
| Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) |
| Клиентское состояние | Zustand (плеер, очередь, UI) |
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono, сабсет Material Symbols Rounded — всё встроено в сборку |
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска) |
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
| Формы | React Hook Form + Zod |
| i18n | i18next + react-i18next, словари из `packages/i18n` |
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
## Структура (feature-sliced)
```
apps/web/src/
├── app/ # провайдеры, роутер, глобальные стили, гарды
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
│ # collection/{history,likes,dislikes}, settings,
│ # admin/{users,sources,library,upload,upload-queue,settings}
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
├── features/ # like-track, change-password, mood-picker, create-user, pattern-editor, ...
├── entities/ # track, album, artist, user — модели и маленькие UI-карточки
└── shared/ # ui-kit, lib, hooks, config
```
Правило: слой может импортировать только из нижележащих (`pages → widgets → features → entities → shared`),
проверяется `eslint-plugin-boundaries`.
## Ключевые компоненты
### App shell
Макет — `Pipu Web.dc.html`, каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
Админка по роли) / контент / плеер.
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
- Адаптивность (макетов нет): < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
### Поиск
- `SearchBox` — Radix Popover + combobox по WAI-ARIA (стрелки, Enter, Esc).
- Debounce 150 мс, отмена предыдущего запроса, `GET /search/suggest`.
- Enter без выбранного элемента → `/search?q=…`; Enter / клик по элементу → исполнитель / альбом / запуск трека.
- Горячая клавиша `/` или `Ctrl+K` — фокус в поиск.
### Плеер
- Движок: `HTMLAudioElement` → Web Audio граф (`MediaElementSource → Gain (нормализация) → Analyser → destination`).
`Analyser` даёт спектр для анимации кнопки волны (тот же origin — CORS не мешает).
- Два элемента `<audio>` для предзагрузки следующего трека и бесшовного перехода (опционально — кроссфейд 0–6 с в настройках).
- Media Session API: метаданные, обложка, кнопки ОС / наушников.
- Горячие клавиши: пробел — пауза, ←/→ — перемотка 10 с, Shift+←/→ — трек, L — лайк.
- Логика очереди, событий, сохранения позиции — из `packages/core` (общая с мобильным); веб реализует
только адаптер `AudioEngine`.
### Кнопка «Моя волна»
- Большой круг в центре главной (раскладка **Center**). Два вида на выбор пользователя: **сфера с шумом** (`orb`,
по умолчанию) и **жидкая форма** (`liquid`). Реализация — порт `wave.js` на TS, Canvas 2D; API и поведение —
[design.md](design.md#3-кнопка-моя-волна).
- В покое — медленное «дыхание». При воспроизведении — пульс с частотой BPM трека и силой от energy,
дополнительно амплитуда от RMS спектра (`AnalyserNode`); цвета — палитра обложки текущего трека.
- Hover — волны ускоряются и «тянутся» к курсору; нажатие — сжатие / отскок (spring).
- Та же кнопка, увеличенная и неинтерактивная, — декоративный фон экрана входа.
- Под кнопкой — `MoodPicker`: чипы занятий и настроений (одно занятие + одно настроение, повторный клик снимает выбор).
Смена во время игры волны — без остановки музыки.
- `prefers-reduced-motion` — замедленная версия без пульса, ambient-фон статичен.
### Профиль и настройки
Пароль, язык, тема (тёмная / светлая / системная), вид волны, вид плеера, ambient-фон, кроссфейд (0–6 с),
выравнивание громкости (EBU R128), выход. Настройки внешнего вида сохраняются в `ui_prefs` (`PATCH /me`).
### Админка
- Пользователи: таблица с поиском и фильтром по ролям, создание (диалог: логин, имя, роль; пароль генерируется сервером
и показывается один раз), сброс пароля (тот же диалог «Временный пароль» с кнопкой «копировать»), блокировка, смена роли (только админу видно).
- Хранилища: список с статусом, форма NFS / local, кнопка «Проверить»; редактор шаблонов — список регэкспов
с drag-and-drop порядка, подсветкой синтаксиса и ошибок компиляции, таблица предпросмотра с подсветкой групп.
- Библиотека: счётчики, прогресс скана и анализа (live), список ошибок.
- Хранилища → «Шаблон раскладки»: поле `write_template` с кнопками «Подобрать автоматически» и «Проверить»
(таблица расхождений реальный путь ↔ сгенерированный).
- **Предложить треки** (moderator, admin): зона drag-and-drop для файлов и папок, индикатор очереди «X из Y ГБ».
Перед загрузкой — Web Worker считает SHA-256 и читает теги (`music-metadata`), `POST /uploads/precheck` →
дубли сразу показываются как «уже есть: <трек>» и не загружаются. Загрузка — `tus-js-client`, до 4 параллельно,
пауза / возобновление. Ниже — «Мои предложения» со статусами и причинами отклонения.
- **Очередь на добавление** (admin): группы по альбомам (вариант `grouped` из прототипа), мини-плеер для прослушивания, inline-редактирование
метаданных (поле на весь альбом), live-превью итогового пути, бейджи «возможный дубль» / «лучшее качество»,
массовые одобрение / отклонение с причиной. Бейдж с числом ожидающих — в сайдбаре.
- **Настройки** (admin): лимит очереди (ГБ), лимит на модератора, макс. размер файла, хранилище по умолчанию.
## Производительность
- Длинные списки (история, лайки, треки альбома) — виртуализация (TanStack Virtual).
- Код-сплиттинг по страницам; админка — отдельный чанк, грузится только для ролей admin / moderator.
- Обложки — `srcset` 64 / 300 / 1200, `loading="lazy"`.
- Бюджет: первый JS-чанк < 200 КБ gzip, LCP < 1.5 с в локальной сети.
## Доступность
- Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA.
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

@@ -0,0 +1,790 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700&family=Unbounded:wght@500;600;700&family=JetBrains+Mono:wght@400;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0&display=block" rel="stylesheet">
<script src="pipu-data.js"></script>
<style>
:root{--bg:#05070D;--bg2:#080B14;--surface:#0B0F1A;--surface2:#121827;--surface3:#1A2236;--elev:#111726;--hover:rgba(120,150,255,.07);--line:rgba(130,160,255,.10);--line2:rgba(130,160,255,.18);--text:#EDF1FF;--text2:#A0ABC6;--text3:#737F9C;--accent:#2F80FF;--accent2:#5E9BFF;--accent-soft:rgba(47,128,255,.15);--violet:#9B7CFF;--ok:#2FD39A;--ok-soft:rgba(47,211,154,.14);--warn:#FFB547;--warn-soft:rgba(255,181,71,.14);--danger:#FF5C70;--danger-soft:rgba(255,92,112,.14);--overlay:rgba(3,5,10,.6);--shadow:0 24px 64px rgba(0,0,0,.55);--glass:rgba(14,19,32,.82);--glow:0 8px 28px rgba(47,128,255,.38);--amb-op:.75}
[data-theme="light"]{--bg:#F3F5FA;--bg2:#EBEEF5;--surface:#FFFFFF;--surface2:#E9EDF5;--surface3:#DDE3EE;--elev:#FFFFFF;--hover:rgba(30,60,140,.06);--line:rgba(20,35,80,.10);--line2:rgba(20,35,80,.17);--text:#0A0F1E;--text2:#4B5675;--text3:#677290;--accent:#1F6FF2;--accent2:#1F6FF2;--accent-soft:rgba(31,111,242,.11);--violet:#6A47F0;--ok:#0E9465;--ok-soft:rgba(14,148,101,.10);--warn:#A86200;--warn-soft:rgba(168,98,0,.10);--danger:#D32F47;--danger-soft:rgba(211,47,71,.09);--overlay:rgba(20,30,60,.35);--shadow:0 24px 64px rgba(20,30,60,.18);--glass:rgba(255,255,255,.86);--glow:0 8px 24px rgba(31,111,242,.28);--amb-op:.42}
@keyframes pipuDrift1{0%{transform:translate(-10%,-6%) scale(1)}35%{transform:translate(45%,28%) scale(1.2)}70%{transform:translate(15%,60%) scale(.9)}100%{transform:translate(60%,-5%) scale(1.1)}}
@keyframes pipuDrift2{0%{transform:translate(4%,0) scale(1.05)}40%{transform:translate(-50%,40%) scale(.9)}75%{transform:translate(-20%,-25%) scale(1.2)}100%{transform:translate(-60%,15%) scale(1)}}
@keyframes pipuDrift3{0%{transform:translate(0,0) scale(.95)}30%{transform:translate(-40%,-45%) scale(1.15)}65%{transform:translate(45%,-25%) scale(1)}100%{transform:translate(10%,-55%) scale(1.2)}}
@keyframes pipuHue{0%{filter:hue-rotate(0deg)}50%{filter:hue-rotate(-35deg)}100%{filter:hue-rotate(0deg)}}
@keyframes pipuEq{0%{transform:scaleY(.25)}100%{transform:scaleY(1)}}
@keyframes pipuIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
@keyframes pipuUp{from{transform:translateY(100%)}to{transform:none}}
@keyframes pipuSpin{to{transform:rotate(360deg)}}
body{margin:0;background:#05070D;font-family:Onest,system-ui,sans-serif;-webkit-font-smoothing:antialiased}
a{color:#5E9BFF;text-decoration:none}a:hover{color:#EDF1FF}
button{font:inherit;color:inherit;background:none;border:0;padding:0;cursor:pointer}
input,textarea,select{font:inherit;color:inherit}
i{font-family:'Material Symbols Rounded';font-style:normal;font-weight:normal;line-height:1;font-size:24px;display:inline-block;-webkit-font-smoothing:antialiased;font-feature-settings:'liga';user-select:none;flex-shrink:0}
</style>
</helmet>
<div style="min-height:100vh;display:flex;gap:48px;justify-content:center;align-items:flex-start;padding:40px 32px;box-sizing:border-box;flex-wrap:wrap;font-family:Onest,system-ui,sans-serif;color:#EDF1FF;background:radial-gradient(ellipse at 70% 0%, #0C1428, #05070D 60%)">
<div style="width:300px;display:flex;flex-direction:column;gap:20px;position:sticky;top:40px">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:28px;height:28px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);box-shadow:0 0 18px rgba(47,128,255,.55)"></div>
<div style="font-family:Unbounded;font-weight:600;font-size:19px;letter-spacing:-0.02em">pipu <span style="color:#737F9C;font-weight:500">· Android</span></div>
</div>
<div style="font-size:14px;line-height:1.55;color:#A0ABC6;text-wrap:pretty">Кликабельный прототип. Внутри телефона всё работает: волна, плеер, загрузки, обновление. Слева — быстрый переход к экранам.</div>
<div style="display:flex;flex-direction:column;gap:2px">
<sc-for list="{{ jumps }}" as="j" hint-placeholder-count="12">
<button onClick="{{ j.onClick }}" style="display:flex;align-items:center;gap:12px;height:38px;padding:0 12px;border-radius:10px;font-size:14px;font-weight:500;text-align:left;background:{{ j.bg }};color:{{ j.color }}" style-hover="background:rgba(120,150,255,.08);color:#EDF1FF"><span style="width:20px;font-family:'JetBrains Mono';font-size:12px;color:#737F9C">{{ j.n }}</span>{{ j.label }}</button>
</sc-for>
</div>
<sc-if value="{{ note }}">
<div style="padding:14px 16px;border-radius:14px;background:rgba(47,128,255,.10);border:1px solid rgba(47,128,255,.25);font-size:13px;line-height:1.55;color:#C4CEE6;text-wrap:pretty">{{ note }}</div>
</sc-if>
</div>
<x-import component-from-global-scope="AndroidDevice" from="./android-frame.jsx" dark="{{ isDark }}" bg="{{ deviceBg }}" hint-size="412px,892px">
<div data-theme="{{ theme }}" style="position:absolute;inset:0;background:var(--bg);color:var(--text);font-family:Onest,system-ui,sans-serif;overflow:hidden;display:flex;flex-direction:column">
{{ ambient }}
<sc-if value="{{ scr.connect }}">
<div style="position:relative;z-index:1;flex:1;display:flex;flex-direction:column;gap:24px;padding:40px 24px 24px;overflow:auto">
<div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:16px 0 8px">
<div style="width:72px;height:72px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);box-shadow:0 0 40px rgba(47,128,255,.55)"></div>
<div style="font-family:Unbounded;font-weight:600;font-size:28px;letter-spacing:-0.02em">pipu</div>
<div style="font-size:15px;color:var(--text2);text-align:center;text-wrap:pretty">Подключитесь к своему серверу Pipu Music</div>
</div>
<label style="display:flex;flex-direction:column;gap:8px">
<span style="font-size:13px;font-weight:500;color:var(--text2)">Адрес сервера</span>
<div style="display:flex;align-items:center;gap:10px;height:56px;padding:0 8px 0 16px;border-radius:16px;background:var(--surface2);border:1px solid {{ srv.border }}">
<i style="font-size:22px;color:var(--text3)">dns</i>
<input value="{{ srv.addr }}" onChange="{{ srv.onAddr }}" placeholder="music.example.ru" style="flex:1;min-width:0;height:100%;background:none;border:0;outline:none;font-size:16px;font-family:'JetBrains Mono'">
</div>
</label>
<sc-if value="{{ srv.checking }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--surface2);font-size:14px;color:var(--text2)"><i style="font-size:20px;animation:pipuSpin 1s linear infinite">progress_activity</i>Проверяем сервер…</div>
</sc-if>
<sc-if value="{{ srv.ok }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--ok-soft);animation:pipuIn .2s ease-out"><i style="color:var(--ok)">check_circle</i><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px">Pipu Music · сервер 0.6.1</span><span style="font-size:13px;color:var(--text2)">HTTPS · версия приложения поддерживается</span></div></div>
</sc-if>
<sc-if value="{{ srv.err }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--danger-soft);color:var(--danger);font-size:14px;font-weight:500"><i>error</i>Сервер не отвечает. Проверьте адрес и сеть.</div>
</sc-if>
<div style="display:flex;flex-direction:column;gap:8px">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Сохранённые серверы</span>
<sc-for list="{{ srv.profiles }}" as="p" hint-placeholder-count="2">
<button onClick="{{ p.onClick }}" style="display:flex;align-items:center;gap:14px;min-height:60px;padding:8px 14px;border-radius:14px;background:{{ p.bg }};border:1px solid {{ p.border }};text-align:left">
<i style="color:var(--text2)">{{ p.icon }}</i>
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-weight:500;font-size:15px">{{ p.name }}</span><span style="font-size:13px;color:var(--text2);font-family:'JetBrains Mono'">{{ p.addr }}</span></div>
</button>
</sc-for>
</div>
<div style="flex:1"></div>
<div style="display:flex;gap:10px">
<button onClick="{{ srv.onCheck }}" style="flex:1;height:52px;border-radius:26px;border:1px solid var(--line2);font-weight:600;font-size:15px">Проверить</button>
<button onClick="{{ srv.onNext }}" style="flex:1;height:52px;border-radius:26px;background:{{ srv.nextBg }};color:{{ srv.nextColor }};font-weight:600;font-size:15px">Далее</button>
</div>
</div>
</sc-if>
<sc-if value="{{ scr.login }}">
<div style="position:relative;z-index:1;flex:1;display:flex;flex-direction:column;gap:22px;padding:20px 24px 24px;overflow:auto">
<div style="display:flex;align-items:center;justify-content:space-between">
<button onClick="{{ toConnect }}" style="display:flex;align-items:center;gap:6px;height:36px;padding:0 12px 0 8px;border-radius:18px;background:var(--surface2);font-size:13px;font-family:'JetBrains Mono';color:var(--text2)"><i style="font-size:18px">dns</i>{{ srv.addr }}</button>
<div style="display:flex;gap:2px;padding:3px;border-radius:12px;background:var(--surface2)">
<sc-for list="{{ langs }}" as="l" hint-placeholder-count="2"><button onClick="{{ l.onClick }}" style="height:32px;padding:0 12px;border-radius:9px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px;padding-top:36px">
<div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">Вход</div>
<div style="font-size:15px;line-height:1.5;color:var(--text2)">Учётную запись выдаёт администратор сервера</div>
</div>
<label style="display:flex;flex-direction:column;gap:8px"><span style="font-size:13px;font-weight:500;color:var(--text2)">Логин</span><input value="{{ f.login }}" onChange="{{ fh.login }}" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px" style-focus="border-color:var(--accent)"></label>
<label style="display:flex;flex-direction:column;gap:8px"><span style="font-size:13px;font-weight:500;color:var(--text2)">Пароль</span><input type="password" value="{{ f.pass }}" onChange="{{ fh.pass }}" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px" style-focus="border-color:var(--accent)"></label>
<sc-if value="{{ loginErr }}"><div style="display:flex;align-items:center;gap:8px;padding:12px 14px;border-radius:12px;background:var(--danger-soft);color:var(--danger);font-size:14px;font-weight:500"><i style="font-size:20px">error</i>{{ loginErr }}</div></sc-if>
<div style="flex:1"></div>
<button onClick="{{ onLogin }}" style="height:56px;border-radius:28px;background:var(--accent);color:#fff;font-weight:600;font-size:16px;box-shadow:var(--glow)">Войти</button>
</div>
</sc-if>
<sc-if value="{{ scr.app }}" hint-placeholder-val="{{ true }}">
<div style="position:relative;z-index:1;flex:1;min-height:0;display:flex;flex-direction:column">
<sc-if value="{{ offline }}">
<div style="display:flex;align-items:center;gap:10px;padding:10px 16px;background:var(--warn-soft);color:var(--warn);font-size:13px;font-weight:500"><i style="font-size:20px">cloud_off</i><span style="flex:1">Нет сети — играет скачанное. {{ pendingEvents }} прослушиваний отправятся позже</span></div>
</sc-if>
<div ref="{{ scrollRef }}" style="flex:1;min-height:0;overflow-y:auto;overflow-x:hidden">
<sc-if value="{{ pg.home }}" hint-placeholder-val="{{ true }}">
<div style="min-height:100%;display:flex;flex-direction:column;gap:20px;padding:12px 0 24px;box-sizing:border-box">
<div style="display:flex;align-items:center;gap:10px;padding:0 20px;height:48px">
<div style="width:24px;height:24px;border-radius:50%;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8)"></div>
<div style="flex:1;font-family:Unbounded;font-weight:600;font-size:18px;letter-spacing:-0.02em">pipu</div>
</div>
<div style="display:flex;justify-content:center;padding:28px 0 12px">
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ waveIsPlaying }}" bpm="{{ cur.bpm }}" energy="{{ cur.energy }}" color="{{ waveC.c1 }}" color2="{{ waveC.c2 }}" color3="{{ waveC.c3 }}" theme="{{ theme }}" size="{{ 250 }}" onClick="{{ onWave }}" hint-size="250px,250px">
<i style="font-size:48px;color:#fff;font-variation-settings:'FILL' 1">{{ waveIcon }}</i>
<div style="font-family:Unbounded;font-weight:600;font-size:21px;color:#fff;margin-top:8px">Моя волна</div>
<div style="font-size:13px;color:rgba(255,255,255,.8);margin-top:5px">{{ ctxLabel }}</div>
</x-import>
</div>
<div style="display:flex;flex-direction:column;gap:10px;padding-top:16px">
<span style="padding:0 20px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Занятие</span>
<div style="display:flex;gap:8px;overflow-x:auto;padding:0 20px 4px;scrollbar-width:none">
<sc-for list="{{ activities }}" as="c" hint-placeholder-count="7">
<button onClick="{{ c.onClick }}" style="flex-shrink:0;display:flex;align-items:center;gap:8px;height:44px;padding:0 16px 0 12px;border-radius:22px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }}"><i style="font-size:20px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
</sc-for>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<span style="padding:0 20px;font-size:12px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text3)">Настроение</span>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:0 20px">
<sc-for list="{{ moods }}" as="c" hint-placeholder-count="4">
<button onClick="{{ c.onClick }}" style="display:flex;align-items:center;gap:8px;height:48px;padding:0 14px;border-radius:16px;font-size:14px;font-weight:500;background:{{ c.bg }};color:{{ c.color }};border:1px solid {{ c.border }}"><i style="font-size:20px;color:{{ c.ic }}">{{ c.icon }}</i>{{ c.label }}</button>
</sc-for>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.search }}">
<div style="display:flex;flex-direction:column;gap:16px;padding:12px 16px 24px">
<div style="display:flex;align-items:center;gap:12px;height:56px;padding:0 8px 0 18px;border-radius:28px;background:var(--surface2)">
<i style="color:var(--text2)">search</i>
<input value="{{ q }}" onChange="{{ onQ }}" placeholder="Исполнители, альбомы, треки" style="flex:1;min-width:0;height:100%;background:none;border:0;outline:none;font-size:16px">
<sc-if value="{{ hasQ }}"><button onClick="{{ clearQ }}" aria-label="Очистить" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)"><i>close</i></button></sc-if>
</div>
<sc-if value="{{ sEmpty }}">
<div style="display:flex;flex-direction:column;gap:12px;padding:8px 4px">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Недавние запросы</span>
<div style="display:flex;flex-wrap:wrap;gap:8px"><sc-for list="{{ recent }}" as="r"><button onClick="{{ r.onClick }}" style="display:flex;align-items:center;gap:6px;height:40px;padding:0 14px 0 10px;border-radius:20px;border:1px solid var(--line2);font-size:14px"><i style="font-size:18px;color:var(--text3)">history</i>{{ r.label }}</button></sc-for></div>
<span style="font-size:13px;line-height:1.5;color:var(--text3)">Опечатки и неправильная раскладка не помеха: «ршту» найдёт «Кино».</span>
</div>
</sc-if>
<sc-if value="{{ sr.fixed }}"><span style="font-size:13px;color:var(--text2);padding:0 4px">Исправили раскладку: «{{ sr.fixed }}»</span></sc-if>
<sc-if value="{{ sr.hasArtists }}">
<div style="display:flex;gap:16px;overflow-x:auto;padding:4px 4px;scrollbar-width:none">
<sc-for list="{{ sr.artists }}" as="a"><button onClick="{{ a.onClick }}" style="flex-shrink:0;width:96px;display:flex;flex-direction:column;align-items:center;gap:8px"><div style="width:84px;height:84px;border-radius:50%;background:{{ a.img }};display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:22px;color:#fff">{{ a.ini }}</div><span style="font-size:13px;font-weight:500;text-align:center">{{ a.name }}</span></button></sc-for>
</div>
</sc-if>
<sc-if value="{{ sr.hasAlbums }}">
<div style="display:flex;flex-direction:column;gap:4px">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:0 4px 6px">Альбомы</span>
<sc-for list="{{ sr.albums }}" as="a"><button onClick="{{ a.onClick }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:4px;border-radius:12px;text-align:left"><div style="width:56px;height:56px;border-radius:10px;background:{{ a.img }};flex-shrink:0"></div><div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">{{ a.title }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div></button></sc-for>
</div>
</sc-if>
<sc-if value="{{ sr.hasTracks }}">
<div style="display:flex;flex-direction:column">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3);padding:0 4px 6px">Треки</span>
<sc-for list="{{ sr.tracks }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:4px;border-radius:12px;cursor:pointer;opacity:{{ r.op }}">
<div style="width:48px;height:48px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center;flex-shrink:0"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="display:flex;align-items:center;gap:4px;font-size:13px;color:var(--text2)"><sc-if value="{{ r.dl }}"><i style="font-size:15px;color:var(--accent2)">download_done</i></sc-if>{{ r.artist }}</span></div>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ sr.none }}"><div style="padding:40px 0;text-align:center;color:var(--text2);font-size:15px">Ничего не нашлось</div></sc-if>
</div>
</sc-if>
<sc-if value="{{ pg.collection }}">
<div style="display:flex;flex-direction:column;gap:14px;padding:12px 0 24px">
<div style="padding:16px 20px 4px;font-family:Unbounded;font-size:28px;font-weight:600;letter-spacing:-0.02em">Коллекция</div>
<div style="display:flex;gap:8px;overflow-x:auto;padding:0 16px;scrollbar-width:none">
<sc-for list="{{ colTabs }}" as="t" hint-placeholder-count="4">
<button onClick="{{ t.onClick }}" style="flex-shrink:0;display:flex;align-items:center;gap:6px;height:40px;padding:0 16px 0 12px;border-radius:20px;font-size:14px;font-weight:500;background:{{ t.bg }};color:{{ t.color }};border:1px solid {{ t.border }}"><i style="font-size:19px;font-variation-settings:{{ t.fill }}">{{ t.icon }}</i>{{ t.label }}</button>
</sc-for>
</div>
<sc-if value="{{ col.isDl }}">
<div style="display:flex;flex-direction:column;gap:14px;padding:4px 16px">
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:18px;background:var(--surface2)">
<div style="display:flex;align-items:baseline;justify-content:space-between"><span style="font-weight:600;font-size:15px">Занято на устройстве</span><span style="font-size:14px;color:var(--text2)"><span style="font-family:Unbounded;font-weight:600;color:var(--text);font-size:17px">{{ dl.usedStr }}</span> · {{ dl.count }}</span></div>
<div style="height:8px;border-radius:4px;background:var(--line2);overflow:hidden;display:flex"><div style="height:100%;width:{{ dl.pct }};background:var(--accent)"></div></div>
<span style="font-size:12px;color:var(--text3)">Свободно в телефоне: 41,2 ГБ · файлы видны только приложению</span>
</div>
<div style="display:flex;flex-direction:column;border-radius:18px;background:var(--surface2);overflow:hidden">
<div onClick="{{ dl.onAuto }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--line)">
<i style="color:var(--text2)">favorite</i>
<div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Скачивать новые лайки</span><span style="font-size:13px;color:var(--text2)">Сразу после ♥</span></div>
<div style="width:52px;height:32px;border-radius:16px;background:{{ dl.auto.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:4px;left:{{ dl.auto.x }};width:24px;height:24px;border-radius:50%;background:#fff;transition:left .2s"></div></div>
</div>
<button onClick="{{ dl.onAllLikes }}" style="display:flex;align-items:center;gap:14px;min-height:56px;padding:8px 16px;text-align:left"><i style="color:var(--accent2)">download</i><span style="flex:1;font-size:15px;font-weight:500;color:var(--accent2)">Скачать все лайки</span><span style="font-size:13px;color:var(--text3)">{{ dl.likesLeft }}</span></button>
</div>
<sc-if value="{{ dl.hasQueue }}">
<div style="display:flex;flex-direction:column;gap:8px">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Загружается · по Wi-Fi</span>
<sc-for list="{{ dl.queue }}" as="r">
<div style="display:flex;align-items:center;gap:12px">
<div style="width:40px;height:40px;border-radius:8px;background:{{ r.cover }};flex-shrink:0"></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:6px"><span style="font-size:14px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><div style="height:4px;border-radius:2px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ r.w }};background:var(--accent)"></div></div></div>
<span style="width:36px;text-align:right;font-size:12px;color:var(--text3)">{{ r.pctStr }}</span>
</div>
</sc-for>
</div>
</sc-if>
<sc-for list="{{ dl.albums }}" as="a">
<div style="display:flex;flex-direction:column;gap:2px">
<div style="display:flex;align-items:center;gap:12px;padding:8px 0">
<div style="width:44px;height:44px;border-radius:8px;background:{{ a.cover }}"></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-weight:600;font-size:15px">{{ a.title }}</span><span style="font-size:13px;color:var(--text2)">{{ a.sub }}</span></div>
<button onClick="{{ a.onDel }}" aria-label="Удалить альбом" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text3)"><i>delete</i></button>
</div>
<sc-for list="{{ a.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:12px;min-height:48px;padding-left:56px;cursor:pointer">
<div style="flex:1;min-width:0;display:flex;align-items:center;gap:8px"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><span style="font-size:14px;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span></div>
<span style="font-size:12px;color:var(--text3)">{{ r.size }}</span>
<button onClick="{{ r.onDel }}" aria-label="Удалить" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text3)"><i style="font-size:20px">close</i></button>
</div>
</sc-for>
</div>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ col.isList }}">
<div style="display:flex;flex-direction:column;padding:0 12px">
<sc-if value="{{ col.canPlay }}">
<div style="display:flex;gap:8px;padding:4px 4px 10px">
<button onClick="{{ col.onPlay }}" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;height:48px;border-radius:24px;background:var(--accent);color:#fff;font-weight:600;font-size:15px"><i style="font-variation-settings:'FILL' 1">play_arrow</i>Слушать</button>
<button onClick="{{ col.onShuffle }}" style="flex:1;display:flex;align-items:center;justify-content:center;gap:6px;height:48px;border-radius:24px;border:1px solid var(--line2);font-weight:600;font-size:15px"><i>shuffle</i>Перемешать</button>
</div>
</sc-if>
<sc-if value="{{ col.isDis }}"><span style="padding:0 8px 10px;font-size:13px;line-height:1.5;color:var(--text2)">Эти треки не попадают в волну. Снимите дизлайк, если передумали.</span></sc-if>
<sc-for list="{{ col.groups }}" as="g">
<sc-if value="{{ g.day }}"><div style="padding:12px 8px 6px;font-size:14px;font-weight:600;color:var(--text2)">{{ g.day }}</div></sc-if>
<sc-for list="{{ g.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:4px 8px;border-radius:12px;cursor:pointer;opacity:{{ r.op }}">
<div style="width:48px;height:48px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center;flex-shrink:0"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="display:flex;align-items:center;gap:4px;font-size:13px;color:var(--text2);white-space:nowrap;overflow:hidden"><sc-if value="{{ r.dl }}"><i style="font-size:15px;color:var(--accent2)">download_done</i></sc-if>{{ r.artist }}<sc-if value="{{ r.time }}"><span style="color:var(--text3)">· {{ r.time }}</span></sc-if></span></div>
<sc-if value="{{ col.isDis }}"><button onClick="{{ r.onUndo }}" aria-label="Убрать дизлайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center;color:var(--text2)"><i>undo</i></button></sc-if>
<sc-if value="{{ col.notDis }}"><button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button></sc-if>
</div>
</sc-for>
</sc-for>
</div>
</sc-if>
</div>
</sc-if>
<sc-if value="{{ pg.album }}">
<div style="display:flex;flex-direction:column;gap:18px;padding:8px 0 24px;background:{{ alb.tint }}">
<div style="display:flex;align-items:center;height:56px;padding:0 4px"><button onClick="{{ back }}" aria-label="Назад" style="width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i>arrow_back</i></button></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:14px;padding:0 24px;text-align:center">
<div style="width:220px;height:220px;border-radius:18px;background:{{ alb.cover }};box-shadow:0 24px 56px rgba(0,0,0,.5)"></div>
<div style="font-family:Unbounded;font-size:24px;font-weight:600;letter-spacing:-0.02em;text-wrap:balance">{{ alb.title }}</div>
<button onClick="{{ alb.onArtist }}" style="font-size:15px;color:var(--text2)"><span style="color:var(--text);font-weight:600">{{ alb.artist }}</span> · {{ alb.year }} · {{ alb.meta }}</button>
</div>
<div style="display:flex;align-items:center;justify-content:center;gap:16px">
<button onClick="{{ alb.onDl }}" aria-label="Скачать альбом" style="width:52px;height:52px;border-radius:50%;border:1px solid var(--line2);display:flex;align-items:center;justify-content:center;color:{{ alb.dlColor }}"><i>{{ alb.dlIcon }}</i></button>
<button onClick="{{ alb.onPlay }}" aria-label="Слушать" style="width:68px;height:68px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;box-shadow:var(--glow)"><i style="font-size:38px;font-variation-settings:'FILL' 1">{{ alb.playIcon }}</i></button>
<button onClick="{{ alb.onShuffle }}" aria-label="Перемешать" style="width:52px;height:52px;border-radius:50%;border:1px solid var(--line2);display:flex;align-items:center;justify-content:center"><i>shuffle</i></button>
</div>
<span style="text-align:center;font-size:13px;color:var(--text2)">{{ alb.dlLabel }}</span>
<div style="display:flex;flex-direction:column;padding:0 12px">
<sc-for list="{{ alb.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:14px;min-height:56px;padding:4px 8px;border-radius:12px;cursor:pointer;opacity:{{ r.op }}">
<div style="width:24px;display:flex;justify-content:center;font-size:14px;color:var(--text3)"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if><sc-if value="{{ r.notCur }}">{{ r.num }}</sc-if></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="display:flex;align-items:center;gap:4px;font-size:13px;color:var(--text2)"><sc-if value="{{ r.dl }}"><i style="font-size:15px;color:var(--accent2)">download_done</i></sc-if><sc-if value="{{ r.dling }}"><i style="font-size:15px;color:var(--text3);animation:pipuSpin 1s linear infinite">progress_activity</i></sc-if>{{ r.dur }}</span></div>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
</div>
</sc-for>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.artist }}">
<div style="display:flex;flex-direction:column;gap:18px;padding:8px 0 24px;background:{{ art.banner }}">
<div style="display:flex;align-items:center;height:56px;padding:0 4px"><button onClick="{{ back }}" aria-label="Назад" style="width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i>arrow_back</i></button></div>
<div style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:0 24px;text-align:center">
<div style="width:150px;height:150px;border-radius:50%;background:{{ art.img }};box-shadow:0 20px 48px rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;font-family:Unbounded;font-weight:600;font-size:42px;color:#fff">{{ art.ini }}</div>
<div style="font-family:Unbounded;font-size:30px;font-weight:600;letter-spacing:-0.02em">{{ art.name }}</div>
<span style="font-size:14px;color:var(--text2)">{{ art.meta }}</span>
</div>
<div style="display:flex;gap:8px;padding:0 16px">
<button onClick="{{ art.onWave }}" style="flex:1.4;display:flex;align-items:center;justify-content:center;gap:8px;height:48px;border-radius:24px;background:var(--accent);color:#fff;font-weight:600;font-size:15px;box-shadow:var(--glow)"><i style="font-size:22px">graphic_eq</i>Волна по исполнителю</button>
<button onClick="{{ art.onPlay }}" aria-label="Слушать" style="width:48px;height:48px;border-radius:50%;border:1px solid var(--line2);display:flex;align-items:center;justify-content:center"><i style="font-variation-settings:'FILL' 1">play_arrow</i></button>
</div>
<div style="display:flex;flex-direction:column;padding:0 12px">
<span style="padding:6px 8px;font-size:17px;font-weight:600">Популярные треки</span>
<sc-for list="{{ art.rows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:14px;min-height:64px;padding:4px 8px;border-radius:12px;cursor:pointer;opacity:{{ r.op }}">
<div style="width:48px;height:48px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center;flex-shrink:0"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="font-size:13px;color:var(--text2)">{{ r.album }}</span></div>
<button onClick="{{ r.onLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ r.likeColor }};font-variation-settings:{{ r.likeFill }}">favorite</i></button>
</div>
</sc-for>
</div>
<div style="display:flex;flex-direction:column;gap:10px">
<span style="padding:0 20px;font-size:17px;font-weight:600">Альбомы</span>
<div style="display:flex;gap:12px;overflow-x:auto;padding:0 20px;scrollbar-width:none">
<sc-for list="{{ art.albums }}" as="a"><button onClick="{{ a.onClick }}" style="flex-shrink:0;width:140px;display:flex;flex-direction:column;gap:8px;text-align:left"><div style="width:140px;height:140px;border-radius:12px;background:{{ a.img }}"></div><span style="font-size:14px;font-weight:600">{{ a.title }}</span><span style="font-size:12px;color:var(--text2);margin-top:-4px">{{ a.year }}</span></button></sc-for>
</div>
</div>
</div>
</sc-if>
<sc-if value="{{ pg.profile }}">
<div style="display:flex;flex-direction:column;gap:16px;padding:12px 16px 24px">
<div style="display:flex;align-items:center;gap:14px;padding:16px 4px 8px">
<div style="width:60px;height:60px;border-radius:50%;background:linear-gradient(140deg,#2F80FF,#7C5CFF);display:flex;align-items:center;justify-content:center;font-size:20px;font-weight:700;color:#fff">{{ me.ini }}</div>
<div style="flex:1;display:flex;flex-direction:column;gap:3px"><span style="font-weight:600;font-size:18px">{{ me.name }}</span><span style="font-size:14px;color:var(--text2)">{{ me.login }} · {{ me.role }}</span></div>
</div>
<div style="display:flex;flex-direction:column;border-radius:20px;background:var(--surface2);overflow:hidden">
<button onClick="{{ openPw }}" style="display:flex;align-items:center;gap:16px;min-height:60px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">key</i><span style="flex:1;font-size:15px;font-weight:500">Сменить пароль</span><i style="color:var(--text3)">chevron_right</i></button>
<div style="display:flex;align-items:center;gap:16px;min-height:60px;padding:8px 16px"><i style="color:var(--text2)">translate</i><span style="flex:1;font-size:15px;font-weight:500">Язык</span><div style="display:flex;gap:2px;padding:3px;border-radius:12px;background:var(--surface3)"><sc-for list="{{ langs }}" as="l"><button onClick="{{ l.onClick }}" style="height:34px;padding:0 12px;border-radius:9px;font-size:13px;font-weight:600;background:{{ l.bg }};color:{{ l.color }}">{{ l.label }}</button></sc-for></div></div>
</div>
<span style="padding:6px 8px 0;font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Качество звука</span>
<div style="display:flex;flex-direction:column;border-radius:20px;background:var(--surface2);overflow:hidden">
<sc-for list="{{ qualRows }}" as="r" hint-placeholder-count="3">
<button onClick="{{ r.onClick }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">{{ r.icon }}</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">{{ r.label }}</span><span style="font-size:13px;color:var(--accent2)">{{ r.value }}</span></div></button>
</sc-for>
<div onClick="{{ toggleWifiOnly }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;cursor:pointer;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">wifi</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Скачивать только по Wi-Fi</span></div><div style="width:52px;height:32px;border-radius:16px;background:{{ wifiOnly.bg }};position:relative;transition:background .2s"><div style="position:absolute;top:4px;left:{{ wifiOnly.x }};width:24px;height:24px;border-radius:50%;background:#fff;transition:left .2s"></div></div></div>
<div style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px"><i style="color:var(--text2)">cached</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Кэш недавних треков</span><span style="font-size:13px;color:var(--text2)">{{ cacheStr }} из 500 МБ</span></div><button onClick="{{ clearCache }}" style="height:40px;padding:0 14px;border-radius:20px;border:1px solid var(--line2);font-size:13px;font-weight:600">Очистить</button></div>
</div>
<span style="padding:6px 8px 0;font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Приложение</span>
<div style="display:flex;flex-direction:column;border-radius:20px;background:var(--surface2);overflow:hidden">
<button onClick="{{ checkUpdate }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">system_update</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Проверить обновления</span><span style="font-size:13px;color:var(--text2)">Версия {{ appVer }}</span></div><sc-if value="{{ updDot }}"><span style="height:24px;padding:0 9px;border-radius:12px;background:var(--accent);color:#fff;font-size:12px;font-weight:700;display:flex;align-items:center">0.6.2</span></sc-if></button>
<button onClick="{{ toConnect }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">dns</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Сервер</span><span style="font-size:13px;color:var(--text2);font-family:'JetBrains Mono'">{{ srv.addr }}</span></div><i style="color:var(--text3)">chevron_right</i></button>
<sc-if value="{{ isStaff }}"><button onClick="{{ openAdmin }}" style="display:flex;align-items:center;gap:16px;min-height:64px;padding:8px 16px;text-align:left;border-bottom:1px solid var(--line)"><i style="color:var(--text2)">shield_person</i><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">Админка</span><span style="font-size:13px;color:var(--text2)">Откроется в браузере</span></div><i style="color:var(--text3)">open_in_new</i></button></sc-if>
<button onClick="{{ onLogout }}" style="display:flex;align-items:center;gap:16px;min-height:60px;padding:8px 16px;text-align:left;color:var(--danger)"><i>logout</i><span style="font-size:15px;font-weight:500">Выйти</span></button>
</div>
</div>
</sc-if>
</div>
<div onClick="{{ openFull }}" style="margin:0 8px 8px;display:flex;align-items:center;gap:12px;height:62px;padding:0 6px 0 8px;border-radius:16px;background:var(--glass);backdrop-filter:blur(20px);border:1px solid var(--line);position:relative;overflow:hidden;cursor:pointer;flex-shrink:0">
<div style="width:46px;height:46px;border-radius:10px;background:{{ cur.cover }};flex-shrink:0"></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ cur.title }}</span><span style="font-size:12px;color:{{ srcColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ miniSub }}</span></div>
<button onClick="{{ miniLike }}" aria-label="Лайк" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:22px;color:{{ cur.likeColor }};font-variation-settings:{{ cur.likeFill }}">favorite</i></button>
<button onClick="{{ miniPlay }}" aria-label="Пауза" style="width:44px;height:44px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:32px;font-variation-settings:'FILL' 1">{{ playIcon }}</i></button>
<div style="position:absolute;left:8px;right:8px;bottom:0;height:2px;background:var(--line2)"><div style="height:100%;width:{{ pct }};background:var(--accent2)"></div></div>
</div>
<div style="display:grid;grid-template-columns:repeat(4,1fr);height:76px;background:var(--surface);border-top:1px solid var(--line);flex-shrink:0">
<sc-for list="{{ tabs }}" as="t" hint-placeholder-count="4">
<button onClick="{{ t.onClick }}" style="display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px">
<div style="width:60px;height:32px;border-radius:16px;background:{{ t.bg }};display:flex;align-items:center;justify-content:center"><i style="color:{{ t.ic }};font-variation-settings:{{ t.fill }}">{{ t.icon }}</i></div>
<span style="font-size:12px;font-weight:{{ t.w }};color:{{ t.color }}">{{ t.label }}</span>
</button>
</sc-for>
</div>
</div>
<sc-if value="{{ full }}">
<div style="position:absolute;inset:0;z-index:50;display:flex;flex-direction:column;background:var(--bg);animation:pipuUp .28s cubic-bezier(.2,.8,.2,1)">
<div style="position:absolute;inset:0;background:{{ fp.tint }};pointer-events:none"></div>
<div style="position:relative;flex:1;display:flex;flex-direction:column;padding:8px 20px 20px;min-height:0">
<div style="display:flex;align-items:center;gap:8px;height:56px">
<button onClick="{{ closeFull }}" aria-label="Свернуть" style="width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-left:-8px"><i style="font-size:30px">keyboard_arrow_down</i></button>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:1px;min-width:0"><span style="font-size:11px;font-weight:600;letter-spacing:.08em;text-transform:uppercase;color:var(--text2)">{{ fp.srcKind }}</span><span style="font-size:13px;font-weight:600;color:{{ srcColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%">{{ fp.srcName }}</span></div>
<button onClick="{{ openQueue }}" aria-label="Очередь" style="width:48px;height:48px;border-radius:50%;display:flex;align-items:center;justify-content:center;margin-right:-8px"><i>queue_music</i></button>
</div>
<sc-if value="{{ fpCover }}" hint-placeholder-val="{{ true }}">
<div style="flex:1;display:flex;align-items:center;justify-content:center;min-height:0;padding:16px 0">
<div onClick="{{ cur.onAlbum }}" style="width:100%;aspect-ratio:1;max-height:100%;border-radius:22px;background:{{ cur.cover }};box-shadow:0 30px 70px rgba(0,0,0,.5);cursor:pointer"></div>
</div>
</sc-if>
<sc-if value="{{ fpWave }}">
<div style="flex:1;display:flex;align-items:center;justify-content:center;min-height:0">
<x-import component="WaveButton" from="./wave.js" variant="{{ waveVariant }}" playing="{{ playing }}" bpm="{{ cur.bpm }}" energy="{{ cur.energy }}" color="{{ waveC.c1 }}" color2="{{ fp.c1 }}" color3="{{ waveC.c3 }}" theme="{{ theme }}" size="{{ 230 }}" onClick="{{ miniPlay }}" hint-size="230px,230px">
<div style="width:104px;height:104px;border-radius:50%;background:{{ cur.cover }};box-shadow:0 10px 30px rgba(0,0,0,.45);border:2px solid rgba(255,255,255,.25)"></div>
</x-import>
</div>
</sc-if>
<div style="display:flex;align-items:center;gap:12px;padding:8px 0 16px">
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:4px"><span style="font-size:22px;font-weight:700;letter-spacing:-0.01em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ cur.title }}</span><span onClick="{{ cur.onArtist }}" style="font-size:16px;color:var(--text2);cursor:pointer">{{ cur.artist }}</span></div>
<sc-if value="{{ cur.dl }}"><i style="font-size:22px;color:var(--accent2)">download_done</i></sc-if>
</div>
<div onClick="{{ onSeek }}" style="height:24px;display:flex;align-items:center;cursor:pointer">
<div style="position:relative;width:100%;height:4px;border-radius:2px;background:var(--line2)">
<div style="height:100%;width:{{ pct }};border-radius:2px;background:linear-gradient(90deg,var(--accent),#35D3FF)"></div>
<div style="position:absolute;top:50%;left:{{ pct }};width:16px;height:16px;margin:-8px 0 0 -8px;border-radius:50%;background:#fff;box-shadow:0 0 0 4px rgba(47,128,255,.3)"></div>
</div>
</div>
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text2);font-variant-numeric:tabular-nums;margin-top:2px"><span>{{ posStr }}</span><span>{{ durStr }}</span></div>
<div style="display:flex;align-items:center;justify-content:space-between;padding:18px 0 10px">
<button onClick="{{ cur.onDislike }}" aria-label="Дизлайк" style="width:52px;height:52px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:26px;color:{{ cur.disColor }};font-variation-settings:{{ cur.disFill }}">thumb_down</i></button>
<button onClick="{{ prev }}" aria-label="Назад" style="width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:40px;font-variation-settings:'FILL' 1">skip_previous</i></button>
<button onClick="{{ togglePlay }}" aria-label="Пауза" style="width:76px;height:76px;border-radius:50%;background:var(--text);color:var(--bg);display:flex;align-items:center;justify-content:center"><i style="font-size:44px;font-variation-settings:'FILL' 1">{{ playIcon }}</i></button>
<button onClick="{{ next }}" aria-label="Вперёд" style="width:56px;height:56px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:40px;font-variation-settings:'FILL' 1">skip_next</i></button>
<button onClick="{{ cur.onLike }}" aria-label="Лайк" style="width:52px;height:52px;border-radius:50%;display:flex;align-items:center;justify-content:center"><i style="font-size:26px;color:{{ cur.likeColor }};font-variation-settings:{{ cur.likeFill }}">favorite</i></button>
</div>
<div style="display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;color:var(--text3)"><i style="font-size:18px">phone_android</i>{{ fp.output }}</div>
</div>
</div>
</sc-if>
</sc-if>
<sc-if value="{{ sheet.any }}">
<div onClick="{{ closeSheet }}" style="position:absolute;inset:0;z-index:80;background:var(--overlay);display:flex;flex-direction:column;justify-content:flex-end;animation:pipuIn .15s ease-out">
<div onClick="{{ stop }}" style="display:flex;flex-direction:column;gap:16px;max-height:85%;padding:12px 20px 24px;border-radius:28px 28px 0 0;background:var(--elev);box-shadow:var(--shadow);animation:pipuUp .25s cubic-bezier(.2,.8,.2,1);overflow:auto">
<div style="align-self:center;width:36px;height:4px;border-radius:2px;background:var(--line2)"></div>
<sc-if value="{{ sheet.qual }}">
<div style="font-size:20px;font-weight:600">{{ qs.title }}</div>
<div style="display:flex;flex-direction:column">
<sc-for list="{{ qs.opts }}" as="o">
<button onClick="{{ o.onClick }}" style="display:flex;align-items:center;gap:16px;min-height:56px;text-align:left"><div style="width:22px;height:22px;border-radius:50%;border:2px solid {{ o.ring }};display:flex;align-items:center;justify-content:center;box-sizing:border-box"><div style="width:10px;height:10px;border-radius:50%;background:{{ o.dot }}"></div></div><div style="flex:1;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500">{{ o.label }}</span><span style="font-size:13px;color:var(--text2)">{{ o.sub }}</span></div></button>
</sc-for>
</div>
</sc-if>
<sc-if value="{{ sheet.pw }}">
<div style="font-size:20px;font-weight:600">Смена пароля</div>
<input type="password" value="{{ f.cur }}" onChange="{{ fh.cur }}" placeholder="Текущий пароль" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px">
<input type="password" value="{{ f.np }}" onChange="{{ fh.np }}" placeholder="Новый пароль, от 8 символов" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px">
<input type="password" value="{{ f.np2 }}" onChange="{{ fh.np2 }}" placeholder="Ещё раз" style="height:56px;padding:0 16px;border-radius:16px;background:var(--surface2);border:1px solid var(--line2);outline:none;font-size:16px">
<sc-if value="{{ pwErr }}"><span style="font-size:13px;color:var(--danger)">{{ pwErr }}</span></sc-if>
<button onClick="{{ onPw }}" style="height:52px;border-radius:26px;background:var(--accent);color:#fff;font-weight:600;font-size:15px">Сохранить</button>
</sc-if>
<sc-if value="{{ sheet.queue }}">
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-size:20px;font-weight:600">Очередь</span><span style="font-size:13px;color:var(--text2)">{{ srcLabel }}</span></div>
<div style="display:flex;flex-direction:column">
<sc-for list="{{ queueRows }}" as="r">
<div onClick="{{ r.onPlay }}" style="display:flex;align-items:center;gap:12px;min-height:56px;padding:4px;border-radius:12px;cursor:pointer;background:{{ r.bg }}">
<div style="width:44px;height:44px;border-radius:8px;background:{{ r.cover }};display:flex;align-items:center;justify-content:center;flex-shrink:0"><sc-if value="{{ r.isCur }}">{{ r.eq }}</sc-if></div>
<div style="flex:1;min-width:0;display:flex;flex-direction:column;gap:2px"><span style="font-size:15px;font-weight:500;color:{{ r.titleColor }};white-space:nowrap;overflow:hidden;text-overflow:ellipsis">{{ r.title }}</span><span style="font-size:13px;color:var(--text2)">{{ r.artist }}</span></div>
<span style="font-size:12px;color:var(--text3)">{{ r.dur }}</span>
</div>
</sc-for>
</div>
<sc-if value="{{ waveOn }}"><div style="display:flex;align-items:center;gap:10px;padding:12px;border-radius:14px;background:var(--accent-soft);font-size:13px;line-height:1.45;color:var(--text2)"><i style="font-size:20px;color:var(--accent2)">all_inclusive</i>Волна бесконечна — дальше подберётся по ходу.</div></sc-if>
</sc-if>
<sc-if value="{{ sheet.update }}">
<div style="display:flex;align-items:center;gap:14px">
<div style="width:52px;height:52px;border-radius:16px;background:radial-gradient(circle at 35% 30%, #9CC6FF, #2F80FF 55%, #3A1FA8);display:flex;align-items:center;justify-content:center"><i style="color:#fff">system_update</i></div>
<div style="display:flex;flex-direction:column;gap:2px"><span style="font-size:20px;font-weight:600">{{ up.title }}</span><span style="font-size:13px;color:var(--text2)">{{ up.sub }}</span></div>
</div>
<sc-if value="{{ up.isAvail }}">
<div style="display:flex;flex-direction:column;gap:10px;padding:16px;border-radius:16px;background:var(--surface2)">
<span style="font-size:13px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;color:var(--text3)">Что нового</span>
<sc-for list="{{ up.notes }}" as="n"><div style="display:flex;gap:10px;font-size:14px;line-height:1.45"><span style="color:var(--accent2)">•</span><span>{{ n }}</span></div></sc-for>
</div>
<div style="display:flex;gap:10px"><button onClick="{{ closeSheet }}" style="flex:1;height:52px;border-radius:26px;border:1px solid var(--line2);font-weight:600;font-size:15px">Позже</button><button onClick="{{ up.onUpdate }}" style="flex:1;height:52px;border-radius:26px;background:var(--accent);color:#fff;font-weight:600;font-size:15px">Обновить</button></div>
</sc-if>
<sc-if value="{{ up.isPerm }}">
<div style="display:flex;gap:12px;padding:16px;border-radius:16px;background:var(--warn-soft);font-size:14px;line-height:1.5"><i style="color:var(--warn)">shield</i><span>Приложение ставится не из Google Play. Android один раз попросит разрешить установку из этого источника.</span></div>
<button onClick="{{ up.onPerm }}" style="height:52px;border-radius:26px;background:var(--accent);color:#fff;font-weight:600;font-size:15px">Открыть настройки и продолжить</button>
</sc-if>
<sc-if value="{{ up.isDl }}">
<div style="display:flex;flex-direction:column;gap:10px">
<div style="height:8px;border-radius:4px;background:var(--line2);overflow:hidden"><div style="height:100%;width:{{ up.w }};background:linear-gradient(90deg,var(--accent),#35D3FF)"></div></div>
<div style="display:flex;justify-content:space-between;font-size:13px;color:var(--text2)"><span>{{ up.dlStr }}</span><span>{{ up.pctStr }}</span></div>
</div>
</sc-if>
<sc-if value="{{ up.isVerify }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--surface2);font-size:14px"><i style="animation:pipuSpin 1s linear infinite;color:var(--accent2)">progress_activity</i>Проверяем контрольную сумму SHA-256…</div>
</sc-if>
<sc-if value="{{ up.isReady }}">
<div style="display:flex;align-items:center;gap:12px;padding:14px 16px;border-radius:14px;background:var(--ok-soft);font-size:14px"><i style="color:var(--ok)">verified</i><div style="display:flex;flex-direction:column;gap:2px"><span style="font-weight:600">Подпись и SHA-256 совпали</span><span style="font-family:'JetBrains Mono';font-size:11px;color:var(--text2)">sha256 9f2c…e41a</span></div></div>
<button onClick="{{ up.onInstall }}" style="height:52px;border-radius:26px;background:var(--accent);color:#fff;font-weight:600;font-size:15px">Установить</button>
</sc-if>
</sc-if>
</div>
</div>
</sc-if>
<sc-if value="{{ toast }}">
<div style="position:absolute;left:12px;right:12px;bottom:{{ toastBottom }};z-index:90;display:flex;align-items:center;gap:10px;padding:14px 16px;border-radius:12px;background:var(--text);color:var(--bg);font-size:14px;font-weight:500;box-shadow:var(--shadow);animation:pipuIn .18s ease-out">{{ toast }}</div>
</sc-if>
</div>
</x-import>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props="{&quot;theme&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;dark&quot;,&quot;light&quot;],&quot;default&quot;:&quot;dark&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Вид&quot;},&quot;waveVariant&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;orb&quot;,&quot;rings&quot;,&quot;liquid&quot;],&quot;default&quot;:&quot;orb&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Вид&quot;},&quot;playerLayout&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;cover&quot;,&quot;wave&quot;],&quot;default&quot;:&quot;cover&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Варианты&quot;},&quot;ambient&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:true,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Вид&quot;},&quot;offline&quot;:{&quot;editor&quot;:&quot;boolean&quot;,&quot;default&quot;:false,&quot;tsType&quot;:&quot;boolean&quot;,&quot;section&quot;:&quot;Прототип&quot;},&quot;role&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;user&quot;,&quot;moderator&quot;,&quot;admin&quot;],&quot;default&quot;:&quot;user&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Прототип&quot;},&quot;startScreen&quot;:{&quot;editor&quot;:&quot;enum&quot;,&quot;options&quot;:[&quot;app&quot;,&quot;connect&quot;],&quot;default&quot;:&quot;app&quot;,&quot;tsType&quot;:&quot;string&quot;,&quot;section&quot;:&quot;Прототип&quot;}}">
const F1 = "'FILL' 1", F0 = "'FILL' 0";
const QOPT = [['low', 'Низкое', '96 кбит/с · экономит трафик'], ['normal', 'Нормальное', '160 кбит/с Opus'], ['high', 'Высокое', '320 кбит/с Opus'], ['orig', 'Оригинал', 'Без перекодирования, FLAC — как в библиотеке']];
const QROWS = [['wifi', 'По Wi-Fi', 'wifi'], ['cell', 'В мобильной сети', 'signal_cellular_alt'], ['dl', 'Для загрузок', 'download']];
const NOTES = {
connect: 'Адрес сервера вводится один раз; можно хранить несколько профилей серверов. «Проверить» обращается к /server-info. Попробуйте адрес со словом bad — увидите ошибку.',
login: 'Без регистрации. Язык переключается прямо здесь. Любой логин и пароль от 4 символов — вход.',
home: 'Нажмите волну — заиграет, пульс в такт BPM трека. Занятие и настроение меняют поток без остановки музыки.',
search: 'Ищет на лету по исполнителям, альбомам и трекам; прощает опечатки и раскладку.',
collection: 'История по дням, лайки, дизлайки (с отменой) и скачанное.',
downloads: 'Занятое место, авто-скачивание лайков, очередь загрузок (2 параллельно, только Wi-Fi), удаление по треку или альбомом.',
album: 'Кнопка со стрелкой скачивает альбом для офлайна — прогресс виден по каждому треку.',
artist: '«Волна по исполнителю» — поток, построенный вокруг артиста и похожих.',
profile: 'Качество для Wi-Fi, мобильной сети и загрузок раздельно, кэш, обновления, сервер. Админка — только ссылкой в браузер.',
player: 'Свайп/тап по мини-плееру. Два варианта полноэкранного плеера — переключаются в Tweaks (playerLayout).',
update: 'Самообновление через сервер Pipu: changelog → разрешение на установку → загрузка APK → проверка SHA-256 → установка.',
offline: 'Без сети играет только скачанное, волна строится из локальных треков, прослушивания копятся и уйдут на сервер позже.'
};
class Component extends DCLogic {
state = { ready: false };
scrollRef = React.createRef();
_fh = {};
componentDidMount() {
const wait = () => (window.PIPU ? this.init() : setTimeout(wait, 40)); wait();
this.iv = setInterval(() => this.tick(), 250);
}
componentWillUnmount() { clearInterval(this.iv); }
init() {
const D = window.PIPU;
let sv = null; try { sv = JSON.parse(localStorage.getItem('pipu-android-player') || 'null'); } catch (e) {}
const ok = sv && D.TR[sv.trackId];
const alb = D.ALB['ma-mz'].tracks;
const hist = []; let k = 5;
[['Сегодня', 5, 9], ['Вчера', 6, 21]].forEach(([d, n, h]) => { let m = 50; for (let j = 0; j < n; j++) { k = (k * 7 + 3) % D.ALL.length; hist.push({ id: D.ALL[k], day: d, time: String(h).padStart(2, '0') + ':' + String(m).padStart(2, '0') }); m -= 12; if (m < 0) { m += 60; h--; } } });
this.setState({
ready: true, auth: this.props.startScreen === 'connect' ? 'connect' : null,
tab: 'home', sub: null, colTab: 'history', full: false, sheet: null,
queue: ok && sv.queue ? sv.queue.filter(id => D.TR[id]) : alb, qi: ok ? sv.qi || 0 : 2, trackId: ok ? sv.trackId : alb[2], pos: ok ? sv.pos || 0 : 64, playing: false,
source: (ok && sv.source) || { label: 'Альбом · Mezzanine' }, waveOn: ok ? !!sv.waveOn : false, activity: null, mood: null,
likes: ['rh-ir-6', 'ma-mz-2', 'kino-gk-0', 'mod-2-1', 'bon-mg-2', 'dp-ram-4', 'zem-pm-0', 'kino-zs-4', 'rh-ir-3'], dislikes: ['dp-ram-1', 'spl-ga-3'], history: hist,
downloads: [...D.ALB['rh-ir'].tracks, ...D.ALB['ma-mz'].tracks, 'kino-gk-0', 'kino-zs-4', 'bon-mg-2', 'mod-2-1'], dlQueue: [], autoLikes: true, wifiOnly: true,
qual: { wifi: 'high', cell: 'normal', dl: 'high' }, cache: 312,
srvAddr: 'music.home.lan', srvState: 'ok', f: {}, loginErr: '', pwErr: '', lang: 'ru', q: '',
upd: 'available', updPct: 0, updSeen: false, offline: null, pending: 3, toast: null
});
}
tick() {
const s = this.state, D = window.PIPU; if (!s.ready) return;
const u = {};
if (s.playing && !s.auth) { const t = D.TR[s.trackId]; const np = s.pos + 0.25; if (np >= t.dur) { this.next(); return; } u.pos = np; }
if (s.dlQueue.length) {
const q = s.dlQueue.map((x, i) => i < 2 ? { ...x, p: x.p + 6 + Math.random() * 10 } : x);
const done = q.filter(x => x.p >= 100).map(x => x.id);
u.dlQueue = q.filter(x => x.p < 100);
if (done.length) u.downloads = [...s.downloads, ...done.filter(id => !s.downloads.includes(id))];
}
if (s.upd === 'downloading') { const p = s.updPct + 3 + Math.random() * 4; if (p >= 100) { u.updPct = 100; u.upd = 'verify'; setTimeout(() => this.setState({ upd: 'ready' }), 1200); } else u.updPct = p; }
if (Object.keys(u).length) this.setState(u);
this._t = (this._t || 0) + 1;
if (this._t % 4 === 0) { try { localStorage.setItem('pipu-android-player', JSON.stringify({ trackId: s.trackId, pos: s.pos, queue: s.queue.slice(0, 60), qi: s.qi, source: s.source, waveOn: s.waveOn })); } catch (e) {} }
}
isOff() { return this.state.offline ?? !!this.props.offline; }
toastMsg(m) { clearTimeout(this._tt); this.setState({ toast: m }); this._tt = setTimeout(() => this.setState({ toast: null }), 2600); }
fh(k) { return this._fh[k] || (this._fh[k] = e => this.setState(s => ({ f: { ...s.f, [k]: e.target.value } }))); }
goTab(tab) { this.setState({ tab, sub: null, full: false }); if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0; }
goSub(sub) { this.setState({ sub, full: false }); if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0; }
canPlay(id) { if (this.isOff() && !this.state.downloads.includes(id)) { this.toastMsg('Нет сети — этот трек не скачан'); return false; } return true; }
addHist(h, id) { if (h[0] && h[0].id === id) return h; const d = new Date(); return [{ id, day: 'Сегодня', time: String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0') }, ...h]; }
setTrack(qi, queue, extra = {}) { const id = queue[qi]; this.setState(s => ({ queue, qi, trackId: id, pos: 0, playing: true, history: this.addHist(s.history, id), pending: this.isOff() ? s.pending + 1 : s.pending, ...extra })); }
playList(list, i, label) {
if (this.isOff()) { const dl = this.state.downloads; const id = list[i]; if (!this.canPlay(id)) return; list = list.filter(x => dl.includes(x)); i = list.indexOf(id); }
this.setTrack(i, list, { waveOn: false, source: { label } });
}
wopts(extra = {}) { const s = this.state; return { activity: s.activity, mood: s.mood, dislikes: s.dislikes, likes: s.likes, ...extra }; }
batch(n, extra) { const D = window.PIPU; let b = D.waveBatch(this.isOff() ? 200 : n, this.wopts(extra)); if (this.isOff()) b = b.filter(id => this.state.downloads.includes(id)).slice(0, n); return b; }
startWave(artistId) { const q = this.batch(12, { artistId }); if (!q.length) return this.toastMsg('Нет скачанных треков для офлайн-волны'); this.setTrack(0, q, { waveOn: true, source: { artistId: artistId || null } }); }
next() {
const s = this.state; let q = s.queue, i = s.qi + 1;
if (s.waveOn && i >= q.length - 2) q = [...q, ...this.batch(8, { recent: q.slice(-20), artistId: s.source.artistId })];
if (i >= q.length) { this.setState({ playing: false, pos: 0 }); return; }
this.setTrack(i, q);
}
prev() { const s = this.state; if (s.pos > 3 || s.qi === 0) return this.setState({ pos: 0 }); this.setTrack(s.qi - 1, s.queue); }
togglePlay() { this.setState(s => ({ playing: !s.playing })); }
setCtx(k, v) {
this.setState(s => ({ [k]: s[k] === v ? null : v }), () => {
const s = this.state; if (!s.waveOn) return;
const keep = s.queue.slice(0, s.qi + 1);
this.setState({ queue: [...keep, ...this.batch(10, { recent: keep.slice(-20) })] });
});
}
toggleLike(id) {
const s = this.state, on = s.likes.includes(id);
this.setState({ likes: on ? s.likes.filter(x => x !== id) : [id, ...s.likes], dislikes: s.dislikes.filter(x => x !== id) });
if (!on && s.autoLikes && !s.downloads.includes(id) && !this.isOff()) { this.setState(st => ({ dlQueue: [...st.dlQueue, { id, p: 0 }] })); this.toastMsg('Лайк · трек скачивается для офлайна'); }
}
toggleDislike(id) {
const s = this.state, was = s.dislikes.includes(id);
this.setState({ dislikes: was ? s.dislikes.filter(x => x !== id) : [id, ...s.dislikes], likes: s.likes.filter(x => x !== id) });
if (!was && id === s.trackId && s.waveOn) { setTimeout(() => this.next(), 0); this.toastMsg('Больше не попадётся в волне'); }
else this.toastMsg(was ? 'Дизлайк снят' : 'Трек скрыт из рекомендаций');
}
jump(k) {
const D = window.PIPU, base = { full: false, sheet: null, offline: null, sub: null };
const m = {
connect: { auth: 'connect', srvState: null }, login: { auth: 'login' },
home: { auth: null, tab: 'home' }, search: { auth: null, tab: 'search', q: 'кино' }, collection: { auth: null, tab: 'collection', colTab: 'history' },
downloads: { auth: null, tab: 'collection', colTab: 'downloads' }, album: { auth: null, tab: 'search', sub: { type: 'album', id: 'mod-2' } },
artist: { auth: null, tab: 'search', sub: { type: 'artist', id: 'radiohead' } }, profile: { auth: null, tab: 'profile' },
player: { auth: null, tab: 'home', full: true }, update: { auth: null, tab: 'profile', sheet: 'update', upd: 'available', updPct: 0 },
offline: { auth: null, tab: 'home', offline: true }
}[k];
this.setState({ ...base, ...m, jumpKey: k });
if (k === 'player' && !this.state.playing) this.setState({ playing: true });
if (this.scrollRef.current) this.scrollRef.current.scrollTop = 0;
}
ambient(c3) {
const blob = (k, bg, size, anim, pos) => React.createElement('div', { key: k, style: { position: 'absolute', ...pos, width: size, height: size, borderRadius: '50%', background: `radial-gradient(circle, ${bg} 0%, transparent 62%)`, filter: 'blur(24px)', animation: anim } });
return React.createElement('div', { 'aria-hidden': true, style: { position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none', opacity: 'var(--amb-op)', zIndex: 0, animation: 'pipuHue 30s linear infinite' } }, [
blob('a', 'rgba(47,128,255,.6)', '560px', 'pipuDrift1 16s ease-in-out infinite alternate', { left: '-260px', top: '-240px' }),
blob('b', 'rgba(124,92,255,.45)', '480px', 'pipuDrift2 21s ease-in-out infinite alternate', { right: '-240px', top: '200px' }),
blob('c', c3 + '88', '460px', 'pipuDrift3 26s ease-in-out infinite alternate', { left: '-60px', bottom: '-280px' })
]);
}
renderVals() {
const D = window.PIPU, s = this.state, P = this.props;
const theme = P.theme || 'dark';
const base = { theme, isDark: theme === 'dark', deviceBg: theme === 'dark' ? '#05070D' : '#F3F5FA', waveVariant: P.waveVariant || 'orb' };
const jk = s.jumpKey || (s.auth || (s.full ? 'player' : s.sub ? s.sub.type : s.tab === 'collection' && s.colTab === 'downloads' ? 'downloads' : s.tab));
const JL = [['connect', 'Подключение к серверу'], ['login', 'Вход'], ['home', 'Главная · Моя волна'], ['search', 'Поиск'], ['collection', 'Коллекция'], ['downloads', 'Скачанное'], ['album', 'Альбом'], ['artist', 'Исполнитель'], ['profile', 'Профиль'], ['player', 'Полноэкранный плеер'], ['update', 'Обновление приложения'], ['offline', 'Без сети']];
base.jumps = JL.map(([k, l], i) => ({ n: String(i + 1).padStart(2, '0'), label: l, onClick: () => this.jump(k), bg: jk === k ? 'rgba(47,128,255,.16)' : 'transparent', color: jk === k ? '#EDF1FF' : '#A0ABC6' }));
base.note = NOTES[jk] || '';
if (!s.ready || !D) return { ...base, scr: {}, ambient: null };
const { ART, ALB, TR, fmt, plural } = D;
const off = this.isOff();
const t = TR[s.trackId], al = ALB[t.albumId], ar = ART[t.artistId];
base.ambient = P.ambient === false ? null : this.ambient(s.playing ? al.c1 : '#35D3FF');
const role = P.role || 'user';
const me = role === 'admin' ? { name: 'Алексей Морозов', login: 'admin', ini: 'АМ', role: 'Администратор' } : role === 'moderator' ? { name: 'Илья Сафонов', login: 'ilya', ini: 'ИС', role: 'Модератор' } : { name: 'Анна Белова', login: 'anna', ini: 'АБ', role: 'Пользователь' };
const eq = React.createElement('div', { style: { display: 'flex', gap: 2, alignItems: 'flex-end', height: 14 } }, [0, 1, 2].map(i => React.createElement('span', { key: i, style: { width: 3, height: 14, borderRadius: 2, background: '#fff', boxShadow: '0 0 4px rgba(0,0,0,.4)', transformOrigin: 'bottom', animation: `pipuEq ${0.55 + i * 0.17}s ease-in-out ${i * 0.1}s infinite alternate`, animationPlayState: s.playing ? 'running' : 'paused' } })));
const eqA = React.createElement('div', { style: { display: 'flex', gap: 2, alignItems: 'flex-end', height: 14 } }, [0, 1, 2].map(i => React.createElement('span', { key: i, style: { width: 3, height: 14, borderRadius: 2, background: 'var(--accent2)', transformOrigin: 'bottom', animation: `pipuEq ${0.55 + i * 0.17}s ease-in-out ${i * 0.1}s infinite alternate`, animationPlayState: s.playing ? 'running' : 'paused' } })));
const row = (id, i, list, label, extra = {}) => {
const tr = TR[id], isCur = s.trackId === id, liked = s.likes.includes(id), dl = s.downloads.includes(id);
return { id, num: i + 1, title: tr.title, artist: ART[tr.artistId].name, album: ALB[tr.albumId].title, dur: fmt(tr.dur), cover: ALB[tr.albumId].cover, size: (tr.dur * 0.04).toFixed(1).replace('.', ',') + ' МБ',
isCur, notCur: !isCur, eq: extra.eqA ? eqA : eq, dl, dling: s.dlQueue.some(x => x.id === id), time: extra.time || '', op: off && !dl ? 0.38 : (extra.op ?? 1),
titleColor: isCur ? 'var(--accent2)' : 'var(--text)', likeColor: liked ? 'var(--accent2)' : 'var(--text3)', likeFill: liked ? F1 : F0, bg: isCur ? 'var(--accent-soft)' : 'transparent',
onPlay: extra.onPlay || (() => this.playList(list, i, label)),
onLike: e => { e.stopPropagation(); this.toggleLike(id); }, onUndo: e => { e.stopPropagation(); this.toggleDislike(id); },
onDel: e => { e.stopPropagation(); this.setState(st => ({ downloads: st.downloads.filter(x => x !== id) })); this.toastMsg('Удалено с устройства'); } };
};
const seg = (on, label, onClick) => ({ label, onClick, bg: on ? 'var(--accent)' : 'transparent', color: on ? '#fff' : 'var(--text2)' });
const fh = {}; ['login', 'pass', 'cur', 'np', 'np2'].forEach(k => { fh[k] = this.fh(k); });
const srv = {
addr: s.srvAddr, onAddr: e => this.setState({ srvAddr: e.target.value, srvState: null }),
checking: s.srvState === 'checking', ok: s.srvState === 'ok', err: s.srvState === 'err', border: s.srvState === 'err' ? 'var(--danger)' : s.srvState === 'ok' ? 'var(--ok)' : 'var(--line2)',
onCheck: () => { this.setState({ srvState: 'checking' }); setTimeout(() => this.setState(st => ({ srvState: /bad|^\s*$/.test(st.srvAddr) ? 'err' : 'ok' })), 900); },
onNext: () => { if (s.srvState === 'ok') this.setState({ auth: 'login', jumpKey: null }); else this.toastMsg('Сначала проверьте сервер'); },
nextBg: s.srvState === 'ok' ? 'var(--accent)' : 'var(--surface3)', nextColor: s.srvState === 'ok' ? '#fff' : 'var(--text3)',
profiles: [['Дом', 'music.home.lan', 'home'], ['Дача', '10.8.0.2:8443', 'cottage']].map(([n, a, ic]) => ({ name: n, addr: a, icon: ic, bg: s.srvAddr === a ? 'var(--accent-soft)' : 'var(--surface2)', border: s.srvAddr === a ? 'var(--accent)' : 'transparent', onClick: () => this.setState({ srvAddr: a, srvState: null }) }))
};
const common = { ...base, f: s.f, fh, srv, langs: [seg(s.lang === 'ru', 'RU', () => this.setState({ lang: 'ru' })), seg(s.lang === 'en', 'EN', () => this.setState({ lang: 'en' }))], toast: s.toast, stop: e => e.stopPropagation(),
toConnect: () => this.setState({ auth: 'connect', srvState: null, jumpKey: null }), onLogout: () => this.setState({ auth: 'login', playing: false, jumpKey: null }) };
const sheet = { any: !!s.sheet, qual: !!s.sheet && s.sheet.startsWith('q-'), pw: s.sheet === 'pw', queue: s.sheet === 'queue', update: s.sheet === 'update' };
const out = { ...common, sheet, closeSheet: () => this.setState({ sheet: null, pwErr: '' }) };
if (s.auth) {
return { ...out, scr: { connect: s.auth === 'connect', login: s.auth === 'login' }, loginErr: s.loginErr, toastBottom: '24px',
onLogin: () => { const l = (s.f.login || '').trim(), p = s.f.pass || ''; if (!l || p.length < 4) return this.setState({ loginErr: !l || !p ? 'Введите логин и пароль' : 'Неверный логин или пароль' }); this.setState({ auth: null, loginErr: '', f: {}, tab: 'home', jumpKey: null }); } };
}
const page = s.sub ? s.sub.type : s.tab;
const actLbl = s.activity && D.ACT.find(a => a[0] === s.activity)[1], moodRec = s.mood && D.MOOD.find(m => m[0] === s.mood);
const ctxLabel = [actLbl, moodRec && moodRec[1]].filter(Boolean).join(' · ') || (off ? 'Из скачанного' : 'Под ваш вкус');
const waveIsPlaying = s.waveOn && s.playing;
const wc = moodRec ? { c1: moodRec[4], c2: moodRec[5], c3: moodRec[6] } : { c1: '#2F80FF', c2: '#7C5CFF', c3: '#35D3FF' };
if (waveIsPlaying) wc.c2 = al.c1;
const chip = (on, icon, label, onClick) => ({ label, icon, onClick, bg: on ? 'var(--accent)' : 'var(--surface2)', color: on ? '#fff' : 'var(--text)', border: on ? 'var(--accent)' : 'var(--line)', ic: on ? '#fff' : 'var(--text2)' });
const srcLabel = s.waveOn ? (s.source.artistId ? 'Волна по исполнителю · ' + ART[s.source.artistId].name : 'Моя волна · ' + ctxLabel) : s.source.label;
const liked = s.likes.includes(t.id), dis = s.dislikes.includes(t.id);
const tabDef = [['home', 'Главная', 'home'], ['search', 'Поиск', 'search'], ['collection', 'Коллекция', 'library_music'], ['profile', 'Профиль', 'person']];
Object.assign(out, {
scr: { app: true }, me, offline: off, pendingEvents: s.pending, isStaff: role !== 'user',
pg: { [page]: true }, back: () => this.setState({ sub: null }),
tabs: tabDef.map(([k, l, ic]) => { const on = s.tab === k; return { label: l, icon: ic, onClick: () => this.goTab(k), bg: on ? 'var(--accent-soft)' : 'transparent', ic: on ? 'var(--accent2)' : 'var(--text2)', fill: on ? F1 : F0, color: on ? 'var(--text)' : 'var(--text2)', w: on ? '600' : '500' }; }),
cur: { title: t.title, artist: ar.name, cover: al.cover, bpm: t.bpm, energy: t.energy, dl: s.downloads.includes(t.id), likeColor: liked ? 'var(--accent2)' : 'var(--text2)', likeFill: liked ? F1 : F0, disColor: dis ? 'var(--danger)' : 'var(--text2)', disFill: dis ? F1 : F0,
onLike: () => this.toggleLike(t.id), onDislike: () => this.toggleDislike(t.id), onArtist: () => this.goSub({ type: 'artist', id: ar.id }), onAlbum: () => this.goSub({ type: 'album', id: al.id }) },
playing: s.playing, waveOn: s.waveOn, waveIsPlaying, waveC: wc, waveIcon: waveIsPlaying ? 'pause' : 'play_arrow', ctxLabel,
onWave: () => { if (s.waveOn && !s.source.artistId) this.togglePlay(); else this.startWave(); },
activities: D.ACT.map(a => chip(s.activity === a[0], a[2], a[1], () => this.setCtx('activity', a[0]))),
moods: D.MOOD.map(m => chip(s.mood === m[0], m[2], m[1], () => this.setCtx('mood', m[0]))),
playIcon: s.playing ? 'pause' : 'play_arrow', togglePlay: () => this.togglePlay(), next: () => this.next(), prev: () => this.prev(),
miniPlay: e => { e && e.stopPropagation && e.stopPropagation(); this.togglePlay(); }, miniLike: e => { e.stopPropagation(); this.toggleLike(t.id); },
miniSub: s.waveOn ? srcLabel : ar.name, srcLabel, srcColor: s.waveOn ? 'var(--accent2)' : 'var(--text2)',
posStr: fmt(s.pos), durStr: fmt(t.dur), pct: (s.pos / t.dur * 100).toFixed(2) + '%',
onSeek: e => { const b = e.currentTarget.getBoundingClientRect(); this.setState({ pos: D.clamp((e.clientX - b.left) / b.width, 0, 0.999) * t.dur }); },
full: s.full, openFull: () => this.setState({ full: true, jumpKey: null }), closeFull: () => this.setState({ full: false, jumpKey: null }),
fpCover: (P.playerLayout || 'cover') === 'cover', fpWave: P.playerLayout === 'wave',
fp: { tint: `linear-gradient(180deg, ${al.c1}55 0%, ${al.c2}44 45%, transparent 85%)`, c1: al.c1, srcKind: s.waveOn ? 'Играет волна' : 'Играет', srcName: s.waveOn ? ctxLabel : s.source.label, output: off || s.downloads.includes(t.id) ? 'Динамик телефона · из памяти' : 'Динамик телефона · ' + QOPT.find(q => q[0] === s.qual.wifi)[1].toLowerCase() + ' качество' },
openQueue: () => this.setState({ sheet: 'queue' }),
queueRows: s.queue.slice(s.qi, s.qi + 30).map((id, k) => row(id, k, s.queue, '', { onPlay: () => { this.setTrack(s.qi + k, s.queue); this.setState({ sheet: null }); } })),
toastBottom: s.full ? '40px' : '160px'
});
if (page === 'search') {
const q = s.q.trim(), r = q ? D.search(q) : null;
Object.assign(out, { q: s.q, hasQ: !!s.q, onQ: e => this.setState({ q: e.target.value }), clearQ: () => this.setState({ q: '' }), sEmpty: !q,
recent: ['Кино', 'Moderat', 'Teardrop', 'ршту'].map(l => ({ label: l, onClick: () => this.setState({ q: l }) })),
sr: r ? { fixed: r.fixed, hasArtists: r.artists.length > 0, hasAlbums: r.albums.length > 0, hasTracks: r.tracks.length > 0, none: !r.artists.length && !r.albums.length && !r.tracks.length,
artists: r.artists.map(a => ({ name: a.name, img: a.avatar, ini: a.name.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase(), onClick: () => this.goSub({ type: 'artist', id: a.id }) })),
albums: r.albums.slice(0, 4).map(a => ({ title: a.title, sub: 'Альбом · ' + ART[a.artistId].name + ' · ' + a.year, img: a.cover, onClick: () => this.goSub({ type: 'album', id: a.id }) })),
tracks: r.tracks.slice(0, 10).map((id, i) => row(id, i, r.tracks, 'Поиск · ' + q)) } : {} });
}
if (page === 'collection') {
const ct = s.colTab;
out.colTabs = [['history', 'История', 'history'], ['likes', 'Лайки', 'favorite'], ['dislikes', 'Дизлайки', 'heart_broken'], ['downloads', 'Скачанное', 'download_done']].map(([k, l, ic]) => ({ label: l, icon: ic, onClick: () => this.setState({ colTab: k, jumpKey: null }), bg: ct === k ? 'var(--accent)' : 'transparent', color: ct === k ? '#fff' : 'var(--text)', border: ct === k ? 'var(--accent)' : 'var(--line2)', fill: ct === k ? F1 : F0 }));
let groups = [];
if (ct === 'history') { const list = s.history.map(h => h.id); s.history.forEach((h, i) => { let g = groups.find(x => x.day === h.day); if (!g) { g = { day: h.day, rows: [] }; groups.push(g); } g.rows.push(row(h.id, i, list, 'История', { time: h.time })); }); }
if (ct === 'likes') groups = [{ day: '', rows: s.likes.map((id, i) => row(id, i, s.likes, 'Лайки')) }];
if (ct === 'dislikes') groups = [{ day: '', rows: s.dislikes.map((id, i) => row(id, i, s.dislikes, 'Дизлайки', { op: 0.6 })) }];
out.col = { isDl: ct === 'downloads', isList: ct !== 'downloads', isDis: ct === 'dislikes', notDis: ct !== 'dislikes', canPlay: ct === 'likes' && s.likes.length > 0, groups,
onPlay: () => this.playList(s.likes, 0, 'Лайки'), onShuffle: () => this.playList([...s.likes].sort(() => Math.random() - 0.5), 0, 'Лайки · вперемешку') };
if (ct === 'downloads') {
const mb = s.downloads.reduce((a, id) => a + TR[id].dur * 0.04, 0);
const byAlb = []; s.downloads.forEach(id => { const a = TR[id].albumId; let g = byAlb.find(x => x.id === a); if (!g) { g = { id: a, ids: [] }; byAlb.push(g); } g.ids.push(id); });
const left = s.likes.filter(id => !s.downloads.includes(id) && !s.dlQueue.some(x => x.id === id));
out.dl = {
usedStr: mb >= 1024 ? (mb / 1024).toFixed(1).replace('.', ',') + ' ГБ' : Math.round(mb) + ' МБ', count: s.downloads.length + ' ' + plural(s.downloads.length, ['трек', 'трека', 'треков']), pct: Math.min(100, mb / 4096 * 100) + '%',
auto: { bg: s.autoLikes ? 'var(--accent)' : 'var(--line2)', x: s.autoLikes ? '24px' : '4px' }, onAuto: () => this.setState({ autoLikes: !s.autoLikes }),
likesLeft: left.length ? 'ещё ' + left.length : 'всё скачано', onAllLikes: () => { if (!left.length) return this.toastMsg('Все лайки уже скачаны'); if (off) return this.toastMsg('Нет сети'); this.setState({ dlQueue: [...s.dlQueue, ...left.map(id => ({ id, p: 0 }))] }); },
hasQueue: s.dlQueue.length > 0, queue: s.dlQueue.map(x => ({ title: TR[x.id].title + ' — ' + ART[TR[x.id].artistId].name, cover: ALB[TR[x.id].albumId].cover, w: Math.min(100, x.p) + '%', pctStr: x.p > 0 ? Math.round(Math.min(100, x.p)) + '%' : 'ждёт' })),
albums: byAlb.map(g => { const A = ALB[g.id]; const m2 = g.ids.reduce((a, id) => a + TR[id].dur * 0.04, 0); return { title: A.title, cover: A.cover, sub: ART[A.artistId].name + ' · ' + g.ids.length + ' из ' + A.tracks.length + ' · ' + Math.round(m2) + ' МБ',
onDel: () => { this.setState(st => ({ downloads: st.downloads.filter(x => !g.ids.includes(x)) })); this.toastMsg('Альбом удалён с устройства'); }, rows: g.ids.map((id, i) => row(id, i, g.ids, 'Скачанное · ' + A.title, { eqA: true })) }; })
};
}
}
if (page === 'album') {
const B = ALB[s.sub.id], A = ART[B.artistId], tot = B.tracks.reduce((x, id) => x + TR[id].dur, 0);
const have = B.tracks.filter(id => s.downloads.includes(id)).length, inQ = B.tracks.some(id => s.dlQueue.some(x => x.id === id)), all = have === B.tracks.length;
const isThis = !s.waveOn && B.tracks.includes(s.trackId) && s.source.label === 'Альбом · ' + B.title;
out.alb = { title: B.title, cover: B.cover, artist: A.name, year: B.year, tint: `linear-gradient(180deg, ${B.c1}55, transparent 420px)`, meta: Math.round(tot / 60) + ' мин',
playIcon: isThis && s.playing ? 'pause' : 'play_arrow', onPlay: () => isThis ? this.togglePlay() : this.playList(B.tracks, 0, 'Альбом · ' + B.title),
onShuffle: () => this.playList([...B.tracks].sort(() => Math.random() - 0.5), 0, 'Альбом · ' + B.title), onArtist: () => this.goSub({ type: 'artist', id: A.id }),
dlIcon: all ? 'download_done' : inQ ? 'downloading' : 'download', dlColor: all || inQ ? 'var(--accent2)' : 'var(--text)',
dlLabel: all ? 'Альбом скачан · доступен без сети' : inQ ? 'Скачивается: ' + have + ' из ' + B.tracks.length : have ? 'Скачано ' + have + ' из ' + B.tracks.length : 'Скачайте, чтобы слушать без сети',
onDl: () => { if (all) { this.setState({ downloads: s.downloads.filter(x => !B.tracks.includes(x)) }); return this.toastMsg('Альбом удалён с устройства'); } if (off) return this.toastMsg('Нет сети'); this.setState({ dlQueue: [...s.dlQueue, ...B.tracks.filter(id => !s.downloads.includes(id) && !s.dlQueue.some(x => x.id === id)).map(id => ({ id, p: 0 }))] }); this.toastMsg(s.wifiOnly ? 'Скачиваем по Wi-Fi' : 'Скачиваем'); },
rows: B.tracks.map((id, i) => row(id, i, B.tracks, 'Альбом · ' + B.title, { eqA: true })) };
}
if (page === 'artist') {
const A = ART[s.sub.id], top = [...A.tracks].sort((a, b) => TR[b].pop - TR[a].pop).slice(0, 5);
out.art = { name: A.name, img: A.avatar, banner: `linear-gradient(180deg, ${A.c1}55, transparent 420px)`, ini: A.name.split(/\s+/).map(w => w[0]).join('').slice(0, 2).toUpperCase(), meta: A.genre + ' · ' + A.tracks.length + ' ' + plural(A.tracks.length, ['трек', 'трека', 'треков']),
onWave: () => { this.startWave(A.id); }, onPlay: () => this.playList(top, 0, 'Исполнитель · ' + A.name),
rows: top.map((id, i) => row(id, i, top, 'Исполнитель · ' + A.name)), albums: A.albums.map(id => ({ title: ALB[id].title, year: ALB[id].year, img: ALB[id].cover, onClick: () => this.goSub({ type: 'album', id }) })) };
}
if (page === 'profile') {
Object.assign(out, {
openPw: () => this.setState({ sheet: 'pw', f: {}, pwErr: '' }),
qualRows: QROWS.map(([k, l, ic]) => { const o = QOPT.find(q => q[0] === s.qual[k]); return { label: l, icon: ic, value: o[1] + ' · ' + o[2].split(' · ')[0], onClick: () => this.setState({ sheet: 'q-' + k }) }; }),
wifiOnly: { bg: s.wifiOnly ? 'var(--accent)' : 'var(--line2)', x: s.wifiOnly ? '24px' : '4px' }, toggleWifiOnly: () => this.setState({ wifiOnly: !s.wifiOnly }),
cacheStr: s.cache + ' МБ', clearCache: () => { this.setState({ cache: 0 }); this.toastMsg('Кэш очищен — скачанное не тронуто'); },
appVer: '0.6.1 · ' + (s.updSeen ? 'доступна 0.6.2' : 'проверено сегодня'), updDot: s.upd !== 'done',
checkUpdate: () => { if (s.upd === 'done') return this.toastMsg('У вас последняя версия'); this.setState({ sheet: 'update', updSeen: true }); },
openAdmin: () => this.toastMsg('Открываем https://' + s.srvAddr + '/admin в браузере')
});
}
if (sheet.qual) {
const k = s.sheet.slice(2), def = QROWS.find(r => r[0] === k);
out.qs = { title: 'Качество · ' + def[1].toLowerCase(), opts: QOPT.map(([v, l, sub]) => ({ label: l, sub, ring: s.qual[k] === v ? 'var(--accent)' : 'var(--line2)', dot: s.qual[k] === v ? 'var(--accent)' : 'transparent', onClick: () => this.setState({ qual: { ...s.qual, [k]: v }, sheet: null }) })) };
}
if (sheet.pw) {
out.pwErr = s.pwErr;
out.onPw = () => { const f = s.f; if (!f.cur) return this.setState({ pwErr: 'Введите текущий пароль' }); if ((f.np || '').length < 8) return this.setState({ pwErr: 'Новый пароль — минимум 8 символов' }); if (f.np !== f.np2) return this.setState({ pwErr: 'Пароли не совпадают' }); this.setState({ sheet: null, f: {} }); this.toastMsg('Пароль изменён'); };
}
if (sheet.update) {
const u = s.upd;
out.up = { isAvail: u === 'available', isPerm: u === 'perm', isDl: u === 'downloading', isVerify: u === 'verify', isReady: u === 'ready',
title: u === 'available' || u === 'perm' ? 'Доступна версия 0.6.2' : u === 'ready' ? 'Готово к установке' : 'Скачиваем обновление', sub: 'Сейчас 0.6.1 · APK 38,4 МБ · через сервер ' + s.srvAddr,
notes: ['Офлайн-волна учитывает выбранное настроение', 'Плавная докачка альбомов после обрыва сети', 'Исправлено: мини-плеер мигал при смене трека', 'Поддержка пользовательского CA для self-signed сертификатов'],
w: s.updPct + '%', pctStr: Math.round(s.updPct) + '%', dlStr: (s.updPct / 100 * 38.4).toFixed(1).replace('.', ',') + ' из 38,4 МБ',
onUpdate: () => this.setState({ upd: 'perm' }), onPerm: () => this.setState({ upd: 'downloading', updPct: 0 }),
onInstall: () => { this.setState({ sheet: null, upd: 'done' }); this.toastMsg('Открыт установщик Android — после установки приложение перезапустится'); } };
}
return out;
}
}
</script>
</body>
</html>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<meta name="design_doc_mode" content="canvas">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600;700&family=Unbounded:wght@500;600;700&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Rounded:opsz,wght,FILL,GRAD@24,400,0..1,0&display=block" rel="stylesheet">
<style>
body{margin:0;background:#05070D;color:#EDF1FF;font-family:Onest,system-ui,sans-serif}
a{color:#5E9BFF}a:hover{color:#8FB4FF}
i{font-family:'Material Symbols Rounded';font-style:normal;font-weight:normal;line-height:1;font-size:22px;display:inline-block;-webkit-font-smoothing:antialiased;font-feature-settings:'liga';user-select:none}
</style>
</helmet>
<section style="padding:56px 64px;display:flex;flex-direction:column;gap:32px">
<div style="display:flex;flex-direction:column;gap:8px;max-width:720px">
<div style="font-family:Unbounded;font-size:28px;font-weight:600;letter-spacing:-0.01em">Кнопка «Моя волна» — 3 варианта</div>
<div style="font-size:16px;line-height:1.5;color:#A0ABC6;text-wrap:pretty">Наведите курсор — волны ускоряются и тянутся к нему; зажмите — сжатие с отскоком; кликните — включение/пауза (пульс в такт BPM). Выбранный вариант переключается и в прототипе через Tweaks.</div>
</div>
<div style="display:flex;flex-wrap:wrap;gap:24px">
<sc-for list="{{ variants }}" as="v" hint-placeholder-count="3">
<div id="{{ v.id }}" style="width:440px;display:flex;flex-direction:column;gap:20px;padding:24px;border-radius:24px;background:#0B0F1A;border:1px solid rgba(130,160,255,.10)">
<div style="display:flex;align-items:center;gap:12px">
<div style="padding:4px 10px;border-radius:999px;background:rgba(47,128,255,.14);color:#5E9BFF;font-weight:600;font-size:13px">{{ v.id }}</div>
<div style="font-weight:600;font-size:17px">{{ v.name }}</div>
</div>
<div style="height:400px;display:flex;align-items:center;justify-content:center;border-radius:16px;background:radial-gradient(circle at 50% 50%, #0A1020, #05070D 70%);overflow:hidden">
<x-import component="WaveButton" from="./wave.js" variant="{{ v.key }}" playing="{{ v.playing }}" bpm="{{ 118 }}" energy="{{ 0.75 }}" size="{{ 260 }}" onClick="{{ v.toggle }}" hint-size="260px,260px">
<i style="font-size:44px;color:#fff;font-variation-settings:'FILL' 1">{{ v.icon }}</i>
<div style="font-family:Unbounded;font-weight:600;font-size:18px;color:#fff;margin-top:6px">Моя волна</div>
</x-import>
</div>
<div style="font-size:14px;line-height:1.5;color:#A0ABC6;text-wrap:pretty">{{ v.desc }}</div>
</div>
</sc-for>
</div>
</section>
</x-dc>
<script type="text/x-dc" data-dc-script>
class Component extends DCLogic {
state = { playing: {} };
renderVals() {
const defs = [
{ id: '1a', key: 'orb', name: 'Сфера с шумом', desc: 'Живая сфера: перетекающие цветовые пятна и синусоидальные волны внутри. Ближе всего к шейдеру из документации.' },
{ id: '1b', key: 'rings', name: 'Кольца', desc: 'Плотное ядро и расходящиеся кольца-волны. Самый «читаемый» ритм: на каждый бит — выброс кольца.' },
{ id: '1c', key: 'liquid', name: 'Жидкая форма', desc: 'Три слоя мягкого блоба меняют силуэт; на ховере форма вытягивается к курсору.' }
];
return {
variants: defs.map(d => ({
...d,
playing: !!this.state.playing[d.key],
icon: this.state.playing[d.key] ? 'pause' : 'play_arrow',
toggle: () => this.setState(s => ({ playing: { ...s.playing, [d.key]: !s.playing[d.key] } }))
}))
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,229 @@
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
// Copied omelette starter. Re-running copy_starter_component with this kind overwrites this file with the latest version (page content is unaffected).
/* BEGIN USAGE */
// Android.jsx — Simplified Android (Material 3) device frame
// Status bar + top app bar + content + gesture nav + keyboard.
// Based on Figma M3 spec. No dependencies, no image assets.
// Exports (to window): AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard
//
// Usage — wrap your screen content in <AndroidDevice> to get the bezel, status
// bar and gesture nav (props: title, large, keyboard, dark):
//
// <AndroidDevice title="Inbox" large>
// ...your screen content...
// </AndroidDevice>
// <AndroidDevice title="Compose" keyboard>…</AndroidDevice>
/* END USAGE */
const MD_C = {
surface: '#f4fbf8',
surfaceVariant: '#dae5e1',
inverseOnSurface: '#ecf2ef',
secondaryContainer: '#cde8e1',
primaryFixedDim: '#83d5c6',
onSurface: '#171d1b',
onSurfaceVar: '#49454f',
onPrimaryContainer: '#00201c',
primary: '#006a60',
frameBorder: 'rgba(116,119,117,0.5)',
};
// ─────────────────────────────────────────────────────────────
// Status bar (time left, wifi/cell/battery right)
// ─────────────────────────────────────────────────────────────
function AndroidStatusBar({ dark = false }) {
const c = dark ? '#fff' : MD_C.onSurface;
return (
<div style={{
height: 40, display: 'flex', alignItems: 'center',
justifyContent: 'space-between', padding: '0 16px',
position: 'relative',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{/* time left */}
<div style={{ width: 128, display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14, fontWeight: 400, letterSpacing: 0.25, lineHeight: '20px', color: c }}>9:30</span>
</div>
{/* camera punch-hole (center) */}
<div style={{
position: 'absolute', left: '50%', top: 8, transform: 'translateX(-50%)',
width: 24, height: 24, borderRadius: 100, background: '#2e2e2e',
}} />
{/* status icons right */}
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ display: 'flex', paddingRight: 2 }}>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M8 13.3L.67 5.97a10.37 10.37 0 0114.66 0L8 13.3z" fill={c}/>
</svg>
<svg width="16" height="16" viewBox="0 0 16 16" style={{ marginRight: -2 }}>
<path d="M14.67 14.67V1.33L1.33 14.67h13.34z" fill={c}/>
</svg>
</div>
<svg width="16" height="16" viewBox="0 0 16 16">
<rect x="3.75" y="2" width="8.5" height="13" rx="1.5" fill={c}/>
<rect x="5.5" y="0.9" width="5" height="2" rx="0.5" fill={c}/>
</svg>
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Top app bar (Material 3 small/medium)
// ─────────────────────────────────────────────────────────────
function AndroidAppBar({ title = 'Title', large = false }) {
const iconDot = (
<div style={{
width: 48, height: 48, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{ width: 22, height: 22, borderRadius: '50%', background: MD_C.onSurfaceVar, opacity: 0.3 }} />
</div>
);
return (
<div style={{ background: MD_C.surface, padding: '4px 4px 0' }}>
<div style={{ height: 56, display: 'flex', alignItems: 'center', gap: 4 }}>
{iconDot}
{!large && (
<span style={{
flex: 1, fontSize: 22, fontWeight: 400, color: MD_C.onSurface,
fontFamily: 'Roboto, system-ui, sans-serif',
}}>{title}</span>
)}
{large && <div style={{ flex: 1 }} />}
{iconDot}
</div>
{large && (
<div style={{
padding: '16px 16px 20px',
fontSize: 28, fontWeight: 400, color: MD_C.onSurface,
fontFamily: 'Roboto, system-ui, sans-serif',
}}>{title}</div>
)}
</div>
);
}
// ─────────────────────────────────────────────────────────────
// List item (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidListItem({ headline, supporting, leading }) {
return (
<div style={{
display: 'flex', alignItems: 'center', gap: 16,
padding: '12px 16px', minHeight: 56, boxSizing: 'border-box',
fontFamily: 'Roboto, system-ui, sans-serif',
}}>
{leading && (
<div style={{
width: 40, height: 40, borderRadius: '50%',
background: MD_C.primary, color: '#fff',
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 18, fontWeight: 500, flexShrink: 0,
}}>{leading}</div>
)}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 16, color: MD_C.onSurface, lineHeight: '24px' }}>{headline}</div>
{supporting && (
<div style={{ fontSize: 14, color: MD_C.onSurfaceVar, lineHeight: '20px' }}>{supporting}</div>
)}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Gesture nav bar (pill)
// ─────────────────────────────────────────────────────────────
function AndroidNavBar({ dark = false }) {
return (
<div style={{
height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<div style={{
width: 108, height: 4, borderRadius: 2,
background: dark ? '#fff' : MD_C.onSurface, opacity: 0.4,
}} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Device frame — wraps everything
// ─────────────────────────────────────────────────────────────
function AndroidDevice({
children, width = 412, height = 892, dark = false,
title, large = false, keyboard = false, bg,
}) {
return (
// data-om-starter: inert presence marker — Claude Design's starter-usage
// probe reads it; it renders nothing. Keep it on this root element.
<div data-om-starter="android-frame" style={{
width, height, borderRadius: 18, overflow: 'hidden',
background: bg || (dark ? '#1d1b20' : MD_C.surface),
border: `8px solid ${MD_C.frameBorder}`, position: 'relative',
boxShadow: '0 30px 80px rgba(0,0,0,0.25)',
display: 'flex', flexDirection: 'column', boxSizing: 'border-box',
}}>
<AndroidStatusBar dark={dark} />
{title !== undefined && <AndroidAppBar title={title} large={large} />}
<div style={{ flex: 1, overflow: 'hidden', position: 'relative', minHeight: 0 }}>
{children}
</div>
{keyboard && <AndroidKeyboard />}
<AndroidNavBar dark={dark} />
</div>
);
}
// ─────────────────────────────────────────────────────────────
// Keyboard — Gboard (Material 3)
// ─────────────────────────────────────────────────────────────
function AndroidKeyboard() {
let _k = 0;
const key = (l, { flex = 1, bg = MD_C.surface, r = 6, minW, fs = 21 } = {}) => (
<div key={_k++} style={{
height: 46, borderRadius: r, flex, minWidth: minW,
background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontFamily: 'Roboto, system-ui', fontSize: fs,
color: MD_C.onPrimaryContainer,
}}>{l}</div>
);
const row = (keys, style = {}) => (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', ...style }}>
{keys.map(l => key(l))}
</div>
);
return (
<div style={{
background: MD_C.inverseOnSurface, padding: '0 8px 8px',
display: 'flex', flexDirection: 'column', gap: 4,
}}>
{/* navbar spacer (icons omitted) */}
<div style={{ height: 44 }} />
{/* key rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
{row(['q','w','e','r','t','y','u','i','o','p'])}
{row(['a','s','d','f','g','h','j','k','l'], { padding: '0 20px' })}
<div style={{ display: 'flex', gap: 6 }}>
{key('', { bg: MD_C.surfaceVariant })}
<div style={{ display: 'flex', gap: 6, flex: 7, minWidth: 274 }}>
{['z','x','c','v','b','n','m'].map(l => key(l))}
</div>
{key('', { bg: MD_C.surfaceVariant })}
</div>
<div style={{ display: 'flex', gap: 6 }}>
{key('?123', { bg: MD_C.secondaryContainer, r: 100, minW: 58, fs: 14 })}
{key(',', { bg: MD_C.surfaceVariant })}
{key('', { flex: 3, minW: 154 })}
{key('.', { bg: MD_C.surfaceVariant })}
{key('', { bg: MD_C.primaryFixedDim, r: 100, minW: 58 })}
</div>
</div>
</div>
);
}
Object.assign(window, {
AndroidDevice, AndroidStatusBar, AndroidAppBar, AndroidListItem, AndroidNavBar, AndroidKeyboard,
});
@@ -0,0 +1,104 @@
(function () {
const H = s => { let h = 2166136261; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
const clamp = (v, a, b) => Math.max(a, Math.min(b, v));
const fmt = s => { s = Math.max(0, Math.floor(s)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
const plural = (n, f) => { const a = n % 10, b = n % 100; return f[(a === 1 && b !== 11) ? 0 : (a >= 2 && a <= 4 && (b < 10 || b >= 20)) ? 1 : 2]; };
const AR = {
kino: ['Кино', 'Русский рок', 0.62], zemfira: ['Земфира', 'Рок', 0.55], radiohead: ['Radiohead', 'Альтернатива', 0.45],
massive: ['Massive Attack', 'Трип-хоп', 0.38], moderat: ['Moderat', 'Электроника', 0.66], bonobo: ['Bonobo', 'Даунтемпо', 0.48],
daftpunk: ['Daft Punk', 'Электроника', 0.78], splean: ['Сплин', 'Русский рок', 0.56]
};
const AL = [
['rh-ir', 'In Rainbows', 'radiohead', 2007, '#F2A23A', '#1C3B6E', ['15 Step', 'Bodysnatchers', 'Nude', 'Weird Fishes/Arpeggi', 'All I Need', 'Faust Arp', 'Reckoner', 'House of Cards', 'Jigsaw Falling into Place', 'Videotape']],
['kino-gk', 'Группа крови', 'kino', 1988, '#C2412D', '#2A0E0A', ['Группа крови', 'Закрой за мной дверь, я ухожу', 'Война', 'Спокойная ночь', 'Мама, мы все сошли с ума', 'Бошетунмай', 'Попробуй спеть вместе со мной', 'Звезда']],
['kino-zs', 'Звезда по имени Солнце', 'kino', 1989, '#E39B2D', '#3A1A05', ['Звезда по имени Солнце', 'Песня без слов', 'Место для шага вперёд', 'Невесёлая песня', 'Апрель', 'Пачка сигарет']],
['zem-pm', 'Прости меня моя любовь', 'zemfira', 2000, '#D9467A', '#2B0B23', ['Хочешь?', 'Искала', 'Ромашки', 'Непошлое', 'Прости меня моя любовь', 'Город']],
['ma-mz', 'Mezzanine', 'massive', 1998, '#6A7385', '#0E1116', ['Angel', 'Risingson', 'Teardrop', 'Inertia Creeps', 'Exchange', 'Dissolved Girl']],
['mod-2', 'II', 'moderat', 2013, '#3A7BFF', '#0A1636', ['The Mark', 'Bad Kingdom', 'Versions', 'Let in the Light', 'Milk', 'Damage Done']],
['bon-mg', 'Migration', 'bonobo', 2017, '#2FB6A0', '#0B2730', ['Migration', 'Break Apart', 'Outlier', 'Kerala', 'Grains', 'No Reason']],
['dp-ram', 'Random Access Memories', 'daftpunk', 2013, '#A7AFBF', '#141821', ['Give Life Back to Music', 'The Game of Love', 'Instant Crush', 'Lose Yourself to Dance', 'Get Lucky', "Doin' It Right"]],
['spl-ga', 'Гранатовый альбом', 'splean', 1998, '#B4233A', '#1D0710', ['Орбит без сахара', 'Пластмассовая жизнь', 'Иди через лес', 'Феллини']]
];
const ART = {}, ALB = {}, TR = {}, ALL = [];
Object.keys(AR).forEach(k => { ART[k] = { id: k, name: AR[k][0], genre: AR[k][1], energy: AR[k][2], albums: [], tracks: [] }; });
AL.forEach(([id, title, ar, year, c1, c2, list]) => {
const cover = `radial-gradient(circle at 28% 22%, rgba(255,255,255,.32), rgba(255,255,255,0) 55%), linear-gradient(140deg, ${c1}, ${c2})`;
ALB[id] = { id, title, artistId: ar, year, c1, c2, cover, tracks: [] };
const A = ART[ar];
A.albums.push(id);
if (!A.c1) { A.c1 = c1; A.c2 = c2; A.avatar = `radial-gradient(circle at 35% 28%, ${c1}, ${c2} 78%)`; A.banner = `radial-gradient(ellipse at 20% 0%, ${c1}55, transparent 60%), linear-gradient(180deg, ${c2}cc, transparent)`; }
list.forEach((t, i) => {
const h = H(id + t), tid = id + '-' + i;
TR[tid] = { id: tid, title: t, artistId: ar, albumId: id, n: i + 1, dur: 150 + h % 170, bpm: 72 + h % 74, energy: clamp(AR[ar][2] + ((h >> 3) % 40 - 20) / 100, 0.05, 0.98), pop: h % 100 };
ALB[id].tracks.push(tid); A.tracks.push(tid); ALL.push(tid);
});
});
const ACT = [['run', 'Бегаю', 'directions_run', 0.85], ['wake', 'Просыпаюсь', 'wb_twilight', 0.55], ['focus', 'Работаю', 'center_focus_strong', 0.4], ['gym', 'Тренировка', 'fitness_center', 0.92], ['road', 'В дороге', 'directions_car', 0.6], ['rest', 'Отдыхаю', 'weekend', 0.35], ['sleep', 'Засыпаю', 'bedtime', 0.15]];
const MOOD = [['energy', 'Энергичное', 'bolt', 0.85, '#2F80FF', '#35D3FF', '#7CF0FF'], ['happy', 'Весёлое', 'sentiment_satisfied', 0.7, '#2F80FF', '#35D3FF', '#9B7CFF'], ['calm', 'Спокойное', 'spa', 0.3, '#2F80FF', '#6C7CFF', '#5AD1E6'], ['sad', 'Грустное', 'water_drop', 0.22, '#3B5BFF', '#7C5CFF', '#24317A']];
function waveBatch(n, opts) {
const { activity, mood, dislikes = [], likes = [], recent = [], artistId } = opts || {};
const tg = [];
if (activity) tg.push(ACT.find(a => a[0] === activity)[3]);
if (mood) tg.push(MOOD.find(m => m[0] === mood)[3]);
const target = tg.length ? tg.reduce((a, b) => a + b, 0) / tg.length : 0.55;
return ALL.filter(id => !dislikes.includes(id) && !recent.includes(id))
.map(id => {
const t = TR[id];
let sc = -Math.abs(t.energy - target) * 1.4 + (likes.includes(id) ? 0.2 : 0) + Math.random() * 0.35;
if (artistId) sc += t.artistId === artistId ? 0.8 : (ART[t.artistId].genre === ART[artistId].genre ? 0.35 : 0);
return [id, sc];
})
.sort((a, b) => b[1] - a[1]).slice(0, n).map(x => x[0]);
}
const EN = "qwertyuiop[]asdfghjkl;'zxcvbnm,.`", RU = 'йцукенгшщзхъфывапролджэячсмитьбюё';
const swapLayout = q => q.split('').map(c => { const l = c.toLowerCase(); const i = EN.indexOf(l); if (i >= 0) return RU[i]; const j = RU.indexOf(l); if (j >= 0) return EN[j]; return c; }).join('');
const TL = { а: 'a', б: 'b', в: 'v', г: 'g', д: 'd', е: 'e', ж: 'zh', з: 'z', и: 'i', й: 'y', к: 'k', л: 'l', м: 'm', н: 'n', о: 'o', п: 'p', р: 'r', с: 's', т: 't', у: 'u', ф: 'f', х: 'h', ц: 'c', ч: 'ch', ш: 'sh', щ: 'sch', ъ: '', ы: 'y', ь: '', э: 'e', ю: 'yu', я: 'ya' };
const norm = s => s.toLowerCase().replace(/ё/g, 'е').replace(/[^a-zа-я0-9 ]/g, ' ').replace(/\s+/g, ' ').trim();
const tl = s => s.split('').map(c => TL[c] ?? c).join('');
const lev = (a, b) => { const d = Array.from({ length: a.length + 1 }, (_, i) => [i]); for (let j = 1; j <= b.length; j++) d[0][j] = j; for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); return d[a.length][b.length]; };
const m1 = (n, q) => { if (n.includes(q)) return true; if (q.length >= 4) return n.split(' ').some(w => lev(w.slice(0, q.length), q) <= 1) || lev(n.slice(0, q.length), q) <= 1; return false; };
const match = (name, q) => { const n = norm(name), qq = norm(q); if (!qq) return false; return m1(n, qq) || m1(tl(n), tl(qq)); };
function searchRaw(q) {
const artists = Object.values(ART).filter(a => match(a.name, q));
const albums = Object.values(ALB).filter(a => match(a.title, q) || match(ART[a.artistId].name, q));
const tracks = ALL.filter(id => match(TR[id].title, q) || match(ART[TR[id].artistId].name, q)).sort((a, b) => TR[b].pop - TR[a].pop);
return { artists, albums, tracks };
}
function search(q) {
let r = searchRaw(q), fixed = null;
if (q.trim() && !r.artists.length && !r.albums.length && !r.tracks.length) {
const q2 = swapLayout(q), r2 = searchRaw(q2);
if (r2.artists.length || r2.albums.length || r2.tracks.length) { r = r2; fixed = q2; }
}
return { ...r, fixed };
}
const sanitize = v => String(v ?? '').replace(/[\/\\:*?"<>|]/g, '_').replace(/[. ]+$/, '');
function fillTpl(tpl, f) {
let out = tpl.replace(/\[([^\]]*)\]/g, (m, inner) => [...inner.matchAll(/\{(\w+)(?::[^}]*)?\}/g)].every(x => f[x[1]]) ? inner : '');
return out.replace(/\{(\w+)(?::([^}]*))?\}/g, (m, k, mod) => {
let v = f[k]; if (k === 'albumartist' && !v) v = f.artist;
v = v == null ? '' : (k === 'ext' ? v : sanitize(v));
if (mod === '02') v = String(v).padStart(2, '0');
if (mod === '1-') v = v ? v + '-' : '';
return v;
});
}
const GC = { artist: '#5E9BFF', albumartist: '#8FB4FF', album: '#A08BFF', year: '#FFB547', disc: '#FF8FB1', track: '#35D3FF', title: '#2FD39A', genre: '#FF9F5A', skip: '#FF5C70' };
const GL = { artist: 'исполнитель', albumartist: 'исп. альбома', album: 'альбом', year: 'год', disc: 'диск', track: '№', title: 'название', genre: 'жанр', skip: 'пропуск' };
function compile(p) { try { return new RegExp(p.replace(/\(\?P</g, '(?<'), 'd'); } catch (e) { return e; } }
function tokens(p) { return p.split(/(\(\?P<\w+>)/).filter(Boolean).map(t => { const m = t.match(/^\(\?P<(\w+)>$/); return { t, c: m ? (GC[m[1]] || 'var(--accent2)') : 'var(--text)', w: m ? '600' : '400' }; }); }
function segments(path, m) {
const g = m.indices && m.indices.groups ? Object.entries(m.indices.groups).filter(([, v]) => v).sort((a, b) => a[1][0] - b[1][0]) : [];
const out = []; let i = 0;
g.forEach(([k, [s, e]]) => { if (s > i) out.push({ t: path.slice(i, s), c: 'var(--text3)' }); out.push({ t: path.slice(s, e), c: GC[k] || 'var(--text)' }); i = e; });
if (i < path.length) out.push({ t: path.slice(i), c: 'var(--text3)' });
return out;
}
window.PIPU = { H, clamp, fmt, plural, ART, ALB, TR, ALL, ACT, MOOD, waveBatch, search, swapLayout, fillTpl, sanitize, compile, tokens, segments, GC, GL };
})();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,209 @@
(function () {
const R = window.React;
const { useRef, useEffect } = R;
function rgb(hex) {
const h = hex.replace('#', '');
const n = parseInt(h.length === 3 ? h.split('').map(c => c + c).join('') : h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const rgba = (c, a) => `rgba(${c[0]},${c[1]},${c[2]},${a})`;
const mix = (a, b, k) => [a[0] + (b[0] - a[0]) * k, a[1] + (b[1] - a[1]) * k, a[2] + (b[2] - a[2]) * k].map(Math.round);
function drawOrb(ctx, cx, cy, r, s, t) {
const c1 = rgb(s.color), c2 = rgb(s.color2), c3 = rgb(s.color3);
const glow = ctx.createRadialGradient(cx, cy, r * 0.6, cx, cy, r * 1.65);
glow.addColorStop(0, rgba(c1, 0.32 + s.pulse * 0.35 + s.hover * 0.12));
glow.addColorStop(0.5, rgba(c2, 0.10 + s.pulse * 0.12));
glow.addColorStop(1, rgba(c1, 0));
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(cx, cy, r * 1.65, 0, Math.PI * 2); ctx.fill();
ctx.save();
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.clip();
ctx.fillStyle = s.dark ? '#060B1C' : '#0B1533';
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.globalCompositeOperation = 'lighter';
const blobs = [c1, c2, c3, c1, c2];
for (let i = 0; i < blobs.length; i++) {
const a = t * (0.25 + i * 0.07) + i * 1.7;
const bx = cx + Math.cos(a) * r * (0.35 + 0.12 * Math.sin(t * 0.3 + i)) + s.mx * r * 0.18 * s.hover;
const by = cy + Math.sin(a * 1.3) * r * 0.38 + s.my * r * 0.18 * s.hover;
const br = r * (0.75 + 0.15 * Math.sin(t * 0.7 + i) + s.pulse * 0.2);
const g = ctx.createRadialGradient(bx, by, 0, bx, by, br);
g.addColorStop(0, rgba(blobs[i], 0.55));
g.addColorStop(1, rgba(blobs[i], 0));
ctx.fillStyle = g;
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
}
const lines = 9;
for (let l = 0; l < lines; l++) {
const yy = cy - r * 0.75 + (l / (lines - 1)) * r * 1.5;
const amp = r * (0.03 + 0.05 * s.amp + 0.09 * s.pulse + 0.03 * s.hover);
ctx.beginPath();
for (let x = -r; x <= r; x += 4) {
const y = yy + Math.sin(x / r * (3 + l * 0.4) + t * (1.2 + l * 0.15) + l) * amp * Math.cos(x / r * 1.4);
x === -r ? ctx.moveTo(cx + x, y) : ctx.lineTo(cx + x, y);
}
ctx.strokeStyle = `rgba(255,255,255,${0.05 + 0.05 * s.amp + (l % 3 === 0 ? 0.05 : 0)})`;
ctx.lineWidth = 1.5;
ctx.stroke();
}
ctx.globalCompositeOperation = 'source-over';
const hl = ctx.createRadialGradient(cx - r * 0.35, cy - r * 0.45, 0, cx - r * 0.35, cy - r * 0.45, r * 0.9);
hl.addColorStop(0, 'rgba(255,255,255,0.18)');
hl.addColorStop(1, 'rgba(255,255,255,0)');
ctx.fillStyle = hl;
ctx.fillRect(cx - r, cy - r, r * 2, r * 2);
ctx.restore();
ctx.beginPath(); ctx.arc(cx, cy, r - 0.5, 0, Math.PI * 2);
ctx.strokeStyle = rgba(mix(c1, [255, 255, 255], 0.4), 0.35 + s.pulse * 0.3);
ctx.lineWidth = 1; ctx.stroke();
}
function drawRings(ctx, cx, cy, r, s, t) {
const c1 = rgb(s.color), c2 = rgb(s.color2), c3 = rgb(s.color3);
const N = 8;
for (let i = 0; i < N; i++) {
const k = ((t * 0.12 * s.speed + i / N) % 1);
const rr = r * (0.58 + k * 0.95);
const a = Math.pow(1 - k, 1.6) * (0.5 + 0.3 * s.amp);
ctx.beginPath();
for (let j = 0; j <= 120; j++) {
const th = (j / 120) * Math.PI * 2;
const pull = s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 4) * r * 0.08 * (1 - k);
const d = Math.sin(th * 6 + t * 2 + i) * r * 0.012 * (1 + s.amp * 2 + s.pulse * 4) + pull;
const x = cx + Math.cos(th) * (rr + d), y = cy + Math.sin(th) * (rr + d);
j ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
ctx.strokeStyle = rgba(mix(c1, c3, k), a);
ctx.lineWidth = 1.5 + (1 - k) * 2;
ctx.stroke();
}
const cr = r * (0.58 + s.pulse * 0.07 + 0.015 * Math.sin(t * 1.4));
const glow = ctx.createRadialGradient(cx, cy, cr * 0.5, cx, cy, cr * 1.9);
glow.addColorStop(0, rgba(c1, 0.45 + s.pulse * 0.3));
glow.addColorStop(1, rgba(c1, 0));
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(cx, cy, cr * 1.9, 0, Math.PI * 2); ctx.fill();
const g = ctx.createLinearGradient(cx - cr, cy - cr, cx + cr, cy + cr);
g.addColorStop(0, rgba(mix(c1, [255, 255, 255], 0.15), 1));
g.addColorStop(0.6, rgba(c1, 1));
g.addColorStop(1, rgba(c2, 1));
ctx.fillStyle = g;
ctx.beginPath(); ctx.arc(cx + s.mx * 4 * s.hover, cy + s.my * 4 * s.hover, cr, 0, Math.PI * 2); ctx.fill();
}
function drawLiquid(ctx, cx, cy, r, s, t) {
const cols = [rgb(s.color), rgb(s.color2), rgb(s.color3)];
const glow = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r * 1.6);
glow.addColorStop(0, rgba(cols[0], 0.3 + s.pulse * 0.3));
glow.addColorStop(1, rgba(cols[0], 0));
ctx.fillStyle = glow;
ctx.beginPath(); ctx.arc(cx, cy, r * 1.6, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = s.dark ? 'lighter' : 'source-over';
for (let L = 0; L < 3; L++) {
ctx.beginPath();
const P = 140;
for (let j = 0; j <= P; j++) {
const th = (j / P) * Math.PI * 2;
const w = 1
+ 0.06 * Math.sin(3 * th + t * (0.9 + L * 0.2) + L * 2)
+ 0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L)
+ 0.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp)
+ 0.08 * s.pulse * Math.sin(2 * th + L)
+ 0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3);
const rr = r * (0.86 - L * 0.07) * w;
const x = cx + Math.cos(th) * rr, y = cy + Math.sin(th) * rr;
j ? ctx.lineTo(x, y) : ctx.moveTo(x, y);
}
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3, oy = Math.sin(t * 0.5 + L) * r * 0.3;
const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1);
g.addColorStop(0, rgba(mix(cols[L], [255, 255, 255], 0.25), 0.85));
g.addColorStop(1, rgba(cols[L], 0.25));
ctx.fillStyle = g;
ctx.globalAlpha = s.dark ? 0.6 : 0.75;
ctx.fill();
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
}
function WaveButton(props) {
const size = props.size || 360;
const canvasRef = useRef(null), labelRef = useRef(null), wrapRef = useRef(null);
const st = useRef({ hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0, speed: 1, phase: 0 });
const p = st.current;
p.variant = props.variant || 'orb';
p.playing = !!props.playing;
p.bpm = props.bpm || 100;
p.energy = props.energy == null ? 0.6 : props.energy;
p.color = props.color || '#2F80FF';
p.color2 = props.color2 || '#7C5CFF';
p.color3 = props.color3 || '#35D3FF';
p.dark = props.theme !== 'light';
p.reduced = !!props.reduced;
p.size = size;
useEffect(() => {
let raf, last = performance.now();
const cv = canvasRef.current, ctx = cv.getContext('2d');
const loop = (now) => {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
const s = st.current;
const S = s.size * 1.6, dpr = window.devicePixelRatio || 1;
if (cv.width !== Math.round(S * dpr)) { cv.width = Math.round(S * dpr); cv.height = Math.round(S * dpr); }
s.hover += (s.hoverT - s.hover) * 0.08;
s.pressV += (s.pressT - s.press) * 0.35; s.pressV *= 0.62; s.press += s.pressV;
s.amp += ((s.playing ? 1 : 0) - s.amp) * 0.04;
s.speed = s.reduced ? 0.15 : 1 + s.hover * 1.4 + s.amp * 0.7;
s.t += dt * s.speed;
if (s.playing && !s.reduced) {
s.phase = (s.phase + dt * s.bpm / 60) % 1;
s.pulse += (Math.pow(1 - s.phase, 4) * (0.35 + s.energy * 0.65) - s.pulse) * 0.5;
} else {
s.pulse += ((s.reduced ? 0 : (Math.sin(s.t * 1.1) * 0.5 + 0.5) * 0.12) - s.pulse) * 0.1;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, S, S);
const sc = 1 - s.press * 0.07 + s.hover * 0.02;
const r = s.size * 0.5 * sc;
const cx = S / 2 + s.mx * s.hover * 6, cy = S / 2 + s.my * s.hover * 6;
if (s.variant === 'rings') drawRings(ctx, cx, cy, r * 0.62, s, s.t);
else if (s.variant === 'liquid') drawLiquid(ctx, cx, cy, r, s, s.t);
else drawOrb(ctx, cx, cy, r, s, s.t);
if (labelRef.current) labelRef.current.style.transform = `scale(${sc + s.pulse * 0.03})`;
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
const onMove = (e) => {
const b = wrapRef.current.getBoundingClientRect();
const x = (e.clientX - b.left) / b.width * 2 - 1, y = (e.clientY - b.top) / b.height * 2 - 1;
p.mx = x; p.my = y; p.ang = Math.atan2(y, x);
};
const interactive = props.interactive !== false;
return R.createElement('div', {
ref: wrapRef,
role: interactive ? 'button' : undefined,
tabIndex: interactive ? 0 : undefined,
'aria-label': props.ariaLabel || 'Моя волна',
onMouseMove: interactive ? onMove : undefined,
onMouseEnter: interactive ? () => { p.hoverT = 1; } : undefined,
onMouseLeave: interactive ? () => { p.hoverT = 0; p.pressT = 0; } : undefined,
onMouseDown: interactive ? () => { p.pressT = 1; } : undefined,
onMouseUp: interactive ? () => { p.pressT = 0; p.pressV -= 0.12; } : undefined,
onClick: interactive ? props.onClick : undefined,
onKeyDown: interactive ? (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); props.onClick && props.onClick(); } } : undefined,
style: { position: 'relative', width: size, height: size, borderRadius: '50%', cursor: interactive ? 'pointer' : 'default', outline: 'none', flexShrink: 0, pointerEvents: interactive ? 'auto' : 'none' }
},
R.createElement('canvas', { ref: canvasRef, style: { position: 'absolute', left: -size * 0.3, top: -size * 0.3, width: size * 1.6, height: size * 1.6, pointerEvents: 'none' } }),
R.createElement('div', { ref: labelRef, style: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', pointerEvents: 'none' } }, props.children)
);
}
window.WaveButton = WaveButton;
if (typeof module !== 'undefined') module.exports = { WaveButton };
})();