feat: add personal playlists and shareable track links
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s

EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
mrleo1nidandCursor committed 2026-10-21 23:37:53 +03:00
1 parent 3a097c15c8
commit fa79394869
46 files changed
+2307 -292

No files matched your search

+2 -2
View File
@@ -57,19 +57,19 @@
### Левая панель навигации
- **Главная**
- **Коллекция** — с подкатегориями:
- **Плейлисты** — личные подборки треков;
- **История** — прослушанные треки;
- **Лайки** — понравившиеся треки;
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
- **Админка** — видна только администраторам и модераторам.
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
### Плеер (всегда внизу)
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь.
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь.
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
и даже на другом устройстве можно продолжить с того же места.
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
+1
View File
@@ -15,6 +15,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент |
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
+2
View File
@@ -91,6 +91,8 @@
| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` |
| DELETE | `/me/reactions/tracks/{id}` | Снять реакцию |
| PUT/DELETE | `/me/reactions/artists/{id}` | Реакция на исполнителя |
| GET/POST | `/me/playlists` | Сводки личных плейлистов; создание `{name, description, track_ids}` |
| GET/PUT/DELETE | `/me/playlists/{id}` | Метаданные и треки; атомарная замена с `revision`; удаление. Подробно — [playlists.md](playlists.md) |
### Моя волна — [recommendations.md](recommendations.md)
| Метод | Путь | Описание |
+21 -2
View File
@@ -41,7 +41,7 @@ CREATE TABLE refresh_tokens (
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
device_name text, -- "Chrome / Windows", "Pixel 8"
client_type text NOT NULL, -- 'web' | 'android'
client_type text NOT NULL, -- 'web' | 'android' | 'desktop'
created_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used_at timestamptz, -- ротирован
@@ -303,6 +303,25 @@ CREATE TABLE track_reactions ( -- Коллекция → Лайки
);
CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC);
CREATE TABLE playlists (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
revision integer NOT NULL DEFAULT 1, -- оптимистичная блокировка при PUT
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX playlists_owner ON playlists(user_id, updated_at DESC, id);
CREATE TABLE playlist_tracks (
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
position integer NOT NULL CHECK (position >= 0),
PRIMARY KEY (playlist_id, track_id),
UNIQUE (playlist_id, position)
);
CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п.
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
@@ -324,7 +343,7 @@ CREATE TABLE play_events (
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
client_type text NOT NULL, -- web | android | desktop
offline boolean NOT NULL DEFAULT false,
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
+2 -2
View File
@@ -42,12 +42,12 @@
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
прогресс, лайк / дизлайк, скачать, очередь (нижний лист).
прогресс, лайк / дизлайк, скачать, поделиться ссылкой на `/track/{id}`, очередь (нижний лист).
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
выбор занятия / настроения.
- **Коллекция**: История, Лайки, Дизлайки, **Скачанное**.
- **Коллекция**: кнопка **Плейлисты**, затем История, Лайки, Дизлайки, **Скачанное**. Плейлисты — [playlists.md](playlists.md).
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
очередь загрузок с пометкой «по Wi-Fi».
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
+28
View File
@@ -0,0 +1,28 @@
# Личные плейлисты
Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
## API
- `GET /api/v1/me/playlists` — сводки личных плейлистов, последние изменённые первыми.
- `POST /api/v1/me/playlists` — создание: `name`, `description`, `track_ids`.
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
- `DELETE /api/v1/me/playlists/{id}` — удаление.
Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками.
Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы.
Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
## Проверки
- `pnpm --filter @pipu/web test src/App.test.tsx -t playlists`
- `pnpm --filter @pipu/web test:e2e e2e/playlists.spec.ts`
- `pnpm --filter @pipu/web build`
- `pnpm --filter @pipu/mobile typecheck`
- В `services/api`: `go test ./internal/playback ./internal/server`
- С отдельной тестовой PostgreSQL/pgvector: `TEST_DATABASE_URL=… go test -tags integration ./internal/playback -run TestPlaylistPersistenceAndIsolation -v`. Проверяет сохранение порядка, изоляцию владельцев, откат при несуществующем треке, конфликт конкурентных записей и каскадное удаление членства.
+4 -3
View File
@@ -23,7 +23,7 @@
apps/web/src/
├── app/ # провайдеры, роутер, глобальные стили, гарды
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
│ # collection/{history,likes,dislikes}, settings,
│ # collection/{playlists,history,likes,dislikes}, playlist, track, 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, ...
@@ -37,8 +37,8 @@ apps/web/src/
### App shell
Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
Админка по роли) / контент / плеер.
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → Плейлисты / История / Лайки / Дизлайки,
Админка по роли) / контент / плеер. Личные плейлисты — [playlists.md](playlists.md).
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
@@ -55,6 +55,7 @@ apps/web/src/
- Два элемента `<audio>` для предзагрузки следующего трека и бесшовного перехода (опционально — кроссфейд 0–6 с в настройках).
- Media Session API: метаданные, обложка, кнопки ОС / наушников.
- Горячие клавиши: пробел — пауза, ←/→ — перемотка 10 с, Shift+←/→ — трек, L — лайк.
- «Поделиться» копирует адрес страницы трека (`/track/{id}`) без токенов стрима; на телефоне в браузере открывается системный лист.
- Логика очереди, событий, сохранения позиции — из `packages/core` (общая с мобильным); веб реализует
только адаптер `AudioEngine`.