feat: add personal playlists and shareable track links
EOF Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
3a097c15c8
commit
fa79394869
46 files changed
+2307
-292
No files matched your search
+2
-2
@@ -57,19 +57,19 @@
|
||||
### Левая панель навигации
|
||||
- **Главная**
|
||||
- **Коллекция** — с подкатегориями:
|
||||
- **Плейлисты** — личные подборки треков;
|
||||
- **История** — прослушанные треки;
|
||||
- **Лайки** — понравившиеся треки;
|
||||
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
|
||||
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
|
||||
- **Админка** — видна только администраторам и модераторам.
|
||||
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
|
||||
|
||||
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
|
||||
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
|
||||
|
||||
### Плеер (всегда внизу)
|
||||
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
|
||||
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь.
|
||||
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь.
|
||||
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
|
||||
и даже на другом устройстве можно продолжить с того же места.
|
||||
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
|
||||
|
||||
@@ -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-приложение, офлайн, самообновление |
|
||||
|
||||
@@ -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
@@ -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
@@ -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 / сотовая / загрузки),
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
|
||||
Reference in new issue
Block a user