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:
1 parent
36e99fe960
commit
9eb1c25147
30 files changed
+7538
No files matched your search
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,2 +1,8 @@
|
|||||||
# pipu-music
|
# 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
@@ -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: по тегу собирается релиз.
|
||||||
@@ -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
@@ -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`) и тем, что сервисы не публикуют порты наружу.
|
||||||
@@ -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).
|
||||||
@@ -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`.
|
||||||
@@ -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).
|
||||||
@@ -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` без `..`).
|
||||||
@@ -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`.
|
||||||
@@ -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).
|
||||||
@@ -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
@@ -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.
|
||||||
|
- **Английская локализация**: в прототипе только русские строки; при вёрстке проверять длинные строки на обоих языках.
|
||||||
@@ -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 запускаются минимум на двух локалях (длинные русские строки ловят проблемы вёрстки).
|
||||||
@@ -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`, без даунтайма).
|
||||||
@@ -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"` для данных с токенами.
|
||||||
@@ -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.
|
||||||
@@ -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 млн событий; «волна по исполнителю / треку», ежедневные персональные подборки |
|
||||||
@@ -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-скробблинг, импорт лайков.
|
||||||
@@ -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
@@ -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
@@ -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="{"theme":{"editor":"enum","options":["dark","light"],"default":"dark","tsType":"string","section":"Вид"},"waveVariant":{"editor":"enum","options":["orb","rings","liquid"],"default":"orb","tsType":"string","section":"Вид"},"playerLayout":{"editor":"enum","options":["cover","wave"],"default":"cover","tsType":"string","section":"Варианты"},"ambient":{"editor":"boolean","default":true,"tsType":"boolean","section":"Вид"},"offline":{"editor":"boolean","default":false,"tsType":"boolean","section":"Прототип"},"role":{"editor":"enum","options":["user","moderator","admin"],"default":"user","tsType":"string","section":"Прототип"},"startScreen":{"editor":"enum","options":["app","connect"],"default":"app","tsType":"string","section":"Прототип"}}">
|
||||||
|
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 };
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user