Compare commits

...
17 Commits
Author SHA1 Message Date
mrleo1nidandCursor fa79394869 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>
2026-10-21 23:37:53 +03:00
mrleo1nid 3a097c15c8 perf(web): preload restored audio before playback
ci / ts / ts (packages + web) (push) Successful in 2m20s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m43s
2026-10-21 22:49:10 +03:00
mrleo1nid 20abe20686 fix(mobile): prevent crash when restoring a saved playback queue
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m32s
ci / ts / ts (packages + web) (push) Successful in 2m49s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Successful in 4m20s
2026-10-21 18:41:48 +03:00
mrleo1nid c34272d508 fix(auth): allow admins to change any username including their own
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 2m59s
ci / api / api (push) Successful in 49s
ci / db / db (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
2026-10-21 17:33:19 +03:00
mrleo1nid b0cbd0f149 fix: repair Android startup and improve mobile web layouts
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 2m22s
release / android (APK в релиз) (push) Successful in 4m15s
release / release (версия и заметки) (push) Successful in 6s
2026-10-21 17:14:05 +03:00
mrleo1nid 4c2286e70d feat(web): react wave and ambient visuals to live audio
ci / ts / ts (packages + web) (push) Failing after 2m2s
2026-10-21 16:44:04 +03:00
mrleo1nid 9223516547 fix(web): soften wave glow and follow liquid shape
ci / ts / ts (packages + web) (push) Successful in 2m25s
2026-10-21 16:33:22 +03:00
mrleo1nidandClaude Opus 5.5 7b7ce78de8 fix(web): align the upload queue settings fields
ci / ts / ts (packages + web) (push) Successful in 1m53s
Labels that wrapped to two lines pushed their inputs down, the storage
select had its own label style, and the checkbox sat in the middle of its
cell. The section now lays label, input and hint rows out with subgrid, and
the select no longer stretches past its column.

Also add a web-fake launch config that runs the web app on the fake API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:44:20 +03:00
mrleo1nidandClaude Opus 5.5 4d2c6ce22f fix(web): treat AIFF and WavPack as lossless in the upload format hint
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m11s
The web check only accepted FLAC and WAV, so AIFF files were flagged
"needs attention" although the server already counts them as lossless.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:35:43 +03:00
mrleo1nidandClaude Opus 5.5 dca7173a88 ci(ts): install Playwright system deps and cache the browser
ci / ts / ts (packages + web) (push) Successful in 3m30s
E2e failed to launch Chromium on the runner (missing libnspr4.so). Install
with --with-deps and cache ~/.cache/ms-playwright, since the primary download
host is unreachable from the runner and the fallback takes minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:25:21 +03:00
mrleo1nid 1e3b350dfc feat(upload): enhance audio upload handling and user feedback
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m1s
ci / db / db (push) Successful in 1m16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / api / api (push) Successful in 45s
ci / recommender / recommender (push) Successful in 1m50s
ci / ts / ts (packages + web) (push) Failing after 6m53s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m13s
- Introduced a new warning for users uploading non-FLAC or WAV formats, indicating that these formats should only be used as a last resort.
- Added functionality to highlight files needing format attention in the upload queue.
- Updated the UI to provide clearer guidance on acceptable audio formats during uploads.
- Improved documentation to reflect changes in upload requirements and user interface updates.
2026-10-21 10:07:43 +03:00
mrleo1nidandClaude Opus 5.5 74da457362 feat(auth): make upload review a separate grant instead of an admin privilege
Reviewing the upload queue is now a per-user flag (reviews_uploads), not part
of the admin role: several people can hold it, a moderator can have it, and an
admin may not. Only admins grant or revoke it; existing admins keep it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 10:07:05 +03:00
mrleo1nidandCursor 066ba24834 feat: finish the upload, playback, and test gaps from the docs
ci / api / api (push) Successful in 40s
ci / db / db (push) Successful in 1m3s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 1m48s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 46s
ci / ts / ts (packages + web) (push) Failing after 4m57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 5m7s
Quality upgrades, batch covers, staging preview, and resume after reload are in the queue. Loudness and crossfade work on web and Android, and the documented checks now have suites.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:37:53 +03:00
mrleo1nidandCursor 2b76060ea3 fix(web): keep menu labels on one line and fold the admin section by default
ci / ts / ts (packages + web) (push) Successful in 1m27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:06:33 +03:00
mrleo1nidandCursor dfc72b1d69 chore(deploy): leave database backups to external tools
ci / ts / ts (packages + web) (push) Successful in 54s
ci / db / db (push) Successful in 1m40s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m51s
The app no longer dumps Postgres, restores it, or checks copies in CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 09:03:06 +03:00
mrleo1nidandClaude Opus 5.5 978f03b8f8 feat(admin): reset a user's recommendations from the users panel
ci / api / api (push) Successful in 40s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m10s
ci / db / db (push) Successful in 2m35s
ci / ts / ts (packages + web) (push) Successful in 1m23s
- POST /admin/users/{id}/reset-recommendations clears the taste profile
  (onboarding, CF vector, bandit), play stats and wave impressions; likes,
  dislikes and history stay, wave sessions stay so a playing wave keeps going
- moderators may reset plain users, admins anyone including themselves;
  the reset is written to the audit log
- users panel gets the menu action with a confirmation; docs updated

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:52:31 +03:00
mrleo1nidandClaude Opus 5.5 b27b915b63 feat(web): settings to keep the search open and hide home pickers; collapsible menu sections
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m44s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m43s
ci / ts / ts (packages + web) (push) Successful in 1m16s
ci / api / api (push) Successful in 2m27s
ci / db / db (push) Successful in 2m23s
- appearance settings get "collapse search" (off keeps the field always open)
  and separate switches for the activity and mood pickers on the home page;
  stored in ui_prefs (search_collapse, home_activities, home_moods)
- Collection and Admin headers in the sidebar fold their items; the state is
  remembered in the browser, a folded Admin shows the review queue badge;
  icon-only and phone layouts always show every item

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 08:26:48 +03:00
177 changed files with 6938 additions and 6602 deletions

No files matched your search

+32 -3
View File
@@ -4,14 +4,43 @@
{ {
"name": "design", "name": "design",
"runtimeExecutable": "python", "runtimeExecutable": "python",
"runtimeArgs": ["-m", "http.server", "8765", "--bind", "127.0.0.1", "--directory", "docs"], "runtimeArgs": [
"-m",
"http.server",
"8765",
"--bind",
"127.0.0.1",
"--directory",
"docs"
],
"port": 8765 "port": 8765
}, },
{ {
"name": "web", "name": "web",
"runtimeExecutable": "pnpm", "runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@pipu/web", "dev"], "runtimeArgs": [
"--filter",
"@pipu/web",
"dev"
],
"port": 5173 "port": 5173
},
{
"name": "web-fake",
"runtimeExecutable": "pnpm",
"runtimeArgs": [
"--filter",
"@pipu/web",
"exec",
"vite",
"--port",
"4173",
"--strictPort"
],
"env": {
"VITE_E2E": "1"
},
"port": 4173
} }
] ]
} }
+1 -1
View File
@@ -1,4 +1,4 @@
# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта. # Go: сервис api и storage-agent. Только при изменении их кода или контракта.
name: ci / api name: ci / api
on: on:
+1 -1
View File
@@ -1,4 +1,4 @@
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md). # Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
name: ci / contract name: ci / contract
on: on:
-25
View File
@@ -10,13 +10,11 @@ on:
paths: paths:
- 'services/api/**' - 'services/api/**'
- 'services/recommender/**' - 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml' - '.gitea/workflows/ci-db.yml'
pull_request: pull_request:
paths: paths:
- 'services/api/**' - 'services/api/**'
- 'services/recommender/**' - 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml' - '.gitea/workflows/ci-db.yml'
concurrency: concurrency:
@@ -84,26 +82,3 @@ jobs:
run: | run: |
uv sync --frozen uv sync --frozen
uv run pytest -q tests/test_db_integration.py uv run pytest -q tests/test_db_integration.py
# База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление.
- name: Бэкап, проверка и восстановление (deploy/backup.sh)
env:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: pipu
PGDATABASE: pipu
BACKUP_DIR: /tmp/backups
VERIFY_EXACT: "1"
run: |
SUDO=$(command -v sudo || true)
$SUDO install -d /usr/share/postgresql-common/pgdg
$SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null
export PATH=/usr/lib/postgresql/17/bin:$PATH
before=$(psql -Atc "SELECT count(*) FROM tracks")
file=$(bash deploy/backup.sh once)
bash deploy/backup.sh verify "$file"
bash deploy/backup.sh restore "$file"
after=$(psql -Atc "SELECT count(*) FROM tracks")
echo "tracks: $before → $after"
test "$before" = "$after"
+1 -1
View File
@@ -1,4 +1,4 @@
# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml. # Expo: expo-doctor и Metro-бандл Android. Полная сборка APK — только в release.yml.
name: ci / mobile name: ci / mobile
on: on:
+1 -1
View File
@@ -1,4 +1,4 @@
# Python: recommender и analyzer (docs/ci-cd.md). # Python: recommender и analyzer.
name: ci / recommender name: ci / recommender
on: on:
+9 -1
View File
@@ -1,4 +1,4 @@
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md). # TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза.
name: ci / ts name: ci / ts
on: on:
@@ -51,3 +51,11 @@ jobs:
- run: pnpm typecheck - run: pnpm typecheck
- run: pnpm test - run: pnpm test
- run: pnpm --filter @pipu/web build - run: pnpm --filter @pipu/web build
# Браузер качается долго (основной CDN Microsoft из раннера недоступен) — кэшируем по lock-файлу.
# Системные библиотеки (libnspr4 и т.п.) в образе раннера нет, их ставит --with-deps на каждом прогоне.
- uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
- run: pnpm --filter @pipu/web test:e2e
+1 -1
View File
@@ -49,7 +49,7 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и # Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги # --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md). # не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
- name: Сборка образов - name: Сборка образов
env: { DOCKER_BUILDKIT: "1" } env: { DOCKER_BUILDKIT: "1" }
run: | run: |
+37
View File
@@ -0,0 +1,37 @@
# Ночные проверки, которым нужен поднятый стенд или эмулятор. На каждый push не запускаются.
name: nightly / e2e
on:
workflow_dispatch:
schedule:
- cron: "0 3 * * *"
jobs:
schemathesis:
name: schemathesis
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install schemathesis
- name: Фаззинг API
env:
PIPU_API_URL: ${{ vars.PIPU_API_URL }}
PIPU_API_TOKEN: ${{ secrets.PIPU_API_TOKEN }}
run: |
if [ -z "$PIPU_API_URL" ]; then echo "PIPU_API_URL is not set"; exit 0; fi
sh tests/contract/schemathesis.sh
maestro:
name: maestro
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Сценарии Android
env:
PIPU_E2E_SERVER: ${{ vars.PIPU_E2E_SERVER }}
PIPU_E2E_USER: ${{ vars.PIPU_E2E_USER }}
PIPU_E2E_PASSWORD: ${{ secrets.PIPU_E2E_PASSWORD }}
run: |
if [ -z "$PIPU_E2E_SERVER" ]; then echo "emulator flow is configured in apps/mobile/.maestro"; exit 0; fi
curl -Ls "https://get.maestro.mobile.dev" | bash
~/.maestro/bin/maestro test apps/mobile/.maestro
+2 -2
View File
@@ -1,4 +1,4 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): Gitea Release с заметками → APK. # Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы # Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs). # и переписывает статус: собран / не удалась (scripts/release.mjs).
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
@@ -53,7 +53,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK # Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома # переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново. # (container.valid_volumes: ['/opt/ci-cache/**']); иначе раннер их пропускает и всё качается заново.
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail) # с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
defaults: defaults:
run: run:
+6
View File
@@ -27,6 +27,8 @@ __pycache__/
# generated test audio # generated test audio
tests/fixtures/music/out/ tests/fixtures/music/out/
apps/web/test-results/
apps/web/playwright-report/
# os / editors # os / editors
.DS_Store .DS_Store
@@ -35,3 +37,7 @@ Thumbs.db
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.cache/ .cache/
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/
+2 -3
View File
@@ -4,8 +4,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения. умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
- Идея продукта: [docs/IDEA.md](docs/IDEA.md) - Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация для разработки: [docs/README.md](docs/README.md) - Документация: [docs/README.md](docs/README.md)
- План: [docs/roadmap.md](docs/roadmap.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md) - Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура ## Структура
@@ -67,4 +66,4 @@ pnpm generate && (cd services/api && go generate ./...)
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него. Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`, Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md). [docs/install-guide.md](docs/install-guide.md)). Нужные секреты перечислены в шапке `.gitea/workflows/release.yml`.
+290 -7
View File
@@ -359,6 +359,22 @@ paths:
$ref: "#/components/schemas/TemporaryPassword" $ref: "#/components/schemas/TemporaryPassword"
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/admin/users/{id}/reset-recommendations:
parameters:
- $ref: "#/components/parameters/UserId"
post:
operationId: resetUserRecommendations
tags: [admin]
summary: Сбросить рекомендации
description: |
Рекомендации начинаются с нуля: стираются профиль вкуса (онбординг, CF-вектор, бандит),
статистика прослушиваний и показы волны; онбординг покажется снова.
Лайки, дизлайки и история остаются.
responses:
"204":
description: Сброшено
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/quota: /api/v1/uploads/quota:
get: get:
operationId: getUploadQuota operationId: getUploadQuota
@@ -435,6 +451,35 @@ paths:
$ref: "#/components/schemas/UploadFilePage" $ref: "#/components/schemas/UploadFilePage"
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/uploads/unseen:
get:
operationId: unseenUploadDecisions
tags: [uploads]
summary: Сколько решений по своим предложениям с прошлого визита (moderator, admin)
responses:
"200":
description: Число
content:
application/json:
schema:
type: object
required: [count]
properties:
count:
type: integer
minimum: 0
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/seen:
post:
operationId: markUploadsSeen
tags: [uploads]
summary: Отметить свои предложения просмотренными
responses:
"204":
description: Отмечено
default:
$ref: "#/components/responses/Problem"
/api/v1/uploads/files/{id}: /api/v1/uploads/files/{id}:
parameters: parameters:
- $ref: "#/components/parameters/EntityId" - $ref: "#/components/parameters/EntityId"
@@ -519,6 +564,65 @@ paths:
$ref: "#/components/schemas/UploadFile" $ref: "#/components/schemas/UploadFile"
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/admin/uploads/files/{id}/stream:
get:
operationId: streamUpload
tags: [uploads]
security: []
summary: Прослушать файл из staging (подпись, как у стрима трека)
description: Ссылку даёт `stream_url` файла очереди. Range поддерживается.
parameters:
- $ref: "#/components/parameters/EntityId"
- name: exp
in: query
required: true
schema:
type: integer
format: int64
- name: sig
in: query
required: true
schema:
type: string
responses:
"200":
description: Аудио из staging
content:
audio/*:
schema:
type: string
format: binary
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/batches/{id}:
parameters:
- $ref: "#/components/parameters/EntityId"
patch:
operationId: updateUploadBatch
tags: [uploads]
summary: Правка метаданных и обложки на всю пачку (admin)
description: Исполнитель, альбом и год применяются к файлам в pending_review и import_failed. Обложка встраивается при импорте.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UploadBatchPatch"
responses:
"200":
description: Обновлённые файлы пачки
content:
application/json:
schema:
type: object
required: [items]
properties:
items:
type: array
items:
$ref: "#/components/schemas/UploadFile"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/uploads/review: /api/v1/admin/uploads/review:
post: post:
operationId: reviewUploads operationId: reviewUploads
@@ -1434,6 +1538,82 @@ paths:
format: binary format: binary
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/me/playlists:
get:
tags: [collection]
operationId: listPlaylists
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistList'
post:
tags: [collection]
operationId: createPlaylist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistInput'
responses:
default:
$ref: '#/components/responses/Problem'
'201':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
/api/v1/me/playlists/{id}:
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
get:
tags: [collection]
operationId: getPlaylist
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
put:
tags: [collection]
operationId: updatePlaylist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistUpdate'
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
delete:
tags: [collection]
operationId: deletePlaylist
responses:
default:
$ref: '#/components/responses/Problem'
'204':
description: Deleted
components: components:
parameters: parameters:
SourceId: SourceId:
@@ -1494,6 +1674,53 @@ components:
schema: schema:
$ref: "#/components/schemas/Problem" $ref: "#/components/schemas/Problem"
schemas: schemas:
PlaylistInput:
type: object
required: [name, description, track_ids]
properties:
name: { type: string, minLength: 1, maxLength: 120 }
description: { type: string, maxLength: 2000 }
track_ids:
type: array
maxItems: 2000
uniqueItems: true
items: { type: string, format: uuid }
PlaylistUpdate:
allOf:
- $ref: '#/components/schemas/PlaylistInput'
- type: object
required: [revision]
properties:
revision: { type: integer, minimum: 1 }
PlaylistSummary:
type: object
required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at]
properties:
id: { type: string, format: uuid }
name: { type: string }
description: { type: string }
revision: { type: integer }
track_count: { type: integer }
duration_ms: { type: integer, format: int64 }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Playlist:
allOf:
- $ref: '#/components/schemas/PlaylistSummary'
- type: object
required: [tracks]
properties:
tracks:
type: array
items: { $ref: '#/components/schemas/Track' }
PlaylistList:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/PlaylistSummary' }
Onboarding: Onboarding:
type: object type: object
required: [required, artists, genres] required: [required, artists, genres]
@@ -1604,6 +1831,9 @@ components:
type: string type: string
duration_ms: duration_ms:
type: integer type: integer
album:
type: string
maxLength: 500
UploadPrecheckResult: UploadPrecheckResult:
type: object type: object
required: [files] required: [files]
@@ -1717,7 +1947,7 @@ components:
nullable: true nullable: true
UploadFile: UploadFile:
type: object type: object
required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url] required: [id, batch_id, batch_comment, author, status, original_name, client_rel_path, size_bytes, received_bytes, format, codec, bitrate, sample_rate, bit_depth, duration_ms, tags, metadata, metadata_edited, matched_artist_id, matched_album_id, duplicate_of_track_id, duplicate_level, possible_duplicate, quality_upgrade, target_source_id, target_rel_path, status_reason, reviewed_at, created_at, updated_at, tus_url, stream_url]
properties: properties:
id: id:
type: string type: string
@@ -1791,6 +2021,9 @@ components:
enum: [sha256, metadata, fingerprint, null] enum: [sha256, metadata, fingerprint, null]
possible_duplicate: possible_duplicate:
type: boolean type: boolean
quality_upgrade:
type: boolean
description: Тот же трек, но лучшего качества — ждёт решения, если включён allow_quality_upgrade
target_source_id: target_source_id:
type: string type: string
format: uuid format: uuid
@@ -1816,6 +2049,10 @@ components:
type: string type: string
nullable: true nullable: true
description: Только автору и пока файл в статусе uploading description: Только автору и пока файл в статусе uploading
stream_url:
type: string
nullable: true
description: Подписанная ссылка прослушивания из staging; null, если файла уже нет
UploadFilePage: UploadFilePage:
type: object type: object
required: [items, next_cursor] required: [items, next_cursor]
@@ -1835,6 +2072,23 @@ components:
target_source_id: target_source_id:
type: string type: string
format: uuid format: uuid
UploadBatchPatch:
type: object
description: Пустые поля не меняют файлы. cover_base64 null снимает обложку пачки.
properties:
artist:
type: string
maxLength: 500
album:
type: string
maxLength: 500
year:
type: integer
nullable: true
cover_base64:
type: string
nullable: true
description: JPEG, PNG или WebP, не больше 8 МБ после декодирования
UploadReviewRequest: UploadReviewRequest:
type: object type: object
required: [file_ids, decision] required: [file_ids, decision]
@@ -1874,7 +2128,7 @@ components:
- $ref: "#/components/schemas/Problem" - $ref: "#/components/schemas/Problem"
UploadSettings: UploadSettings:
type: object type: object
required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id] required: [queue_limit_bytes, per_user_limit_bytes, max_file_bytes, default_source_id, allow_quality_upgrade]
properties: properties:
queue_limit_bytes: queue_limit_bytes:
type: integer type: integer
@@ -1890,6 +2144,9 @@ components:
type: string type: string
format: uuid format: uuid
nullable: true nullable: true
allow_quality_upgrade:
type: boolean
description: Тот же трек в лучшем качестве идёт на рассмотрение, а не в дубли
UploadSettingsPatch: UploadSettingsPatch:
type: object type: object
description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано). description: Лимит на пользователя и хранилище по умолчанию передаются всегда (null — выключить / не задано).
@@ -1913,6 +2170,8 @@ components:
format: uuid format: uuid
nullable: true nullable: true
description: null — без хранилища по умолчанию description: null — без хранилища по умолчанию
allow_quality_upgrade:
type: boolean
HealthStatus: HealthStatus:
type: object type: object
required: [status] required: [status]
@@ -1964,7 +2223,7 @@ components:
enum: [admin, moderator, user] enum: [admin, moderator, user]
ClientType: ClientType:
type: string type: string
enum: [web, android] enum: [web, android, desktop]
LoginRequest: LoginRequest:
type: object type: object
required: [username, password, client_type] required: [username, password, client_type]
@@ -1984,7 +2243,7 @@ components:
properties: properties:
refresh_token: refresh_token:
type: string type: string
description: Только Android; веб передаёт токен в cookie description: Android и desktop; веб передаёт токен в cookie
AuthTokens: AuthTokens:
type: object type: object
required: [access_token, token_type, expires_in, user] required: [access_token, token_type, expires_in, user]
@@ -1999,7 +2258,7 @@ components:
description: Срок жизни access-токена, секунды description: Срок жизни access-токена, секунды
refresh_token: refresh_token:
type: string type: string
description: "Только для `client_type: android`" description: "Для `client_type: android` и `client_type: desktop`"
user: user:
$ref: "#/components/schemas/Me" $ref: "#/components/schemas/Me"
UiPrefs: UiPrefs:
@@ -2018,6 +2277,15 @@ components:
type: string type: string
enum: [center, left, right] enum: [center, left, right]
description: Где в топбаре веба поиск (по умолчанию center) description: Где в топбаре веба поиск (по умолчанию center)
search_collapse:
type: boolean
description: Сворачивать поиск в кнопку с лупой без фокуса (по умолчанию false)
home_activities:
type: boolean
description: Показывать на главной веба выбор занятия (по умолчанию true)
home_moods:
type: boolean
description: Показывать на главной веба выбор настроения (по умолчанию true)
mobile_player: mobile_player:
type: string type: string
enum: [cover, wave] enum: [cover, wave]
@@ -2031,7 +2299,7 @@ components:
type: boolean type: boolean
Me: Me:
type: object type: object
required: [id, username, display_name, role, locale, ui_prefs, must_change_password, must_change_credentials, created_at] required: [id, username, display_name, role, reviews_uploads, locale, ui_prefs, must_change_password, must_change_credentials, created_at]
properties: properties:
id: id:
type: string type: string
@@ -2043,6 +2311,9 @@ components:
nullable: true nullable: true
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
reviews_uploads:
type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
locale: locale:
type: string type: string
ui_prefs: ui_prefs:
@@ -2083,7 +2354,7 @@ components:
type: string type: string
AdminUser: AdminUser:
type: object type: object
required: [id, username, display_name, role, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at] required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
properties: properties:
id: id:
type: string type: string
@@ -2095,6 +2366,9 @@ components:
nullable: true nullable: true
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
reviews_uploads:
type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
locale: locale:
type: string type: string
is_blocked: is_blocked:
@@ -2132,6 +2406,9 @@ components:
maxLength: 64 maxLength: 64
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
reviews_uploads:
type: boolean
description: Сразу выдать аудит загрузок. Может задать только админ
CreatedUser: CreatedUser:
type: object type: object
required: [user, temporary_password] required: [user, temporary_password]
@@ -2144,6 +2421,9 @@ components:
UpdateUserRequest: UpdateUserRequest:
type: object type: object
properties: properties:
username:
type: string
description: Новый логин. Может менять только администратор, в том числе у себя
display_name: display_name:
type: string type: string
maxLength: 64 maxLength: 64
@@ -2152,6 +2432,9 @@ components:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
is_blocked: is_blocked:
type: boolean type: boolean
reviews_uploads:
type: boolean
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
TemporaryPassword: TemporaryPassword:
type: object type: object
required: [temporary_password] required: [temporary_password]
+5
View File
@@ -0,0 +1,5 @@
appId: app.pipu.music
---
- runFlow: login.yaml
- tapOn: "Коллекция"
- assertVisible: "Лайки"
+15
View File
@@ -0,0 +1,15 @@
appId: app.pipu.music
---
- launchApp:
clearState: true
- assertVisible: "Адрес сервера"
- tapOn: "Адрес сервера"
- inputText: ${PIPU_E2E_SERVER}
- tapOn: "Подключиться"
- assertVisible: "Вход"
- tapOn: "Логин"
- inputText: ${PIPU_E2E_USER}
- tapOn: "Пароль"
- inputText: ${PIPU_E2E_PASSWORD}
- tapOn: "Войти"
- assertVisible: "Бегаю"
+10
View File
@@ -0,0 +1,10 @@
appId: app.pipu.music
---
- launchApp:
clearState: true
- assertVisible: "Адрес сервера|Server address"
- stopApp
- launchApp
- assertVisible: "Адрес сервера|Server address"
- openLink: "pipu://album/1"
- assertVisible: "Адрес сервера|Server address"
+6
View File
@@ -0,0 +1,6 @@
appId: app.pipu.music
---
- runFlow: login.yaml
- tapOn: "Профиль"
- assertVisible: "Проверить обновления"
- tapOn: "Проверить обновления"
+3 -1
View File
@@ -30,7 +30,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
'expo-secure-store', 'expo-secure-store',
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка // Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
// Android по умолчанию запрещает незашифрованный HTTP. // Android по умолчанию запрещает незашифрованный HTTP.
['expo-build-properties', { android: { usesCleartextTraffic: true } }], // Распаковываем .so при установке: SoLoader на x86_64 с ARM-трансляцией ищет
// внутри APK lib/x86_64 вместо arm64-v8a и не находит libreactnative.so.
['expo-build-properties', { android: { usesCleartextTraffic: true, useLegacyPackaging: true } }],
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js). // APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
'./plugins/withArm64Only', './plugins/withArm64Only',
], ],
+4 -2
View File
@@ -7,7 +7,8 @@
"start": "expo start --dev-client", "start": "expo start --dev-client",
"android": "expo run:android", "android": "expo run:android",
"prebuild": "expo prebuild --platform android --clean", "prebuild": "expo prebuild --platform android --clean",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
@@ -42,7 +43,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.20.5", "@types/node": "^22.20.5",
"@types/react": "~19.0.10" "@types/react": "~19.0.10",
"vitest": "^3.2.7"
}, },
"expo": { "expo": {
"doctor": { "doctor": {
@@ -1,4 +1,5 @@
import { unwrap, type Track } from '@pipu/core'; import { unwrap, type Track } from '@pipu/core';
import { useRouter } from 'expo-router';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
const session = useSession(); const session = useSession();
const current = usePlayerState().track; const current = usePlayerState().track;
const [tab, setTab] = useState<Tab>('likes'); const [tab, setTab] = useState<Tab>('likes');
const router = useRouter();
const list = useInfiniteQuery({ const list = useInfiniteQuery({
queryKey: ['m-collection', tab], queryKey: ['m-collection', tab],
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const; const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
const tabs = ( const tabs = (
<View style={{ gap: 12 }}>
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
<Segmented<Tab> <Segmented<Tab>
value={tab} value={tab}
onChange={setTab} onChange={setTab}
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
{ value: 'dislikes', label: t('collection.dislikes') }, { value: 'dislikes', label: t('collection.dislikes') },
]} ]}
/> />
</View>
); );
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />; if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
+10
View File
@@ -116,6 +116,16 @@ export default function ProfileScreen() {
onChange={(v) => void setPrefs({ mobile_player: v })} onChange={(v) => void setPrefs({ mobile_player: v })}
/> />
</Row> </Row>
<Row label={t('settings.normalize')}>
<Switch value={user.ui_prefs.normalize !== false} onValueChange={(v) => void setPrefs({ normalize: v })} />
</Row>
<Row label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}>
<Segmented
value={String([0, 2, 4, 6].includes(user.ui_prefs.crossfade_s ?? 0) ? (user.ui_prefs.crossfade_s ?? 0) : 0)}
options={['0', '2', '4', '6'].map((v) => ({ value: v, label: v }))}
onChange={(v) => void setPrefs({ crossfade_s: Number(v) })}
/>
</Row>
</Section> </Section>
<Section> <Section>
+23 -15
View File
@@ -1,6 +1,6 @@
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n'; import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Stack, useRouter, useSegments } from 'expo-router'; import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar'; import { StatusBar } from 'expo-status-bar';
import type { i18n as I18n } from 'i18next'; import type { i18n as I18n } from 'i18next';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -54,8 +54,6 @@ const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly stri
function Gate() { function Gate() {
const { server, session, ready } = useSessionCtx(); const { server, session, ready } = useSessionCtx();
const auth = useAuthState(); const auth = useAuthState();
const segments = useSegments();
const router = useRouter();
const { i18n } = useTranslation(); const { i18n } = useTranslation();
const user = auth.status === 'signed_in' ? auth.user : null; const user = auth.status === 'signed_in' ? auth.user : null;
const system = useColorScheme(); const system = useColorScheme();
@@ -79,17 +77,6 @@ function Gate() {
return () => sub.remove(); return () => sub.remove();
}, []); }, []);
useEffect(() => {
if (!ready) return;
const first = segments[0] as string | undefined;
let target: string | null = null;
if (!server) target = first === 'connect' ? null : '/connect';
else if (!user) target = first === 'login' || first === 'connect' ? null : '/login';
else if (user.must_change_credentials || user.must_change_password) target = first === 'setup' ? null : '/setup';
else if (first === 'connect' || first === 'login' || first === 'setup') target = '/';
if (target) router.replace(target as never);
}, [ready, server, user?.id, user?.must_change_credentials, user?.must_change_password, segments]);
if (!ready) return <Splash />; if (!ready) return <Splash />;
return ( return (
<ThemeContext.Provider value={theme}> <ThemeContext.Provider value={theme}>
@@ -101,9 +88,30 @@ function Gate() {
function Screens() { function Screens() {
const colors = useColors(); const colors = useColors();
const { server, session } = useSessionCtx();
const auth = useAuthState();
const connected = !!server && !!session;
const user = connected && auth.status === 'signed_in' ? auth.user : null;
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
return ( return (
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} /> {/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
<Stack.Protected guard={!connected}>
<Stack.Screen name="connect" />
</Stack.Protected>
<Stack.Protected guard={connected && !user}>
<Stack.Screen name="login" />
</Stack.Protected>
<Stack.Protected guard={needsSetup}>
<Stack.Screen name="setup" />
</Stack.Protected>
<Stack.Protected guard={!!user && !needsSetup}>
<Stack.Screen name="(tabs)" />
<Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" />
<Stack.Screen name="playlists" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected>
</Stack> </Stack>
); );
} }
+22 -3
View File
@@ -1,9 +1,10 @@
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native'; import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
import { trackShareUrl } from '@pipu/core';
import { useUser } from '../lib/session'; import { useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller'; import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks'; import { usePlayerState } from '../player/hooks';
import { useColors } from '../theme'; import { useColors } from '../theme';
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
const router = useRouter(); const router = useRouter();
const st = usePlayerState(); const st = usePlayerState();
const prefs = useUser().ui_prefs; const prefs = useUser().ui_prefs;
const { server } = useSessionCtx();
const [sharing, setSharing] = useState(false);
const [barWidth, setBarWidth] = useState(1); const [barWidth, setBarWidth] = useState(1);
const track = st.track; const track = st.track;
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
const duration = st.durationMs || track.duration_ms; const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0; const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined); const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
if (!server || sharing) return;
setSharing(true);
try {
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
} catch {
Alert.alert(t('player.share'), t('player.share_error'));
} finally {
setSharing(false);
}
};
return ( return (
<Screen> <Screen>
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
<Icon name="keyboard-arrow-down" size={32} color={colors.text} /> <Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable> </Pressable>
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text> <Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
<DownloadButton track={track} /> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
<Icon name="share" size={24} color={colors.text} />
</Pressable>
<DownloadButton track={track} />
</View>
</View> </View>
<View style={{ alignItems: 'center' }}> <View style={{ alignItems: 'center' }}>
+129
View File
@@ -0,0 +1,129 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
import { useSession, useUser } from '../lib/session';
import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
type Playlist = components['schemas']['Playlist'];
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
export default function PlaylistsScreen() {
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
}
function List({ track }: { track?: string }) {
const session = useSession();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [create, setCreate] = useState(false);
const [search, setSearch] = useState('');
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
return <Screen><FlatList
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
contentContainerStyle={{ padding: 16, gap: 12 }}
ListHeaderComponent={<View style={{ gap: 12 }}>
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
</View>}
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
}} />}</Screen>;
}
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
const session = useSession();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [original] = useState(playlist);
const [name, setName] = useState(playlist?.name ?? '');
const [description, setDescription] = useState(playlist?.description ?? '');
const save = useMutation({ mutationFn: async () => {
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
</ScrollView></Screen></Modal>;
}
function Detail({ id }: { id: string }) {
const session = useSession();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const [edit, setEdit] = useState(false);
const [manage, setManage] = useState(false);
const [playError, setPlayError] = useState<unknown>();
const key = ['m-playlists', user.id, id];
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
const p = query.data;
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
const play = (shuffle: boolean) => {
const tracks = p?.tracks.filter(t => t.available) ?? [];
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
};
const move = (index: number, delta: number) => {
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
};
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
])} />
</>}
</View>}
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
{manage && <View style={{padding:12,gap:6}}>
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
</View>}
</View>}
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
}
+7
View File
@@ -0,0 +1,7 @@
export type ReleaseNeed = 'current' | 'optional' | 'required';
/** Нужно ли обновление: обязательное, если установленная версия ниже min_supported. */
export function releaseNeed(installed: number, versionCode: number, minSupported: number): ReleaseNeed {
if (versionCode <= installed) return 'current';
return installed < minSupported ? 'required' : 'optional';
}
+5 -2
View File
@@ -6,6 +6,8 @@ import * as IntentLauncher from 'expo-intent-launcher';
import * as SecureStore from 'expo-secure-store'; import * as SecureStore from 'expo-secure-store';
import { sha256 } from 'js-sha256'; import { sha256 } from 'js-sha256';
import { releaseNeed } from './releaseNeed';
export type AndroidRelease = components['schemas']['AndroidRelease']; export type AndroidRelease = components['schemas']['AndroidRelease'];
/** Состояние самообновления (docs/mobile.md#самообновление). */ /** Состояние самообновления (docs/mobile.md#самообновление). */
@@ -96,11 +98,12 @@ class Updater {
const release = unwrap(await session.client.GET('/api/v1/app/android/latest')); const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now())); await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
const installed = installedVersionCode(); const installed = installedVersionCode();
if (release.version_code > installed) { const need = releaseNeed(installed, release.version_code, release.min_supported);
if (need !== 'current') {
this.set({ this.set({
status: 'available', status: 'available',
release, release,
required: installed < release.min_supported, required: need === 'required',
dismissed: false, dismissed: false,
}); });
} else { } else {
+22 -10
View File
@@ -2,6 +2,7 @@ import NetInfo from '@react-native-community/netinfo';
import { import {
EventBuffer, EventBuffer,
PlayAttempt, PlayAttempt,
normalizeGain,
unwrap, unwrap,
type AuthSession, type AuthSession,
type PlayContext, type PlayContext,
@@ -18,6 +19,10 @@ import TrackPlayer, {
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs'; import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads'; import { downloads } from '../offline/downloads';
import { PersistentEvents } from '../offline/events'; import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave';
import { eventId } from './eventId';
export { offlineWave };
export interface WaveInfo { export interface WaveInfo {
sessionId: string; sessionId: string;
@@ -45,15 +50,6 @@ const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000; const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000; const SAVE_EVERY_MS = 10_000;
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
return tracks
.filter((t) => t.reaction !== 'dislike')
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
.sort((a, b) => a.key - b.key)
.map((x) => x.t);
}
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id)); const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */ /** Ссылки API относительные — для приложения нужен полный адрес сервера. */
@@ -118,7 +114,7 @@ class Controller {
this.server = server; this.server = server;
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null; this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
const sink = this.persistent; const sink = this.persistent;
this.events = sink ? new EventBuffer({ send: sink.send }) : null; this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
void sink?.drain(); void sink?.drain();
this.unsubscribeNet ??= NetInfo.addEventListener((st) => { this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
if (st.isConnected) void this.persistent?.drain(); if (st.isConnected) void this.persistent?.drain();
@@ -159,6 +155,10 @@ class Controller {
this.lastPositionMs = ms; this.lastPositionMs = ms;
this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs }); this.update({ positionMs: ms, durationMs: Math.round(duration * 1000) || this.state.durationMs });
if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save(); if (this.state.playing && Date.now() - this.lastSave > SAVE_EVERY_MS) this.save();
const cf = Math.min(6, Math.max(0, this.userPrefs()?.crossfade_s ?? 0));
const left = duration - position;
const fade = cf > 0 && left < cf ? Math.max(0, left / cf) : 1;
void this.applyVolume(this.state.track, fade);
}); });
TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => { TrackPlayer.addEventListener(Event.PlaybackActiveTrackChanged, ({ index, lastPosition }) => {
this.finishAttempt(lastPosition); this.finishAttempt(lastPosition);
@@ -167,6 +167,7 @@ class Controller {
if (!track) return; if (!track) return;
this.attempt = new PlayAttempt(track, 0); this.attempt = new PlayAttempt(track, 0);
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms }); this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
void this.applyVolume(track, 1);
this.events?.push({ this.events?.push({
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type, type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
context_id: this.state.context?.id, context_id: this.state.context?.id,
@@ -209,6 +210,17 @@ class Controller {
} }
} }
private userPrefs() {
const st = this.session?.state;
return st?.status === 'signed_in' ? st.user.ui_prefs : undefined;
}
/** Громкость 0..1: нормализация вниз к −14 LUFS и затухание кроссфейда в конце трека. */
private async applyVolume(track: Track | null, fade: number) {
const gain = Math.min(1, normalizeGain(track?.features?.loudness_lufs, this.userPrefs()?.normalize !== false) * fade);
await TrackPlayer.setVolume(gain).catch(() => undefined);
}
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> { private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality(); const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети. // Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
+9
View File
@@ -0,0 +1,9 @@
import { uuidv7 } from '@pipu/core';
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
export function eventId(now: number): string {
return uuidv7(now, () => {
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
});
}
+10
View File
@@ -0,0 +1,10 @@
import type { Track } from '@pipu/core';
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
export function offlineWave(tracks: Track[], random: () => number = Math.random): Track[] {
return tracks
.filter((t) => t.reaction !== 'dislike')
.map((t) => ({ t, key: random() * (t.reaction === 'like' ? 0.5 : 1) }))
.sort((a, b) => a.key - b.key)
.map((x) => x.t);
}
+6
View File
@@ -1,4 +1,5 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useRouter } from 'expo-router';
import { ApiError, type Track } from '@pipu/core'; import { ApiError, type Track } from '@pipu/core';
import type { ComponentProps, ReactNode } from 'react'; import type { ComponentProps, ReactNode } from 'react';
import { useState, useSyncExternalStore } from 'react'; import { useState, useSyncExternalStore } from 'react';
@@ -199,6 +200,7 @@ export function TrackRow({
const colors = useColors(); const colors = useColors();
const { t } = useTranslation(); const { t } = useTranslation();
const liked = track.reaction === 'like'; const liked = track.reaction === 'like';
const router = useRouter();
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id)); const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
return ( return (
<Pressable <Pressable
@@ -233,6 +235,10 @@ export function TrackRow({
</Pressable> </Pressable>
)} )}
{trailing} {trailing}
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
<Icon name="playlist-add" size={22} />
</Pressable>
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />} {saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text> <Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
</Pressable> </Pressable>
+19
View File
@@ -0,0 +1,19 @@
import { afterEach, expect, it, vi } from 'vitest';
import { EventBuffer } from '@pipu/core';
import { eventId } from '../src/player/eventId';
afterEach(() => vi.unstubAllGlobals());
it('queues restored-track events on Hermes without Web Crypto', () => {
vi.stubGlobal('crypto', undefined);
const nativeUuid = vi.fn()
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
vi.stubGlobal('expo', { uuidv4: nativeUuid });
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
buffer.push({ type: 'play_start', track_id: 'restored-track' });
buffer.push({ type: 'skip', track_id: 'restored-track' });
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
expect(nativeUuid).toHaveBeenCalledTimes(2);
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { releaseNeed } from '../src/lib/releaseNeed';
import { offlineWave } from '../src/player/offlineWave';
import type { Track } from '@pipu/core';
function track(id: string, reaction: Track['reaction']): Track {
return {
id,
title: id,
duration_ms: 1000,
artists: [],
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
genres: [],
disc_no: 1,
track_no: 1,
available: true,
stream_url: null,
reaction,
features: null,
};
}
describe('offline wave', () => {
it('drops dislikes and keeps likes ahead of the rest', () => {
const tracks = [track('d', 'dislike'), track('n', null), track('l', 'like')];
const order = offlineWave(tracks, () => 0.5).map((t) => t.id);
expect(order).toEqual(['l', 'n']);
});
});
describe('update dialog decision', () => {
it('marks an older install below the minimum as required', () => {
expect(releaseNeed(10, 12, 11)).toBe('required');
expect(releaseNeed(11, 12, 11)).toBe('optional');
expect(releaseNeed(12, 12, 11)).toBe('current');
});
});
+7
View File
@@ -0,0 +1,7 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
},
});
+119
View File
@@ -0,0 +1,119 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username: string) {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ещё', exact: true })).toBeVisible();
}
async function fits(page: Page) {
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
}
for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430, 'alice']] as const) {
test(`mobile ${width}: ${username} navigation, forms and search`, async ({ page }, info) => {
test.setTimeout(60_000);
await page.setViewportSize({ width, height: 844 });
await login(page, username);
await fits(page);
const bottom = page.getByRole('navigation', { name: 'Навигация' }).filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
await expect(bottom.getByRole('link')).toHaveCount(3);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
await expect(dialog).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Дизлайки', exact: true })).toBeVisible();
if (username !== 'alice') {
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
await expect(dialog.getByRole('link', { name: 'Хранилища', exact: true })).toHaveCount(username === 'mobile-admin' ? 1 : 0);
await dialog.getByRole('link', { name: 'Пользователи', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('users.png') });
await page.getByRole('button', { name: /Действия.*alice/ }).click();
const menu = page.getByRole('menu');
await expect(menu).toBeVisible();
const rect = await menu.boundingBox();
expect(rect!.x).toBeGreaterThanOrEqual(0);
expect(rect!.x + rect!.width).toBeLessThanOrEqual(width);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Создать пользователя', exact: true }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.screenshot({ path: info.outputPath('create-user.png') });
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
} else {
await expect(dialog.getByRole('link', { name: 'Пользователи', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
}
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Настройки', exact: true })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('settings.png') });
for (const name of ['Слева', 'По центру', 'Справа']) {
await page.getByRole('button', { name, exact: true }).click();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await expect(page.getByRole('option', { name: /Кино/ }).first()).toBeVisible();
await fits(page);
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Escape');
}
if (username !== 'alice') {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Очередь на добавление' })).toBeVisible();
await expect(page.locator('audio')).toBeVisible();
await fits(page);
await page.getByRole('checkbox', { name: /^Выбрать Длинное/ }).check();
await expect(page.getByRole('region', { name: 'Действия с выбранными' })).toBeVisible();
await fits(page);
await page.screenshot({ path: info.outputPath('upload-queue.png') });
await page.getByRole('region', { name: 'Действия с выбранными' }).getByRole('button', { name: 'Отклонить' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.getByRole('button', { name: 'Закрыть', exact: true }).click();
}
if (username === 'mobile-admin') {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
await expect(page.getByRole('article', { name: 'Музыкальное хранилище' })).toBeVisible();
await fits(page);
await page.getByRole('button', { name: 'Шаблоны путей', exact: true }).click();
await page.getByRole('button', { name: 'Добавить шаблон', exact: true }).click();
await fits(page);
await page.screenshot({ path: info.outputPath('patterns.png') });
}
});
}
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 740 });
await login(page, 'alice');
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
await box.fill('кино');
await box.press('Enter');
await page.getByRole('link', { name: /Группа крови/ }).first().click();
await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
await fits(page);
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Остров', exact: true }).click();
const nav = page.getByRole('navigation').filter({ has: page.getByRole('button', { name: 'Ещё', exact: true }) });
await expect.poll(async () => {
const p = await player.boundingBox();
const n = await nav.boundingBox();
return p!.y + p!.height <= n!.y + 1;
}).toBe(true);
await player.getByRole('button', { name: 'Очередь', exact: true }).click();
await expect(page.getByRole('complementary', { name: 'Очередь', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('player-queue.png') });
});
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 1280]) {
test(`playlists at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('playlist.png') });
});
}
+58
View File
@@ -0,0 +1,58 @@
import { expect, test } from '@playwright/test';
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
});
test('1. bootstrap admin changes credentials and lands on home', async ({ page }) => {
await page.goto('/login');
await expect(page.getByText(/Первый запуск/)).toBeVisible();
await page.getByLabel('Логин').fill('admin');
await page.getByRole('textbox', { name: 'Пароль' }).fill('admin-password');
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByRole('heading', { name: 'Смените логин и пароль' })).toBeVisible();
await page.getByLabel('Новый логин').fill('owner');
await page.getByRole('textbox', { name: 'Новый пароль' }).fill('a-strong-owner-pass');
await page.getByRole('textbox', { name: 'Повторите пароль' }).fill('a-strong-owner-pass');
await page.getByRole('button', { name: 'Сохранить и продолжить' }).click();
await expect(page.getByRole('button', { name: 'Бегаю' })).toBeVisible();
});
test('4. search suggest opens the artist and Enter opens search', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Логин').fill('alice');
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
await page.getByRole('button', { name: 'Войти' }).click();
const box = page.getByRole('combobox', { name: 'Поиск' });
await box.fill('кино');
await page.getByRole('option', { name: /Кино/ }).first().click();
await expect(page.getByRole('heading', { name: 'Кино' })).toBeVisible();
await box.fill('кино');
// A suggestion under the pointer must not become a keyboard selection.
const suggestion = page.getByRole('option', { name: /Кино/ }).first();
await suggestion.hover();
await expect(suggestion).toHaveAttribute('aria-selected', 'false');
await box.press('Enter');
await expect(page).toHaveURL(/\/search\?q=/);
});
test('settings expose crossfade and loudness normalization', async ({ page }) => {
await page.goto('/login');
await page.getByLabel('Логин').fill('alice');
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
await page.getByRole('button', { name: 'Войти' }).click();
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки' }).click();
await expect(page.getByText('Выравнивание громкости')).toBeVisible();
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
await slider.fill('4');
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
});
test('9. login page switches between Russian and English', async ({ page }) => {
await page.goto('/login');
await page.getByRole('button', { name: 'EN' }).click();
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
await page.getByRole('button', { name: 'RU' }).click();
await expect(page.getByRole('heading', { name: 'Вход' })).toBeVisible();
});
+1 -1
View File
@@ -2,7 +2,7 @@
<html lang="ru" data-theme="dark"> <html lang="ru" data-theme="dark">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>pipu</title> <title>pipu</title>
+6 -1
View File
@@ -8,7 +8,8 @@
"build": "tsc --noEmit && vite build", "build": "tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run" "test": "vitest run",
"test:e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@fontsource/jetbrains-mono": "^5.2.6", "@fontsource/jetbrains-mono": "^5.2.6",
@@ -21,13 +22,16 @@
"@pipu/i18n": "workspace:*", "@pipu/i18n": "workspace:*",
"@tanstack/react-query": "^5.80.7", "@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20", "@tanstack/react-router": "^1.120.20",
"@tanstack/react-virtual": "^3.14.21",
"hash-wasm": "^4.12.0", "hash-wasm": "^4.12.0",
"music-metadata": "^12.2.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-i18next": "^15.5.3", "react-i18next": "^15.5.3",
"tus-js-client": "^4.3.1" "tus-js-client": "^4.3.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.64.1",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -35,6 +39,7 @@
"@types/react": "^19.1.8", "@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2", "@vitejs/plugin-react": "^4.5.2",
"axe-core": "^4.14.0",
"jsdom": "^26.1.0", "jsdom": "^26.1.0",
"vite": "^6.3.5", "vite": "^6.3.5",
"vitest": "^3.2.4" "vitest": "^3.2.4"
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
timeout: 30_000,
use: { baseURL: 'http://127.0.0.1:4173', ...devices['Desktop Chrome'] },
webServer: {
command: 'pnpm exec vite --port 4173 --strictPort',
url: 'http://127.0.0.1:4173',
reuseExistingServer: !process.env.CI,
env: { VITE_E2E: '1' },
},
});
+138 -2
View File
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router'; import { createMemoryHistory } from '@tanstack/react-router';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import axe from 'axe-core';
import { I18nextProvider } from 'react-i18next'; import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest'; import { afterEach, describe, expect, it } from 'vitest';
@@ -117,7 +118,8 @@ describe('navigation by role', () => {
it('shows users but not the audit log to moderators', async () => { it('shows users but not the audit log to moderators', async () => {
const mod = makeUser('mod', 'moderator'); const mod = makeUser('mod', 'moderator');
await renderApp([mod], '/', { signedInAs: mod }); const { user } = await renderApp([mod], '/', { signedInAs: mod });
await user.click(await screen.findByRole('button', { name: 'Админка' }));
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument(); expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
}); });
@@ -152,6 +154,39 @@ describe('users admin', () => {
expect(await within(table).findByText('@ilya')).toBeInTheDocument(); expect(await within(table).findByText('@ilya')).toBeInTheDocument();
}); });
it.each(['root', 'anna'])('lets admins change the username of %s', async (target) => {
const root = makeUser('root', 'admin');
const anna = makeUser('anna', 'user');
const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
await user.click(await screen.findByRole('button', { name: `Действия с ${target}` }));
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
const input = within(dialog).getByLabelText('Логин');
await user.clear(input);
await user.type(input, `new.${target}`);
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument();
expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`);
if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } });
});
it('keeps the username dialog open when the new username is taken', async () => {
const root = makeUser('root', 'admin');
const anna = makeUser('anna', 'user');
const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
await user.click(await screen.findByRole('button', { name: 'Действия с anna' }));
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
const input = within(dialog).getByLabelText('Логин');
await user.clear(input);
await user.type(input, 'ROOT');
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
expect(api.users.get(anna.id)?.username).toBe('anna');
expect(dialog).toBeInTheDocument();
});
it('offers moderators only what they may do', async () => { it('offers moderators only what they may do', async () => {
const mod = makeUser('mod', 'moderator'); const mod = makeUser('mod', 'moderator');
const anna = makeUser('anna', 'user'); const anna = makeUser('anna', 'user');
@@ -164,7 +199,7 @@ describe('users admin', () => {
await user.click(screen.getByRole('button', { name: 'Действия с anna' })); await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
const items = screen.getAllByRole('menuitem').map((i) => i.textContent); const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']); expect(items).toEqual(['Сбросить пароль', 'Сбросить рекомендации', 'Заблокировать']);
await user.keyboard('{Escape}'); await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Создать пользователя' })); await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
@@ -246,6 +281,30 @@ describe('search box', () => {
}); });
describe('player', () => { describe('player', () => {
it('opens a shared track after login and copies its page URL from the player', async () => {
const alice = makeUser('alice', 'user');
const path = '/track/00000000-0000-4000-8000-000000000101';
const { user, router } = await renderApp([alice], path);
await screen.findByRole('heading', { name: 'Вход' });
await user.type(screen.getByLabelText('Логин'), alice.username);
await user.type(screen.getByLabelText('Пароль'), alice.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
expect(router.state.location.pathname).toBe(path);
await user.click(screen.getByRole('listitem'));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
});
it('shows a missing shared track without starting playback', async () => {
const alice = makeUser('alice', 'user');
await renderApp([alice], '/track/missing', { signedInAs: alice });
expect(await screen.findByRole('alert')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
});
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => { it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice }); const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
@@ -313,6 +372,31 @@ describe('my wave', () => {
}); });
}); });
describe('suggest tracks', () => {
it('warns that lossless formats are the ones to send', async () => {
const admin = makeUser('admin', 'admin');
await renderApp([admin], '/uploads/new', { signedInAs: admin });
expect(await screen.findByText(/Нужны FLAC, WAV или AIFF/)).toBeInTheDocument();
expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument();
});
});
describe('accessibility', () => {
it('home and login have no serious axe violations', async () => {
const alice = makeUser('alice', 'user');
await renderApp([alice], '/', { signedInAs: alice });
await screen.findByRole('button', { name: 'Бегаю' });
const home = await axe.run(document.body, { resultTypes: ['violations'] });
expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
cleanup();
await renderApp([], '/login');
await screen.findByRole('heading', { name: 'Вход' });
const login = await axe.run(document.body, { resultTypes: ['violations'] });
expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
});
});
describe('safeRedirect', () => { describe('safeRedirect', () => {
it.each([ it.each([
['/settings', '/settings'], ['/settings', '/settings'],
@@ -323,3 +407,55 @@ describe('safeRedirect', () => {
expect(safeRedirect(input)).toBe(expected); expect(safeRedirect(input)).toBe(expected);
}); });
}); });
describe('playlists', () => {
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'В дорогу');
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'В дорогу' });
const id = [...api.playlists.keys()][0]!;
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
await router.navigate({ to: '/playlist/$id', params: { id } });
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
await user.clear(screen.getByLabelText('Название'));
await user.type(screen.getByLabelText('Название'), 'Вечер');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Вечер' });
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
await screen.findByRole('heading', { name: 'Плейлисты' });
expect(api.playlists.size).toBe(0);
});
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Первый');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Первый' });
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
const item = [...api.playlists.values()][0]!;
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
await user.type(screen.getByLabelText('Название'), ' изменён');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
});
});
+21 -10
View File
@@ -7,19 +7,22 @@ import {
redirect, redirect,
type ParsedLocation, type ParsedLocation,
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { lazy } from 'react';
import { AuditPage } from '../pages/admin/AuditPage'; const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
import { LibraryPage } from '../pages/admin/LibraryPage'; const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
import { PatternsPage } from '../pages/admin/PatternsPage'; const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage'; const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
import { UploadQueuePage } from '../pages/admin/UploadQueuePage'; const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage'; const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
import { SuggestPage } from '../pages/uploads/SuggestPage'; const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
import { SourcesPage } from '../pages/admin/SourcesPage'; const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
import { UsersPage } from '../pages/admin/UsersPage'; const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
import { AlbumPage } from '../pages/AlbumPage'; import { AlbumPage } from '../pages/AlbumPage';
import { TrackPage } from '../pages/TrackPage';
import { ArtistPage } from '../pages/ArtistPage'; import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage'; import { CollectionPage } from '../pages/CollectionPage';
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { HomePage } from '../pages/HomePage'; import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage'; import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage'; import { LoginPage } from '../pages/LoginPage';
@@ -116,6 +119,9 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
}; };
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage }); const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage }); const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
@@ -225,7 +231,9 @@ const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/upl
const uploadQueueRoute = createRoute({ const uploadQueueRoute = createRoute({
getParentRoute: () => appRoute, getParentRoute: () => appRoute,
path: '/admin/uploads', path: '/admin/uploads',
beforeLoad: adminOnly, beforeLoad: ({ context }) => {
if (!context.user.reviews_uploads) throw redirect({ to: '/' });
},
component: UploadQueuePage, component: UploadQueuePage,
}); });
@@ -243,6 +251,9 @@ const routeTree = rootRoute.addChildren([
setupPasswordRoute, setupPasswordRoute,
appRoute.addChildren([ appRoute.addChildren([
homeRoute, homeRoute,
trackRoute,
playlistsRoute,
playlistRoute,
settingsRoute, settingsRoute,
searchRoute, searchRoute,
historyRoute, historyRoute,
+9
View File
@@ -270,3 +270,12 @@
display: none; display: none;
} }
} }
@media (max-width: 767px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
.end { gap: 4px; }
.like { opacity: 1; width: 44px; height: 44px; }
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
}
+109 -71
View File
@@ -1,6 +1,8 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { Fragment } from 'react'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
@@ -114,6 +116,7 @@ export function TrackList({
rowMeta, rowMeta,
lead, lead,
disliked, disliked,
actions,
}: { }: {
tracks: Track[]; tracks: Track[];
showAlbum?: boolean; showAlbum?: boolean;
@@ -125,86 +128,121 @@ export function TrackList({
lead?: (t: Track, i: number) => string; lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */ /** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean; disliked?: boolean;
actions?: (track: Track, index: number) => ReactNode;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
const { track: current, playing } = usePlayerState(); const { track: current, playing } = usePlayerState();
const listRef = useRef<HTMLDivElement>(null);
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1)); const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
const multiDisc = Boolean(numbered && discs.size > 1);
const virtual = tracks.length > 30 && !multiDisc;
const virtualizer = useVirtualizer({
count: virtual ? tracks.length : 0,
getScrollElement: () => listRef.current?.closest('main') ?? null,
estimateSize: () => 68,
overscan: 8,
});
const row = (tr: Track, i: number) => {
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
<div
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
>
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div>
);
};
let lastDisc: number | null = null; let lastDisc: number | null = null;
return ( return (
<div className={s.tracks} role="list" aria-label={t('search.tracks')}> <div
{tracks.map((tr, i) => { ref={listRef}
const disc = tr.disc_no ?? 1; className={s.tracks}
const header = numbered && discs.size > 1 && disc !== lastDisc; role="list"
lastDisc = disc; aria-label={t('search.tracks')}
const isCurrent = current?.id === tr.id; style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
const play = () => tr.available && player.playList(tracks, i, context); >
const liked = tr.reaction === 'like'; {virtual
const meta = rowMeta?.(tr, i); ? virtualizer.getVirtualItems().map((vi) => (
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div <div
role="listitem" key={vi.key}
tabIndex={tr.available ? 0 : -1} data-index={vi.index}
aria-current={isCurrent || undefined} ref={virtualizer.measureElement}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)} style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
> >
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>} {row(tracks[vi.index]!, vi.index)}
<span className={cx(s.num, disliked && s.dimmed)}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={cx(s.trackMain, disliked && s.dimmed)}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end}>
{disliked ? (
<button
type="button"
className={cx(s.like, s.liked)}
aria-label={t('player.undislike')}
onClick={() => void player.react(tr, null).catch(() => undefined)}
>
<Icon src={undoIcon} size={18} />
</button>
) : (
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
)}
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div> </div>
</Fragment> ))
); : tracks.map((tr, i) => {
})} const disc = tr.disc_no ?? 1;
const header = multiDisc && disc !== lastDisc;
lastDisc = disc;
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
{row(tr, i)}
</Fragment>
);
})}
</div> </div>
); );
} }
+97
View File
@@ -0,0 +1,97 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { queueIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
export type PlaylistInput = components['schemas']['PlaylistInput'];
export function usePlaylists(enabled = true) {
const session = useSession();
return useQuery({ queryKey: ['playlists'], enabled,
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
}
export function usePlaylist(id: string) {
const session = useSession();
return useQuery({ queryKey: ['playlists', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
}
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
}
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
}) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const [original] = useState(playlist);
const [name, setName] = useState(playlist?.name ?? '');
const [description, setDescription] = useState(playlist?.description ?? '');
const save = useMutation({ mutationFn: async () => {
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
return original
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
}, onSuccess: p => {
cache.setQueryData(['playlists', p.id], p);
void cache.invalidateQueries({ queryKey: ['playlists'] });
onSaved(p);
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
</form>
</Dialog>;
}
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const list = usePlaylists();
const [create, setCreate] = useState(false);
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = playlistInput(p);
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
<div style={{ display: 'grid', gap: 8 }}>
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
}
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return <>
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
</>;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { needsFormatAttention } from './files';
describe('needsFormatAttention', () => {
it('marks everything except lossless formats', () => {
expect(needsFormatAttention('flac', 'a.mp3')).toBe(false);
expect(needsFormatAttention('WAV', 'track.wav')).toBe(false);
expect(needsFormatAttention(null, 'Кино/01.wav')).toBe(false);
expect(needsFormatAttention('aiff', 'a.aif')).toBe(false);
expect(needsFormatAttention(null, 'track.AIFF')).toBe(false);
expect(needsFormatAttention(null, 'track.wv')).toBe(false);
expect(needsFormatAttention('mp3', 'a.flac')).toBe(true);
expect(needsFormatAttention(null, 'folder/song.MP3')).toBe(true);
expect(needsFormatAttention(null, 'song.m4a')).toBe(true);
expect(needsFormatAttention(null, 'noext')).toBe(false);
});
});
+17
View File
@@ -8,6 +8,23 @@ export interface PickedFile {
/** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */ /** Форматы, которые принимает очередь (сервер всё равно проверяет содержимое через ffprobe). */
export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv']; export const AUDIO_EXTENSIONS = ['flac', 'mp3', 'm4a', 'ogg', 'opus', 'wav', 'aiff', 'aif', 'wv'];
/** То, что просим присылать. MP3 и прочие — крайний случай, их помечает очередь. */
// Lossless — как в quality.go на сервере.
const PREFERRED_AUDIO = new Set(['flac', 'wav', 'aiff', 'aif', 'wv']);
export function audioExt(name: string): string {
const base = name.split(/[/\\]/).pop() ?? name;
const dot = base.lastIndexOf('.');
if (dot < 0 || dot === base.length - 1) return '';
return base.slice(dot + 1).toLowerCase();
}
/** Не lossless (FLAC, WAV, AIFF, WavPack): в очереди на добавление такой файл нужно выделить. */
export function needsFormatAttention(format: string | null | undefined, name: string): boolean {
const ext = (format || audioExt(name)).toLowerCase();
return ext !== '' && !PREFERRED_AUDIO.has(ext);
}
export function isAudio(name: string): boolean { export function isAudio(name: string): boolean {
const ext = name.split('.').pop()?.toLowerCase() ?? ''; const ext = name.split('.').pop()?.toLowerCase() ?? '';
return AUDIO_EXTENSIONS.includes(ext); return AUDIO_EXTENSIONS.includes(ext);
+140 -12
View File
@@ -17,15 +17,18 @@ export type Stage =
| 'uploading' | 'uploading'
| 'done' | 'done'
| 'rejected' | 'rejected'
| 'error'; | 'error'
| 'resume';
export interface UploadItem { export interface UploadItem {
id: string; id: string;
file: File; file: File | null;
size: number;
relPath: string; relPath: string;
stage: Stage; stage: Stage;
progress: number; // 0..1 текущего этапа (хэш или загрузка) progress: number; // 0..1 текущего этапа (хэш или загрузка)
sha256?: string; sha256?: string;
tags?: { artist?: string; title?: string; album?: string; duration_ms?: number };
trackId?: string | null; trackId?: string | null;
possibleDuplicate?: boolean; possibleDuplicate?: boolean;
code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, … code?: string; // код отказа сервера: uploads.queue_full, uploads.duplicate, …
@@ -44,6 +47,17 @@ export interface UploaderState {
const PARALLEL = 4; const PARALLEL = 4;
const CHUNK = 8 << 20; const CHUNK = 8 << 20;
const PENDING_KEY = 'pipu.uploads.pending';
interface PendingUpload {
id: string;
name: string;
size: number;
relPath: string;
sha256: string;
tusUrl: string;
uploadFileId: string;
}
let seq = 0; let seq = 0;
@@ -56,7 +70,9 @@ export class Uploader {
private uploads = new Map<string, tus.Upload>(); private uploads = new Map<string, tus.Upload>();
private refreshing: Promise<unknown> | null = null; private refreshing: Promise<unknown> | null = null;
constructor(private readonly session: AuthSession) {} constructor(private readonly session: AuthSession) {
this.restore();
}
subscribe = (fn: () => void) => { subscribe = (fn: () => void) => {
this.listeners.add(fn); this.listeners.add(fn);
@@ -89,8 +105,8 @@ export class Uploader {
continue; continue;
} }
// Тот же путь и размер уже в списке — не добавляем второй раз. // Тот же путь и размер уже в списке — не добавляем второй раз.
if (this.state.items.some((it) => it.relPath === f.relPath && it.file.size === f.file.size)) continue; if (this.state.items.some((it) => it.relPath === f.relPath && it.size === f.file.size)) continue;
fresh.push({ id: `f${++seq}`, file: f.file, relPath: f.relPath, stage: 'hashing', progress: 0 }); fresh.push({ id: `f${++seq}`, file: f.file, size: f.file.size, relPath: f.relPath, stage: 'hashing', progress: 0 });
} }
this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null }); this.set({ items: [...this.state.items, ...fresh], skipped: this.state.skipped + skipped, batchId: null, error: null });
this.hashQueue.push(...fresh.map((it) => it.id)); this.hashQueue.push(...fresh.map((it) => it.id));
@@ -101,6 +117,7 @@ export class Uploader {
if (this.state.busy) return; if (this.state.busy) return;
this.hashQueue = this.hashQueue.filter((x) => x !== id); this.hashQueue = this.hashQueue.filter((x) => x !== id);
this.set({ items: this.state.items.filter((it) => it.id !== id) }); this.set({ items: this.state.items.filter((it) => it.id !== id) });
this.persist();
} }
/** Очистить список (после завершения или чтобы начать заново). */ /** Очистить список (после завершения или чтобы начать заново). */
@@ -108,6 +125,7 @@ export class Uploader {
if (this.state.busy) return; if (this.state.busy) return;
this.hashQueue = []; this.hashQueue = [];
this.set({ items: [], skipped: 0, batchId: null, error: null }); this.set({ items: [], skipped: 0, batchId: null, error: null });
this.persist();
} }
private hashWorker(): Worker { private hashWorker(): Worker {
@@ -125,6 +143,7 @@ export class Uploader {
if (!it) continue; if (!it) continue;
try { try {
const sha256 = await this.hash(it); const sha256 = await this.hash(it);
await this.readTags(it);
this.patch(id, { sha256, stage: 'checking', progress: 1 }); this.patch(id, { sha256, stage: 'checking', progress: 1 });
} catch (err) { } catch (err) {
this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) }); this.patch(id, { stage: 'error', message: err instanceof Error ? err.message : String(err) });
@@ -137,13 +156,15 @@ export class Uploader {
} }
private hash(it: UploadItem): Promise<string> { private hash(it: UploadItem): Promise<string> {
const file = it.file;
if (!file) return Promise.reject(new Error('no file'));
const w = this.hashWorker(); const w = this.hashWorker();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const onMessage = (e: MessageEvent<HashResponse>) => { const onMessage = (e: MessageEvent<HashResponse>) => {
const m = e.data; const m = e.data;
if (m.id !== it.id) return; if (m.id !== it.id) return;
if (m.type === 'progress') { if (m.type === 'progress') {
this.patch(it.id, { progress: m.done / Math.max(it.file.size, 1) }); this.patch(it.id, { progress: m.done / Math.max(it.size, 1) });
return; return;
} }
w.removeEventListener('message', onMessage); w.removeEventListener('message', onMessage);
@@ -151,7 +172,7 @@ export class Uploader {
else reject(new Error(m.message)); else reject(new Error(m.message));
}; };
w.addEventListener('message', onMessage); w.addEventListener('message', onMessage);
w.postMessage({ id: it.id, file: it.file } satisfies HashRequest); w.postMessage({ id: it.id, file } satisfies HashRequest);
}); });
} }
@@ -162,7 +183,17 @@ export class Uploader {
try { try {
const res = unwrap( const res = unwrap(
await this.session.client.POST('/api/v1/uploads/precheck', { await this.session.client.POST('/api/v1/uploads/precheck', {
body: { files: pending.map((it) => ({ client_id: it.id, size: it.file.size, sha256: it.sha256! })) }, body: {
files: pending.map((it) => ({
client_id: it.id,
size: it.size,
sha256: it.sha256!,
artist: it.tags?.artist,
title: it.tags?.title,
album: it.tags?.album,
duration_ms: it.tags?.duration_ms,
})),
},
}), }),
); );
for (const r of res.files) { for (const r of res.files) {
@@ -192,9 +223,9 @@ export class Uploader {
comment: comment.trim() || undefined, comment: comment.trim() || undefined,
files: ready.map((it) => ({ files: ready.map((it) => ({
client_id: it.id, client_id: it.id,
name: it.file.name, name: it.file?.name ?? it.relPath,
rel_path: it.relPath.includes('/') ? it.relPath : undefined, rel_path: it.relPath.includes('/') ? it.relPath : undefined,
size: it.file.size, size: it.size,
sha256: it.sha256!, sha256: it.sha256!,
})), })),
}, },
@@ -209,6 +240,7 @@ export class Uploader {
queue.push({ id: f.client_id, url: f.tus_url }); queue.push({ id: f.client_id, url: f.tus_url });
} }
} }
this.persist();
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue))); await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} catch (err) { } catch (err) {
this.set({ error: err }); this.set({ error: err });
@@ -239,9 +271,10 @@ export class Uploader {
private upload(id: string, url: string): Promise<void> { private upload(id: string, url: string): Promise<void> {
const it = this.item(id); const it = this.item(id);
if (!it) return Promise.resolve(); if (!it?.file) return Promise.resolve();
const file = it.file;
return new Promise((resolve) => { return new Promise((resolve) => {
const up = new tus.Upload(it.file, { const up = new tus.Upload(file, {
uploadUrl: url, uploadUrl: url,
chunkSize: CHUNK, chunkSize: CHUNK,
retryDelays: [0, 1000, 3000, 5000, 10000, 20000], retryDelays: [0, 1000, 3000, 5000, 10000, 20000],
@@ -264,6 +297,7 @@ export class Uploader {
onSuccess: () => { onSuccess: () => {
this.uploads.delete(id); this.uploads.delete(id);
this.patch(id, { stage: 'done', progress: 1 }); this.patch(id, { stage: 'done', progress: 1 });
this.persist();
resolve(); resolve();
}, },
onError: (err) => { onError: (err) => {
@@ -276,6 +310,100 @@ export class Uploader {
up.start(); up.start();
}); });
} }
private async readTags(it: UploadItem) {
if (!it.file) return;
try {
const { parseBlob } = await import('music-metadata');
const meta = await parseBlob(it.file);
const duration = meta.format.duration;
this.patch(it.id, {
tags: {
artist: meta.common.artist,
title: meta.common.title,
album: meta.common.album,
duration_ms: duration ? Math.round(duration * 1000) : undefined,
},
});
} catch {
// Теги — подсказка для предпроверки; без них сервер прочитает их сам.
}
}
/** Незавершённые tus-загрузки переживают перезагрузку: файл выбирают заново и докачивают. */
private persist() {
const pending: PendingUpload[] = this.state.items
.filter((it): it is UploadItem & { sha256: string; tusUrl: string; uploadFileId: string } =>
Boolean(it.tusUrl && it.sha256 && it.uploadFileId && it.stage !== 'done'),
)
.map((it) => ({
id: it.id,
name: it.file?.name ?? it.relPath,
size: it.size,
relPath: it.relPath,
sha256: it.sha256,
tusUrl: it.tusUrl,
uploadFileId: it.uploadFileId,
}));
try {
if (pending.length) localStorage.setItem(PENDING_KEY, JSON.stringify(pending));
else localStorage.removeItem(PENDING_KEY);
} catch {
// Приватный режим — докачка после перезагрузки недоступна.
}
}
private restore() {
try {
const raw = localStorage.getItem(PENDING_KEY);
if (!raw) return;
const rows = JSON.parse(raw) as PendingUpload[];
if (!Array.isArray(rows) || !rows.length) return;
this.state = {
...this.state,
items: rows.map((r) => ({
id: r.id,
file: null,
size: r.size,
relPath: r.relPath,
stage: 'resume' as const,
progress: 0,
sha256: r.sha256,
tusUrl: r.tusUrl,
uploadFileId: r.uploadFileId,
})),
};
} catch {
localStorage.removeItem(PENDING_KEY);
}
}
/** Приложить заново те же файлы и продолжить tus с принятого смещения. */
async continueWith(files: PickedFile[]) {
const waiting = this.state.items.filter((it) => it.stage === 'resume' && it.tusUrl);
if (!waiting.length) return;
const queue: { id: string; url: string }[] = [];
for (const picked of files) {
const it = waiting.find((w) => w.size === picked.file.size && (w.relPath === picked.relPath || w.relPath.endsWith(picked.file.name)));
if (!it) continue;
this.patch(it.id, { file: picked.file, stage: 'hashing', progress: 0 });
const sha = await this.hash(this.item(it.id)!);
if (sha !== it.sha256) {
this.patch(it.id, { stage: 'error', message: 'sha256' });
continue;
}
this.patch(it.id, { stage: 'uploading', progress: 0 });
queue.push({ id: it.id, url: it.tusUrl! });
}
if (!queue.length) return;
this.set({ busy: true });
try {
await Promise.all(Array.from({ length: Math.min(PARALLEL, queue.length) }, () => this.drain(queue)));
} finally {
this.set({ busy: false });
this.persist();
}
}
} }
let instance: { session: AuthSession; uploader: Uploader } | null = null; let instance: { session: AuthSession; uploader: Uploader } | null = null;
+4
View File
@@ -48,6 +48,10 @@ function deviceName(): string {
} }
applyTheme(undefined); applyTheme(undefined);
if (import.meta.env.VITE_E2E === '1') {
const { installE2EFetch } = await import('./test/e2eBridge');
installE2EFetch();
}
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages])); const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
document.documentElement.lang = i18n.language; document.documentElement.lang = i18n.language;
i18n.on('languageChanged', (l) => { i18n.on('languageChanged', (l) => {
+2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog'; import { Cover, TrackList } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons'; import { playIcon, shuffleIcon } from '../shared/ui/icons';
@@ -45,6 +46,7 @@ export function AlbumPage({ id }: { id: string }) {
)} )}
{a && a.tracks.some((tr) => tr.available) && ( {a && a.tracks.some((tr) => tr.available) && (
<div className={c.heroActions}> <div className={c.heroActions}>
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}> <Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
{t('catalog.play')} {t('catalog.play')}
</Button> </Button>
+22 -15
View File
@@ -84,6 +84,8 @@ function WaveHome() {
staleTime: Infinity, staleTime: Infinity,
}); });
const showActivities = user.ui_prefs.home_activities !== false;
const showMoods = user.ui_prefs.home_moods !== false;
const waveOn = st.wave !== null; const waveOn = st.wave !== null;
const playing = waveOn && st.playing; const playing = waveOn && st.playing;
const track = waveOn ? st.track : null; const track = waveOn ? st.track : null;
@@ -116,6 +118,7 @@ function WaveHome() {
<WaveButton <WaveButton
variant={user.ui_prefs.wave_variant ?? 'orb'} variant={user.ui_prefs.wave_variant ?? 'orb'}
playing={playing} playing={playing}
readAudio={player.readAudio}
bpm={track?.features?.bpm ?? undefined} bpm={track?.features?.bpm ?? undefined}
energy={track?.features?.energy ?? undefined} energy={track?.features?.energy ?? undefined}
colors={colors} colors={colors}
@@ -133,22 +136,26 @@ function WaveHome() {
{st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>} {st.error === 'wave.empty' && <p className={s.note}>{t('wave.empty')}</p>}
<FormError>{error ? errorText(error) : null}</FormError> <FormError>{error ? errorText(error) : null}</FormError>
{options.data && ( {options.data && (showActivities || showMoods) && (
<div className={s.picker}> <div className={s.picker}>
<Chips {showActivities && (
title={t('wave.activity_label')} <Chips
options={options.data.activities} title={t('wave.activity_label')}
value={activity} options={options.data.activities}
label={(k) => tr(`wave.activities.${k}`)} value={activity}
onPick={(k) => choose('activity', k)} label={(k) => tr(`wave.activities.${k}`)}
/> onPick={(k) => choose('activity', k)}
<Chips />
title={t('wave.mood_label')} )}
options={options.data.moods} {showMoods && (
value={mood} <Chips
label={(k) => tr(`wave.moods.${k}`)} title={t('wave.mood_label')}
onPick={(k) => choose('mood', k)} options={options.data.moods}
/> value={mood}
label={(k) => tr(`wave.moods.${k}`)}
onPick={(k) => choose('mood', k)}
/>
)}
</div> </div>
)} )}
</div> </div>
+109
View File
@@ -0,0 +1,109 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
import c from './catalog.module.css';
import s from './page.module.css';
import p from './playlists.module.css';
export function PlaylistsPage() {
const { t } = useTranslation();
const list = usePlaylists();
const errorText = useErrorText();
const navigate = useNavigate();
const [create, setCreate] = useState(false);
const [search, setSearch] = useState('');
const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? [];
return <div className={s.page}>
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
<Cover album={item} size="100%" />
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
{item.description && <span className={p.description}>{item.description}</span>}
</Link>)}</div>
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
</div>;
}
export function PlaylistPage({ id }: { id: string }) {
const { t } = useTranslation();
const query = usePlaylist(id);
const errorText = useErrorText();
return <div className={s.page}>
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
</div>;
}
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const player = usePlayer();
const navigate = useNavigate();
const errorText = useErrorText();
const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false);
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
})), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })),
onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } });
const play = (shuffle: boolean) => {
const tracks = playlist.tracks.filter(x => x.available);
if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; }
if (tracks.length) player.playList(tracks, 0, context);
};
const move = (index: number, delta: number) => {
const ids = playlist.tracks.map(x => x.id);
[ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!];
save.mutate(ids);
};
return <>
<header className={c.colHero}>
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
<p className={p.description}>{playlist.description}</p>
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
</div>
</header>
<div className={s.toolbar}>
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
</div>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
actions={manage ? (track, i) => <span className={p.actions}>
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
</span> : undefined} />
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
</>;
}
+60
View File
@@ -139,6 +139,42 @@ function AppearanceSection() {
]} ]}
/> />
</div> </div>
<div className={s.settingRow}>
<span>{t('settings.search_collapse')}</span>
<Segmented<'on' | 'off'>
label={t('settings.search_collapse')}
value={user.ui_prefs.search_collapse === true ? 'on' : 'off'}
onChange={(v) => void setPrefs({ search_collapse: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.home_activities')}</span>
<Segmented<'on' | 'off'>
label={t('settings.home_activities')}
value={user.ui_prefs.home_activities === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ home_activities: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.home_moods')}</span>
<Segmented<'on' | 'off'>
label={t('settings.home_moods')}
value={user.ui_prefs.home_moods === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ home_moods: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}> <div className={s.settingRow}>
<span>{t('settings.ambient')}</span> <span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'> <Segmented<'on' | 'off'>
@@ -151,6 +187,30 @@ function AppearanceSection() {
]} ]}
/> />
</div> </div>
<div className={s.settingRow}>
<span>{t('settings.normalize')}</span>
<Segmented<'on' | 'off'>
label={t('settings.normalize')}
value={user.ui_prefs.normalize === false ? 'off' : 'on'}
onChange={(v) => void setPrefs({ normalize: v === 'on' })}
options={[
{ value: 'on', label: t('settings.on') },
{ value: 'off', label: t('settings.off') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}</span>
<input
type="range"
min={0}
max={6}
step={1}
aria-label={t('settings.crossfade', { seconds: user.ui_prefs.crossfade_s ?? 0 })}
value={user.ui_prefs.crossfade_s ?? 0}
onChange={(e) => void setPrefs({ crossfade_s: Number(e.target.value) })}
/>
</div>
<div className={s.settingRow}> <div className={s.settingRow}>
<span>{t('language.label')}</span> <span>{t('language.label')}</span>
<LanguageSwitch /> <LanguageSwitch />
+38
View File
@@ -0,0 +1,38 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
import { formatDuration } from '../shared/lib/format';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
export function TrackPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const track = useQuery({
queryKey: ['track', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
});
if (track.isError) return <NotFound error={track.error} />;
const tr = track.data;
return (
<div className={s.page}>
<header className={c.hero}>
{tr && <Cover album={tr.album} size={200} />}
<div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
{tr && <>
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
</>}
</div>
</header>
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
</div>
);
}
+1
View File
@@ -15,6 +15,7 @@ type Entry = components['schemas']['AuditEntry'];
function details(e: Entry, roleName: (r: string) => string): string { function details(e: Entry, roleName: (r: string) => string): string {
const p = e.payload ?? {}; const p = e.payload ?? {};
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`; if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
if (e.action === 'user.username_change') return `${String(p.from)} → ${String(p.to)}`;
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter( const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
(v): v is string => typeof v === 'string' && v !== '', (v): v is string => typeof v === 'string' && v !== '',
); );
+112 -4
View File
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
import { needsFormatAttention } from '../../features/uploads/files';
import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format'; import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons'; import { checkIcon, closeIcon, editIcon, refreshIcon } from '../../shared/ui/icons';
@@ -35,6 +36,7 @@ export function UploadQueuePage() {
const [onlyPossible, setOnlyPossible] = useState(false); const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null); const [editing, setEditing] = useState<UploadFile | null>(null);
const [batchEdit, setBatchEdit] = useState<string | null>(null);
const [rejecting, setRejecting] = useState(false); const [rejecting, setRejecting] = useState(false);
const list = useInfiniteQuery({ const list = useInfiniteQuery({
@@ -131,6 +133,7 @@ export function UploadQueuePage() {
selected={selected} selected={selected}
onToggle={toggle} onToggle={toggle}
onEdit={setEditing} onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)} onRetry={(id) => retry.mutate(id)}
/> />
))} ))}
@@ -158,6 +161,17 @@ export function UploadQueuePage() {
</div> </div>
)} )}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && ( {editing && (
<EditDialog <EditDialog
file={editing} file={editing}
@@ -194,6 +208,7 @@ function BatchCard({
selected, selected,
onToggle, onToggle,
onEdit, onEdit,
onEditBatch,
onRetry, onRetry,
}: { }: {
files: UploadFile[]; files: UploadFile[];
@@ -201,6 +216,7 @@ function BatchCard({
selected: Set<string>; selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void; onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void; onEdit: (f: UploadFile) => void;
onEditBatch: (batchId: string) => void;
onRetry: (id: string) => void; onRetry: (id: string) => void;
}) { }) {
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
@@ -218,6 +234,12 @@ function BatchCard({
<span>{formatDateTime(first.created_at, i18n.language)}</span> <span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span> <span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>} {first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
<span style={{ flex: 1 }} />
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')}
</Button>
)}
</div> </div>
{files.map((f) => ( {files.map((f) => (
<QueueRow <QueueRow
@@ -288,7 +310,13 @@ function QueueRow({
<div className={u.meta}> <div className={u.meta}>
<div className={u.flags}> <div className={u.flags}>
<UploadStatusBadge status={f.status} /> <UploadStatusBadge status={f.status} />
{needsFormatAttention(f.format, f.original_name) && (
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
{t('uploads.flag_attention')}
</span>
)}
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>} {f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
{f.matched_album_id ? ( {f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span> <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : ( ) : (
@@ -302,6 +330,9 @@ function QueueRow({
)} )}
</div> </div>
<div className={u.rowActions}> <div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
)}
{editable && ( {editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}> <Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} /> <Icon src={editIcon} size={18} />
@@ -319,6 +350,75 @@ function QueueRow({
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null }; const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const [artist, setArtist] = useState('');
const [album, setAlbum] = useState('');
const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined);
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
params: { path: { id: batchId } },
body: {
...(artist.trim() ? { artist: artist.trim() } : {}),
...(album.trim() ? { album: album.trim() } : {}),
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
...(cover !== undefined ? { cover_base64: cover } : {}),
},
}),
),
onSuccess: onSaved,
});
const pickCover = (file: File | undefined) => {
if (!file) {
setCover(undefined);
return;
}
const reader = new FileReader();
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
};
return (
<Dialog
title={t('uploads.batch_edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
</label>
<Button variant="ghost" onClick={() => setCover(null)}>
{t('uploads.batch_cover_clear')}
</Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</div>
</Dialog>
);
}
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) { function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
@@ -452,7 +552,7 @@ export function UploadSettingsSection() {
queryKey: ['sources'], queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items, queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
}); });
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string } | null>(null); const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
useEffect(() => { useEffect(() => {
if (settings.data) setForm(toForm(settings.data)); if (settings.data) setForm(toForm(settings.data));
}, [settings.data]); }, [settings.data]);
@@ -468,6 +568,7 @@ export function UploadSettingsSection() {
max_file_bytes: gb(f.maxFile), max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null, per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null, default_source_id: f.source || null,
allow_quality_upgrade: f.upgrade,
}, },
}), }),
); );
@@ -484,7 +585,7 @@ export function UploadSettingsSection() {
return ( return (
<section className={s.section}> <section className={s.section}>
<h2>{t('uploads.settings_title')}</h2> <h2>{t('uploads.settings_title')}</h2>
<div className={u.grid}> <div className={u.settingsGrid}>
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" /> <Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
<Field <Field
label={t('uploads.set_per_user')} label={t('uploads.set_per_user')}
@@ -494,8 +595,8 @@ export function UploadSettingsSection() {
inputMode="decimal" inputMode="decimal"
/> />
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" /> <Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<label className={s.sessions}> <label>
<span>{t('uploads.set_source')}</span> <span className={u.settingsLabel}>{t('uploads.set_source')}</span>
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}> <select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
<option value="">{t('uploads.set_source_auto')}</option> <option value="">{t('uploads.set_source_auto')}</option>
{sources.data {sources.data
@@ -507,6 +608,12 @@ export function UploadSettingsSection() {
))} ))}
</select> </select>
</label> </label>
<div>
<label className={u.settingsCheck}>
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
<span>{t('uploads.set_upgrade')}</span>
</label>
</div>
</div> </div>
<p className={s.note}>{t('uploads.set_note')}</p> <p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}> <div className={s.toolbar}>
@@ -528,5 +635,6 @@ function toForm(st: Settings) {
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes), perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes), maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '', source: st.default_source_id ?? '',
upgrade: st.allow_quality_upgrade,
}; };
} }
+99 -11
View File
@@ -8,7 +8,7 @@ import { useSession, useUser } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format'; import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy'; import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons'; import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, refreshIcon, searchIcon } from '../../shared/ui/icons';
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui'; import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
@@ -16,7 +16,9 @@ type AdminUser = components['schemas']['AdminUser'];
const ROLES: Role[] = ['admin', 'moderator', 'user']; const ROLES: Role[] = ['admin', 'moderator', 'user'];
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser }; const TITLES = { reset: 'reset_password', recs: 'reset_recommendations', block: 'block', delete: 'delete' } as const;
type Confirm = { kind: 'reset' | 'recs' | 'block' | 'delete'; user: AdminUser };
function useDebounced<T>(value: T, ms: number): T { function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value); const [v, setV] = useState(value);
@@ -44,9 +46,11 @@ export function UsersPage() {
const [role, setRole] = useState<Role | null>(null); const [role, setRole] = useState<Role | null>(null);
const query = useDebounced(q.trim(), 200); const query = useDebounced(q.trim(), 200);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [renaming, setRenaming] = useState<AdminUser | null>(null);
const [confirm, setConfirm] = useState<Confirm | null>(null); const [confirm, setConfirm] = useState<Confirm | null>(null);
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null); const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
const [actionError, setActionError] = useState<unknown>(null); const [actionError, setActionError] = useState<unknown>(null);
const [recsDone, setRecsDone] = useState<string | null>(null);
const list = useInfiniteQuery({ const list = useInfiniteQuery({
queryKey: ['admin-users', query, role], queryKey: ['admin-users', query, role],
@@ -79,6 +83,14 @@ export function UsersPage() {
}, },
onError: setActionError, onError: setActionError,
}); });
const resetRecs = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: u.id } } }),
),
onSuccess: (_, u) => setRecsDone(u.username),
onError: setActionError,
});
const remove = useMutation({ const remove = useMutation({
mutationFn: async (u: AdminUser) => mutationFn: async (u: AdminUser) =>
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })), unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
@@ -91,7 +103,9 @@ export function UsersPage() {
const { kind, user } = confirm; const { kind, user } = confirm;
setConfirm(null); setConfirm(null);
setActionError(null); setActionError(null);
setRecsDone(null);
if (kind === 'reset') reset.mutate(user); if (kind === 'reset') reset.mutate(user);
if (kind === 'recs') resetRecs.mutate(user);
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } }); if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
if (kind === 'delete') remove.mutate(user); if (kind === 'delete') remove.mutate(user);
}; };
@@ -99,8 +113,16 @@ export function UsersPage() {
const actionsFor = (u: AdminUser): MenuItem[] => { const actionsFor = (u: AdminUser): MenuItem[] => {
const items: MenuItem[] = []; const items: MenuItem[] = [];
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a); const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
if (me.role === 'admin')
items.push({ label: t('users.change_username'), onSelect: () => setRenaming(u) });
if (can('reset_password')) if (can('reset_password'))
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) }); items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
if (can('reset_recommendations'))
items.push({
label: t('users.reset_recommendations'),
icon: refreshIcon,
onSelect: () => setConfirm({ kind: 'recs', user: u }),
});
if (can('block')) if (can('block'))
items.push( items.push(
u.is_blocked u.is_blocked
@@ -113,6 +135,11 @@ export function UsersPage() {
label: t('users.make_role', { role: t(`common:roles.${r}`) }), label: t('users.make_role', { role: t(`common:roles.${r}`) }),
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }), onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
}); });
if (me.role === 'admin')
items.push({
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
});
if (can('delete')) if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) }); items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items; return items;
@@ -154,9 +181,10 @@ export function UsersPage() {
</div> </div>
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError> <FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
{recsDone && <p className={s.ok}>{t('users.recs_done', { username: recsDone })}</p>}
<div className={s.tableWrap}> <div className={s.tableWrap}>
<table className={s.table}> <table className={cx(s.table, s.mobileCards)}>
<thead> <thead>
<tr> <tr>
<th>{t('users.col_user')}</th> <th>{t('users.col_user')}</th>
@@ -172,7 +200,7 @@ export function UsersPage() {
const st = status(u); const st = status(u);
return ( return (
<tr key={u.id}> <tr key={u.id}>
<td> <td data-label={t('users.col_user')}>
<div className={s.userCell}> <div className={s.userCell}>
<Avatar name={u.display_name ?? u.username} /> <Avatar name={u.display_name ?? u.username} />
<div> <div>
@@ -184,16 +212,17 @@ export function UsersPage() {
</div> </div>
</div> </div>
</td> </td>
<td> <td data-label={t('users.col_role')}>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span> <span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td> </td>
<td> <td data-label={t('users.col_status')}>
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span> <span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
</td> </td>
<td className={s.muted}> <td className={s.muted} data-label={t('users.col_last_login')}>
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')} {u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
</td> </td>
<td> <td className={s.cardAction}>
{items.length > 0 && ( {items.length > 0 && (
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} /> <Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
)} )}
@@ -224,16 +253,28 @@ export function UsersPage() {
/> />
)} )}
{renaming && (
<ChangeUsernameDialog
user={renaming}
onClose={() => setRenaming(null)}
onSaved={async (updated) => {
if (updated.id === me.id) session.setUser({ ...me, username: updated.username });
setRenaming(null);
await refresh();
}}
/>
)}
{confirm && ( {confirm && (
<Dialog <Dialog
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)} title={t(`users.${TITLES[confirm.kind]}`)}
onClose={() => setConfirm(null)} onClose={() => setConfirm(null)}
footer={ footer={
<> <>
<Button variant="ghost" onClick={() => setConfirm(null)}> <Button variant="ghost" onClick={() => setConfirm(null)}>
{t('common:form.cancel')} {t('common:form.cancel')}
</Button> </Button>
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}> <Button variant={confirm.kind === 'block' || confirm.kind === 'delete' ? 'danger' : 'primary'} onClick={runConfirm}>
{t('users.confirm')} {t('users.confirm')}
</Button> </Button>
</> </>
@@ -265,12 +306,18 @@ function CreateUserDialog({
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [role, setRole] = useState<Role>('user'); const [role, setRole] = useState<Role>('user');
const [reviews, setReviews] = useState(false);
const create = useMutation({ const create = useMutation({
mutationFn: async () => mutationFn: async () =>
unwrap( unwrap(
await session.client.POST('/api/v1/admin/users', { await session.client.POST('/api/v1/admin/users', {
body: { username: username.trim(), display_name: name.trim() || undefined, role }, body: {
username: username.trim(),
display_name: name.trim() || undefined,
role,
reviews_uploads: actorRole === 'admin' && reviews ? true : undefined,
},
}), }),
), ),
onSuccess: (r) => onCreated(r.user.username, r.temporary_password), onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
@@ -322,6 +369,13 @@ function CreateUserDialog({
</select> </select>
</label> </label>
)} )}
{actorRole === 'admin' && (
<label className={s.sessions} style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<input type="checkbox" checked={reviews} onChange={(e) => setReviews(e.target.checked)} />
<span>{t('create.review_uploads')}</span>
</label>
)}
{actorRole === 'admin' && <p className={s.note}>{t('create.review_uploads_hint')}</p>}
<p className={s.note}>{t('create.password_note')}</p> <p className={s.note}>{t('create.password_note')}</p>
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError> <FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
</form> </form>
@@ -329,6 +383,40 @@ function CreateUserDialog({
); );
} }
function ChangeUsernameDialog({ user, onClose, onSaved }: {
user: AdminUser;
onClose: () => void;
onSaved: (user: AdminUser) => Promise<void>;
}) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const [username, setUsername] = useState(user.username);
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', {
params: { path: { id: user.id } }, body: { username: username.trim() },
})),
onSuccess: onSaved,
});
const error = fieldError(save.error, 'username');
return (
<Dialog title={t('users.change_username')} onClose={onClose} footer={
<>
<Button variant="ghost" onClick={onClose}>{t('common:form.cancel')}</Button>
<Button type="submit" form="change-username" variant="primary" busy={save.isPending}
disabled={!username.trim() || username.trim() === user.username}>{t('common:settings.save')}</Button>
</>
}>
<form id="change-username" className={s.sessions} noValidate onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
<Field label={t('create.username')} value={username} onChange={setUsername} autoCapitalize="none"
spellCheck={false} hint={t('create.username_hint')} error={error} mono />
<FormError>{save.error && !error ? errorText(save.error) : null}</FormError>
</form>
</Dialog>
);
}
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) { function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@@ -320,3 +320,14 @@
gap: 10px; gap: 10px;
cursor: pointer; cursor: pointer;
} }
@media (max-width: 767px) {
.card { padding: 16px; }
.cardActions > button { flex: 1 1 auto; }
.retryRow { flex-wrap: wrap; gap: 10px; }
.patternRow { grid-template-columns: 20px minmax(0, 1fr); }
.patternTools { grid-column: 2; margin-top: 0 !important; height: auto; flex-wrap: wrap; }
.patternTools .check { flex-basis: 100%; min-height: 44px; }
.patternTools > button { width: 44px; min-width: 44px; padding: 0; }
.dir { min-height: 44px; height: auto; padding: 8px; overflow-wrap: anywhere; }
}
+7
View File
@@ -218,3 +218,10 @@
height: 44px; height: 44px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
} }
@media (max-width: 767px) {
.heroText { min-width: 0; }
.colTitle { font-size: clamp(24px, 7vw, 34px); overflow-wrap: anywhere; }
.underlineTabs { overflow-x: auto; }
.underlineTab { flex-shrink: 0; min-height: 44px; }
}
+19
View File
@@ -1,5 +1,7 @@
.home { .home {
min-height: 100%; min-height: 100%;
width: 100%;
overflow-x: clip;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
@@ -10,6 +12,17 @@
user-select: none; /* главная — только элементы управления волной */ user-select: none; /* главная — только элементы управления волной */
} }
/* Плеер на всю ширину окна, а главная — в колонке справа от сайдбара.
Сдвиг на половину сайдбара ставит волну на ту же ось, что и кнопка play.
Если чипам не хватает места, сдвиг меньше, чтобы они не уехали под меню. */
@media (min-width: 768px) {
.home {
transform: translateX(
calc(-1 * min(var(--side-w, 240px) / 2, max(0px, (100% - 760px) / 2)))
);
}
}
.orbLabel { .orbLabel {
margin-top: 4px; margin-top: 4px;
font-family: var(--font-display); font-family: var(--font-display);
@@ -96,3 +109,9 @@
.chipOn > span { .chipOn > span {
color: #fff; color: #fff;
} }
@media (max-width: 767px) {
.home { justify-content: flex-start; gap: 20px; padding: 8px 0 20px; }
.chip { min-height: 44px; height: auto; padding: 10px 14px; }
.orbLabel { font-size: clamp(18px, 5vw, 22px); }
}
+26
View File
@@ -341,3 +341,29 @@
font-size: 12px; font-size: 12px;
color: var(--text3); color: var(--text3);
} }
@media (max-width: 767px) {
.page, .section, .head > *, .toolbar > *, .settingRow > * { min-width: 0; max-width: 100%; }
.page { gap: 20px; overflow-wrap: anywhere; }
.head h1 { font-size: 24px; }
.section { padding: 16px; }
.row > :first-child, .search { min-width: 0; flex-basis: 100%; max-width: none; }
.settingRow { align-items: flex-start; gap: 10px; }
.settingRow > :first-child { flex-basis: 100%; }
.actions { flex-wrap: wrap; justify-content: flex-start; }
.actions > * { flex: 1 1 auto; }
.chip { min-height: 44px; height: auto; padding: 8px 14px; }
.select { max-width: 100%; min-width: 0; font-size: 16px; }
.session { flex-wrap: wrap; overflow-wrap: anywhere; }
.tableWrap { min-width: 0; max-width: 100%; }
.mobileCards, .mobileCards tbody { display: block; }
.mobileCards thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.mobileCards tr { display: flex; flex-direction: column; padding: 14px; border-bottom: 1px solid var(--line); gap: 12px; }
.mobileCards tr:last-child { border-bottom: 0; }
.mobileCards td { display: flex; flex-wrap: wrap; gap: 6px 10px; height: auto; padding: 0; border: 0; white-space: normal; }
.mobileCards td::before { content: attr(data-label); flex-basis: 100%; font-size: 12px; color: var(--text3); }
.mobileCards .userCell { min-width: 0; }
.mobileCards .userCell > div { min-width: 0; overflow-wrap: anywhere; }
.mobileCards .cardAction { align-self: flex-end; }
.cardAction::before { display: none; }
}
+12
View File
@@ -0,0 +1,12 @@
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
.card strong { overflow-wrap: anywhere; }
.card span { font-size: 13px; }
.card:hover { background: #ffffff10; }
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.actions { display: flex; align-items: center; }
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
.actions button:disabled { opacity: .3; cursor: default; }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
+7 -1
View File
@@ -1,7 +1,7 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
@@ -31,6 +31,12 @@ export function MyUploadsPage() {
const errorText = useErrorText(); const errorText = useErrorText();
const [filter, setFilter] = useState<Filter>('all'); const [filter, setFilter] = useState<Filter>('all');
useEffect(() => {
void session.client.POST('/api/v1/uploads/seen').then(() => {
void queryClient.invalidateQueries({ queryKey: ['uploads-unseen'] });
});
}, [session, queryClient]);
const list = useInfiniteQuery({ const list = useInfiniteQuery({
queryKey: ['my-uploads', filter], queryKey: ['my-uploads', filter],
initialPageParam: undefined as string | undefined, initialPageParam: undefined as string | undefined,
+19 -6
View File
@@ -4,7 +4,7 @@ import { useEffect, useRef, useState, useSyncExternalStore, type DragEvent } fro
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
import { AUDIO_EXTENSIONS, fromDrop, fromInput } from '../../features/uploads/files'; import { AUDIO_EXTENSIONS, fromDrop, fromInput, needsFormatAttention } from '../../features/uploads/files';
import { uploaderFor, type UploadItem } from '../../features/uploads/uploader'; import { uploaderFor, type UploadItem } from '../../features/uploads/uploader';
import { useErrorText } from '../../shared/lib/errors'; import { useErrorText } from '../../shared/lib/errors';
import { formatBytes } from '../../shared/lib/format'; import { formatBytes } from '../../shared/lib/format';
@@ -39,9 +39,10 @@ export function SuggestPage() {
}, []); }, []);
const ready = st.items.filter((it) => it.stage === 'ready'); const ready = st.items.filter((it) => it.stage === 'ready');
const readyBytes = ready.reduce((n, it) => n + it.file.size, 0); const readyBytes = ready.reduce((n, it) => n + it.size, 0);
const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking'); const hashing = st.items.some((it) => it.stage === 'hashing' || it.stage === 'checking');
const finished = !st.busy && st.batchId !== null; const finished = !st.busy && st.batchId !== null;
const resume = st.items.filter((it) => it.stage === 'resume').length;
const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length; const failedUploads = st.items.filter((it) => it.stage === 'error' && it.tusUrl).length;
const uploaded = st.items.filter((it) => it.stage === 'done').length; const uploaded = st.items.filter((it) => it.stage === 'done').length;
const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null; const free = quota.data ? Math.max(quota.data.limit_bytes - quota.data.used_bytes, 0) : null;
@@ -80,7 +81,8 @@ export function SuggestPage() {
> >
<Icon src={musicIcon} size={36} /> <Icon src={musicIcon} size={36} />
<div className={u.dropTitle}>{t('uploads.drop_title')}</div> <div className={u.dropTitle}>{t('uploads.drop_title')}</div>
<div className={s.note}>{t('uploads.drop_hint', { formats: AUDIO_EXTENSIONS.join(', ') })}</div> <p className={u.formatWarn}>{t('uploads.drop_hint')}</p>
<div className={s.note}>{t('uploads.drop_folders')}</div>
<div className={u.dropActions}> <div className={u.dropActions}>
<Button disabled={st.busy} onClick={() => filesRef.current?.click()}> <Button disabled={st.busy} onClick={() => filesRef.current?.click()}>
{t('uploads.pick_files')} {t('uploads.pick_files')}
@@ -96,7 +98,9 @@ export function SuggestPage() {
multiple multiple
accept={ACCEPT} accept={ACCEPT}
onChange={(e) => { onChange={(e) => {
uploader.add(fromInput(e.target.files)); const picked = fromInput(e.target.files);
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
else uploader.add(picked);
e.target.value = ''; e.target.value = '';
}} }}
/> />
@@ -106,12 +110,20 @@ export function SuggestPage() {
type="file" type="file"
multiple multiple
onChange={(e) => { onChange={(e) => {
uploader.add(fromInput(e.target.files)); const picked = fromInput(e.target.files);
if (uploader.getState().items.some((it) => it.stage === 'resume')) void uploader.continueWith(picked);
else uploader.add(picked);
e.target.value = ''; e.target.value = '';
}} }}
/> />
</div> </div>
{resume > 0 && (
<section className={s.section}>
<p>{t('uploads.resume_hint', { count: resume })}</p>
<Button onClick={() => filesRef.current?.click()}>{t('uploads.resume_pick')}</Button>
</section>
)}
{st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>} {st.skipped > 0 && <p className={s.note}>{t('uploads.skipped', { count: st.skipped })}</p>}
{st.items.length > 0 && ( {st.items.length > 0 && (
@@ -213,9 +225,10 @@ function FileRow({ item, locked, onRemove }: { item: UploadItem; locked: boolean
<span style={{ width: `${item.progress * 100}%` }} /> <span style={{ width: `${item.progress * 100}%` }} />
</div> </div>
)} )}
{needsFormatAttention(null, item.relPath) && <span className={u.warn}>{t('uploads.format_attention')}</span>}
{note && <span className={note.cls}>{note.text}</span>} {note && <span className={note.cls}>{note.text}</span>}
</div> </div>
<span className={u.size}>{formatBytes(item.file.size, i18n.language)}</span> <span className={u.size}>{formatBytes(item.size, i18n.language)}</span>
{locked ? ( {locked ? (
<span /> <span />
) : ( ) : (
@@ -25,6 +25,15 @@
font-weight: 600; font-weight: 600;
} }
.formatWarn {
max-width: 640px;
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.45;
color: var(--warn);
}
.dropActions { .dropActions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -230,6 +239,48 @@
gap: 12px; gap: 12px;
} }
/* Настройки очереди: подпись, поле и подсказка каждой ячейки — в общих строках, чтобы поля стояли на одном
уровне, даже когда подпись переносится. */
.settingsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
column-gap: 12px;
row-gap: 6px;
}
.settingsGrid > * {
display: grid;
grid-row: span 3;
grid-template-rows: subgrid;
gap: 6px;
min-width: 0;
margin-bottom: 10px;
}
.settingsGrid select {
width: 100%;
min-width: 0;
}
.settingsGrid > * > :first-child {
align-self: end;
}
.settingsLabel {
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.settingsCheck {
grid-row: 2;
display: flex;
align-items: center;
gap: 8px;
min-height: 48px;
font-size: 14px;
}
.textarea { .textarea {
width: 100%; width: 100%;
min-height: 72px; min-height: 72px;
@@ -269,3 +320,22 @@
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
@media (max-width: 767px) {
.queueRow { grid-template-columns: 24px minmax(0, 1fr); gap: 10px; padding: 12px; }
.queueRow > :nth-child(3), .queueRow > .rowActions { grid-column: 2; }
.rowActions { flex-wrap: wrap; min-width: 0; }
.rowActions audio { width: 100%; min-width: 0; max-width: 100%; }
.meta { overflow-wrap: anywhere; }
.bulk { bottom: 0; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.bulk > span:nth-child(2) { display: none; }
.bulk > button { min-width: 0; font-size: 13px; padding: 10px 8px; }
.grid, .settingsGrid { grid-template-columns: minmax(0, 1fr); }
.drop { padding: 20px 12px; }
.dropActions > * { flex: 1 1 auto; }
.quota { min-width: 0; max-width: 100%; }
.file { grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
.file > :last-child { grid-column: 2; grid-row: 1; }
.file > .size { grid-column: 1; }
.textarea { font-size: 16px; }
}
+48
View File
@@ -0,0 +1,48 @@
import type { AuthSession } from '@pipu/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Player } from './player';
afterEach(() => vi.restoreAllMocks());
function fixture() {
const player = new Player({} as AuthSession, document.createElement('audio'));
const sample = vi.fn((data: Uint8Array) => {
data.fill(0);
// 48 kHz / 1024: bins 1..4 cover the bass band.
data.fill(255, 1, 5);
});
Object.assign(player, {
state: { ...player.getState(), playing: true },
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample },
});
return { player, sample };
}
describe('audio reactivity', () => {
it('separates bass from the other frequency bands and shares the frame', () => {
vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture();
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 });
player.readAudio();
expect(sample).toHaveBeenCalledTimes(1);
});
it('returns silence after pausing or muting', () => {
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture();
player.readAudio();
Object.assign(player, { state: { ...player.getState(), playing: false } });
clock.mockReturnValue(120);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
clock.mockReturnValue(140);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
expect(sample).toHaveBeenCalledTimes(1);
});
it('works when Web Audio is unavailable', () => {
const player = new Player({} as AuthSession, document.createElement('audio'));
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
});
});
+198 -13
View File
@@ -1,5 +1,6 @@
import { import {
EventBuffer, EventBuffer,
normalizeGain,
PlayAttempt, PlayAttempt,
PlayQueue, PlayQueue,
unwrap, unwrap,
@@ -80,6 +81,9 @@ function deviceId(): string {
*/ */
export class Player { export class Player {
private readonly audio: HTMLAudioElement; private readonly audio: HTMLAudioElement;
private readonly overlap: HTMLAudioElement;
private fading = false;
private ignoreEnded = false;
private readonly queue = new PlayQueue(); private readonly queue = new PlayQueue();
private readonly events: EventBuffer; private readonly events: EventBuffer;
private attempt: PlayAttempt | null = null; private attempt: PlayAttempt | null = null;
@@ -93,6 +97,7 @@ export class Player {
private timers: ReturnType<typeof setInterval>[] = []; private timers: ReturnType<typeof setInterval>[] = [];
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = []; private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false; private waveLoading = false;
private restoreVersion = 0;
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */ /** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null; onReaction: (() => void) | null = null;
@@ -102,6 +107,8 @@ export class Player {
) { ) {
this.audio = audio ?? new Audio(); this.audio = audio ?? new Audio();
this.audio.preload = 'auto'; this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
this.events = new EventBuffer({ this.events = new EventBuffer({
send: async (events) => { send: async (events) => {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
@@ -153,6 +160,7 @@ export class Player {
const playable = tracks.filter((t) => t.available && t.stream_url); const playable = tracks.filter((t) => t.available && t.stream_url);
const target = tracks[index]; const target = tracks[index];
if (!target || !playable.length) return; if (!target || !playable.length) return;
this.restoreVersion++;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.leaveWave(); this.leaveWave();
this.context = context; this.context = context;
@@ -165,6 +173,7 @@ export class Player {
/** Запустить волну (или перезапустить с новым контекстом). */ /** Запустить волну (или перезапустить с новым контекстом). */
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) { async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
this.restoreVersion++;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.leaveWave(); this.leaveWave();
const res = unwrap( const res = unwrap(
@@ -269,10 +278,11 @@ export class Player {
play() { play() {
if (!this.queue.current) return; if (!this.queue.current) return;
if (!this.audio.src) { if (!this.audio.src || !this.attempt) {
void this.load(true, this.state.positionMs); void this.load(true, this.state.positionMs);
return; return;
} }
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса. // play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false })); void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
} }
@@ -324,15 +334,17 @@ export class Player {
} }
setVolume(v: number) { setVolume(v: number) {
this.audio.volume = Math.min(Math.max(v, 0), 1); const volume = Math.min(Math.max(v, 0), 1);
this.audio.muted = false; this.audio.muted = false;
this.update({ volume: this.audio.volume, muted: false }); this.update({ volume, muted: false });
this.applyLoudness();
this.saveLocal(); this.saveLocal();
} }
toggleMute() { toggleMute() {
this.audio.muted = !this.audio.muted; this.audio.muted = !this.audio.muted;
this.update({ muted: this.audio.muted }); this.update({ muted: this.audio.muted });
this.applyLoudness();
} }
toggleShuffle() { toggleShuffle() {
@@ -379,9 +391,13 @@ export class Player {
private async load(autoplay: boolean, startMs = 0) { private async load(autoplay: boolean, startMs = 0) {
const track = this.queue.current; const track = this.queue.current;
if (!track?.stream_url) return; if (!track?.stream_url) return;
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms }); this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
this.audio.src = track.stream_url; // Keep the restored element's buffer when the user presses Play.
if (startMs > 0) this.audio.currentTime = startMs / 1000; if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
this.updateMediaMetadata(track);
if (!autoplay) return;
this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs); this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null; this.preloaded = null;
this.events.push({ this.events.push({
@@ -391,11 +407,11 @@ export class Player {
context_type: this.context?.type, context_type: this.context?.type,
context_id: this.context?.id, context_id: this.context?.id,
}); });
this.updateMediaMetadata(track);
this.saveServer(); this.saveServer();
void this.refillWave(); void this.refillWave();
if (autoplay) { if (autoplay) {
try { try {
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
await this.audio.play(); await this.audio.play();
} catch { } catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать». // Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -429,6 +445,151 @@ export class Player {
} }
} }
private prefs() {
const user = this.session.state.status === 'signed_in' ? this.session.state.user : null;
return user?.ui_prefs;
}
private normalizeOn() {
return this.prefs()?.normalize !== false;
}
private crossfadeSeconds() {
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
}
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
private gain: GainNode | null = null;
private wired = false;
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
private spectrum = new Uint8Array(512);
private audioFrame = { bass: 0, mid: 0, treble: 0 };
private sampledAt = -Infinity;
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
readAudio = () => {
const now = performance.now();
if (now - this.sampledAt < 12) return this.audioFrame;
this.sampledAt = now;
const active = this.state.playing && !this.state.loading && !this.state.muted;
const analyser = this.analyser;
if (!active || !analyser) {
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
return this.audioFrame;
}
analyser.getByteFrequencyData(this.spectrum);
const band = (low: number, high: number) => {
const hz = analyser.context.sampleRate / analyser.fftSize;
const start = Math.max(1, Math.ceil(low / hz));
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
let sum = 0;
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
return Math.sqrt(sum / Math.max(1, end - start));
};
this.audioFrame.bass = band(40, 220);
this.audioFrame.mid = band(220, 2500);
this.audioFrame.treble = band(2500, 10000);
return this.audioFrame;
};
private applyLoudness(mix = 1) {
const track = this.state.track;
const user = this.state.muted ? 0 : this.state.volume;
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
this.audioContext = ctx;
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.55;
this.gain.connect(this.analyser);
// The overlap is already audible through its own output; analyse it only.
try {
const overlapSource = ctx.createMediaElementSource(this.overlap);
overlapSource.connect(ctx.destination);
overlapSource.connect(this.analyser);
} catch { /* Crossfade analysis is optional. */ }
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
}
/** В конце трека накладывает следующий на crossfade_s секунд. */
private maybeCrossfade(positionMs: number) {
const cf = this.crossfadeSeconds();
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
const dur = this.audio.duration;
if (!Number.isFinite(dur)) return;
const left = dur * 1000 - positionMs;
if (left <= 0 || left > cf * 1000) return;
const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return;
this.fading = true;
this.overlap.src = next.stream_url;
this.overlap.volume = 0;
void this.overlap.play().catch(() => {
this.fading = false;
});
const started = performance.now();
const step = () => {
if (!this.fading) return;
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
this.applyLoudness(1 - p);
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
if (p < 1) {
requestAnimationFrame(step);
return;
}
this.finishCrossfade(next);
};
requestAnimationFrame(step);
}
private finishCrossfade(next: Track) {
const at = this.overlap.currentTime;
this.ignoreEnded = true;
this.audio.pause();
this.overlap.pause();
this.finishAttempt('play_end');
if (!this.queue.next(true)) {
this.fading = false;
this.ignoreEnded = false;
this.update({ playing: false, positionMs: 0 });
return;
}
this.syncQueue();
const src = next.stream_url;
if (!src) {
this.fading = false;
this.ignoreEnded = false;
this.update({ playing: false });
return;
}
this.audio.src = src;
if (at > 0) this.audio.currentTime = at;
this.applyLoudness();
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.updateMediaMetadata(next);
void this.audio.play().finally(() => {
this.fading = false;
this.ignoreEnded = false;
});
}
private bindAudio() { private bindAudio() {
const a = this.audio; const a = this.audio;
a.addEventListener('playing', () => { a.addEventListener('playing', () => {
@@ -441,20 +602,28 @@ export class Player {
if (a.ended) return; if (a.ended) return;
this.update({ playing: false }); this.update({ playing: false });
const t = this.queue.current; const t = this.queue.current;
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs }); if (t && this.attempt) {
this.saveServer(); this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
this.saveServer();
}
});
a.addEventListener('waiting', () => {
if (this.attempt) this.update({ loading: true });
}); });
a.addEventListener('waiting', () => this.update({ loading: true }));
a.addEventListener('timeupdate', () => { a.addEventListener('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000); const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms); this.attempt?.tick(ms);
this.update({ positionMs: ms }); this.update({ positionMs: ms });
this.updatePositionState(); this.updatePositionState();
this.maybePreload(ms); if (this.state.playing) {
this.maybePreload(ms);
this.maybeCrossfade(ms);
}
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer(); if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal(); this.saveLocal();
}); });
a.addEventListener('ended', () => { a.addEventListener('ended', () => {
if (this.ignoreEnded || this.fading || !this.attempt) return;
this.finishAttempt('play_end'); this.finishAttempt('play_end');
if (this.queue.next(true)) { if (this.queue.next(true)) {
this.syncQueue(); this.syncQueue();
@@ -465,18 +634,24 @@ export class Player {
} }
}); });
a.addEventListener('error', () => void this.onError()); a.addEventListener('error', () => void this.onError());
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
} }
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */ /** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
private async onError() { private async onError() {
const track = this.queue.current; const track = this.queue.current;
if (!track) return; if (!track) return;
// A speculative load must never start playback or skip the saved track.
if (!this.attempt) {
this.audio.removeAttribute('src');
this.audio.load();
return;
}
if (this.refreshedFor !== track.id) { if (this.refreshedFor !== track.id) {
this.refreshedFor = track.id; this.refreshedFor = track.id;
try { try {
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })); const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
const fresh = res.items[0]; const fresh = res.items[0];
if (this.queue.current !== track || !this.attempt) return;
if (fresh?.stream_url) { if (fresh?.stream_url) {
Object.assign(track, fresh); Object.assign(track, fresh);
void this.load(true, this.state.positionMs); void this.load(true, this.state.positionMs);
@@ -618,6 +793,7 @@ export class Player {
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции. * если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
*/ */
async restore() { async restore() {
const version = ++this.restoreVersion;
let local: Saved | null = null; let local: Saved | null = null;
try { try {
const raw = localStorage.getItem(LOCAL_KEY); const raw = localStorage.getItem(LOCAL_KEY);
@@ -647,8 +823,9 @@ export class Player {
} catch { } catch {
// офлайн — остаёмся с локальной копией // офлайн — остаёмся с локальной копией
} }
if (!saved?.queue.length) return; if (version !== this.restoreVersion || !saved?.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })); const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
if (version !== this.restoreVersion) return;
const wanted = saved.queue[saved.index]; const wanted = saved.queue[saved.index];
const tracks = res.items.filter((t) => t.available && t.stream_url); const tracks = res.items.filter((t) => t.available && t.stream_url);
if (!tracks.length) return; if (!tracks.length) return;
@@ -662,12 +839,20 @@ export class Player {
this.syncQueue(); this.syncQueue();
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume }); this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
this.updateMediaMetadata(tracks[idx]!); this.updateMediaMetadata(tracks[idx]!);
// Prepare at the saved position without creating a listening attempt.
await this.load(false, pos);
} }
dispose() { dispose() {
this.restoreVersion++;
this.fading = false;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.audio.pause(); this.audio.pause();
this.audio.removeAttribute('src'); this.audio.removeAttribute('src');
this.audio.load();
this.overlap.pause();
this.overlap.removeAttribute('src');
this.overlap.load();
for (const t of this.timers) clearInterval(t); for (const t of this.timers) clearInterval(t);
this.timers = []; this.timers = [];
void this.events.flush(); void this.events.flush();
+79
View File
@@ -0,0 +1,79 @@
import type { AuthSession, Track } from '@pipu/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Player } from './player';
const track: Track = {
id: 'a', title: 'A', duration_ms: 200_000, artists: [],
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
genres: [], disc_no: null, track_no: null, available: true,
stream_url: '/s/a', reaction: null, features: null,
};
const ok = (data: unknown) => ({ data, response: new Response() });
function fixture() {
localStorage.removeItem('pipu.player');
const client = {
GET: vi.fn().mockResolvedValue(ok({
track_id: 'a', queue: ['a'], queue_index: 0, position_ms: 42_000,
volume: 0.8, shuffle: false, repeat_mode: 'off', updated_at: '2026-10-21',
})),
POST: vi.fn().mockResolvedValue(ok({ items: [track] })),
PUT: vi.fn().mockResolvedValue(ok({})),
};
const audio = document.createElement('audio');
const player = new Player({ client, state: { status: 'signed_out' } } as unknown as AuthSession, audio);
return { player, audio, client };
}
afterEach(() => { vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
describe('restored audio preparation', () => {
it('buffers at the saved position without playback or listening events, then reuses the source', async () => {
const { player, audio, client } = fixture();
const play = vi.spyOn(audio, 'play');
const source = vi.spyOn(audio, 'src', 'set');
await player.restore();
expect(audio.getAttribute('src')).toBe('/s/a');
expect(audio.currentTime).toBe(42);
expect(audio.preload).toBe('auto');
expect(play).not.toHaveBeenCalled();
expect(client.PUT).not.toHaveBeenCalled();
expect(player.getState()).toMatchObject({ playing: false, loading: false, positionMs: 42_000 });
player.onHide();
expect(client.POST).toHaveBeenCalledTimes(1); // Only the track lookup, no listening events.
player.play();
expect(play).toHaveBeenCalledTimes(1);
expect(source).toHaveBeenCalledTimes(1);
expect(player.getState().playing).toBe(true);
player.dispose();
});
it('does not autoplay or skip after a preload failure; Play retries the source', async () => {
const { player, audio } = fixture();
const play = vi.spyOn(audio, 'play');
await player.restore();
audio.dispatchEvent(new Event('error'));
audio.dispatchEvent(new Event('ended'));
expect(play).not.toHaveBeenCalled();
expect(player.getState()).toMatchObject({ track: { id: 'a' }, positionMs: 42_000, playing: false });
player.play();
expect(audio.getAttribute('src')).toBe('/s/a');
expect(play).toHaveBeenCalledTimes(1);
player.dispose();
});
it.each(['selection', 'dispose'])('ignores a restore lookup completed after %s', async (action) => {
const { player, client, audio } = fixture();
let resolve!: (value: unknown) => void;
client.POST.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const restoring = player.restore();
await vi.waitFor(() => expect(client.POST).toHaveBeenCalled());
if (action === 'selection') player.playList([{ ...track, id: 'b', stream_url: '/s/b' }], 0, { type: 'search' });
else player.dispose();
resolve(ok({ items: [track] }));
await restoring;
expect(audio.getAttribute('src')).toBe(action === 'selection' ? '/s/b' : null);
if (action === 'selection') player.dispose();
});
});
+3 -3
View File
@@ -1,7 +1,7 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
export type Role = components['schemas']['Role']; export type Role = components['schemas']['Role'];
export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete'; export type UserAction = 'reset_password' | 'reset_recommendations' | 'block' | 'change_role' | 'delete';
/** /**
* Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI: * Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI:
@@ -9,9 +9,9 @@ export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
*/ */
export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) { export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) {
const self = actor.id === target.id; const self = actor.id === target.id;
if (self && action !== 'reset_password') return false; if (self && (action === 'block' || action === 'change_role' || action === 'delete')) return false;
if (actor.role === 'admin') return true; if (actor.role === 'admin') return true;
if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user'; if (actor.role === 'moderator') return action !== 'change_role' && action !== 'delete' && target.role === 'user';
return false; return false;
} }
+40
View File
@@ -0,0 +1,40 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { shareTrack } from './share';
afterEach(() => vi.restoreAllMocks());
describe('shareTrack', () => {
it('copies on desktop even when native sharing exists', async () => {
const share = vi.fn();
const writeText = vi.fn().mockResolvedValue(undefined);
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Windows');
vi.stubGlobal('navigator', { userAgent: 'Windows', share, clipboard: { writeText } });
try {
expect(await shareTrack('id', 'Title')).toBe('copied');
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/track/id`);
expect(share).not.toHaveBeenCalled();
} finally { vi.unstubAllGlobals(); }
});
it('opens Android sharing with a page URL', async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { userAgent: 'Android', share });
try {
expect(await shareTrack('id', 'Title')).toBe('shared');
expect(share).toHaveBeenCalledWith({ title: 'Title', url: `${window.location.origin}/track/id` });
} finally { vi.unstubAllGlobals(); }
});
it('does not copy or report an error when the share sheet is dismissed', async () => {
const share = vi.fn().mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
vi.stubGlobal('navigator', { userAgent: 'Android', share });
try { expect(await shareTrack('id', 'Title')).toBe('cancelled'); }
finally { vi.unstubAllGlobals(); }
});
it('reports clipboard failures instead of pretending the link was copied', async () => {
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Denied')) } });
try { await expect(shareTrack('id', 'Title')).rejects.toThrow('Denied'); }
finally { vi.unstubAllGlobals(); }
});
});
+17
View File
@@ -0,0 +1,17 @@
import { trackShareUrl } from '@pipu/core';
/** Desktop always copies; mobile browsers can open their native share sheet. */
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
const url = trackShareUrl(window.location.origin, id);
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
try {
await navigator.share({ title, url });
return 'shared';
} catch (error) {
if (typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError') return 'cancelled';
// If sharing is unavailable, still allow copying the link.
}
}
await navigator.clipboard.writeText(url);
return 'copied';
}
+1
View File
@@ -3,6 +3,7 @@ export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg'; export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg'; export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg'; export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
export { default as shareIcon } from '@material-symbols/svg-400/rounded/share.svg';
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg'; export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg'; export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg'; export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
+23
View File
@@ -196,6 +196,7 @@
z-index: 50; z-index: 50;
display: grid; display: grid;
place-items: center; place-items: center;
grid-template-columns: minmax(0, 1fr);
padding: 16px; padding: 16px;
background: var(--overlay); background: var(--overlay);
animation: pipuIn 0.2s ease-out; animation: pipuIn 0.2s ease-out;
@@ -268,7 +269,9 @@
right: 0; right: 0;
top: calc(100% + 6px); top: calc(100% + 6px);
z-index: 40; z-index: 40;
width: max-content;
min-width: 220px; min-width: 220px;
max-width: min(360px, calc(100vw - 24px));
padding: 6px; padding: 6px;
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
background: var(--elev); background: var(--elev);
@@ -292,6 +295,7 @@
border-radius: 8px; border-radius: 8px;
font-size: 14px; font-size: 14px;
text-align: left; text-align: left;
white-space: nowrap;
color: var(--text); color: var(--text);
outline: none; outline: none;
} }
@@ -314,3 +318,22 @@
color: #fff; color: #fff;
font-weight: 700; font-weight: 700;
} }
@media (max-width: 767px) {
.button { white-space: normal; height: auto; min-height: 44px; padding: 10px 14px; }
.iconButton { width: 44px; height: 44px; flex: none; }
.input, .select { font-size: 16px; min-width: 0; }
.segmented { flex-wrap: wrap; max-width: 100%; }
.segmented button { min-height: 44px; height: auto; padding: 8px 12px; }
.overlay { padding: 12px; z-index: 60; }
.dialog { min-width: 0; overflow-wrap: anywhere; max-height: calc(100dvh - 24px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px)); }
.dialogHead { padding: 16px 16px 0; align-items: flex-start; }
.dialogHead h2 { overflow-wrap: anywhere; font-size: 18px; }
.dialogBody { padding: 16px; min-width: 0; }
.field, .inputWrap { min-width: 0; }
.dialogFoot { padding: 0 16px 16px; flex-wrap: wrap; }
.dialogFoot > * { flex: 1 1 auto; }
.menu { position: fixed; inset: auto 12px calc(12px + env(safe-area-inset-bottom, 0px));
width: auto; min-width: 0; max-width: none; max-height: 75dvh; overflow-y: auto; z-index: 70; }
.menuItem { min-height: 44px; height: auto; padding: 12px; white-space: normal; overflow-wrap: anywhere; }
}
+43 -25
View File
@@ -8,6 +8,7 @@ import {
type KeyboardEvent as ReactKeyboardEvent, type KeyboardEvent as ReactKeyboardEvent,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons'; import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
@@ -162,6 +163,18 @@ export function Dialog({
(first ?? ref.current)?.focus(); (first ?? ref.current)?.focus();
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose(); if (e.key === 'Escape') onClose();
if (e.key === 'Tab') {
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
) ?? [])].filter((el) => el.getClientRects().length > 0);
const first = controls[0];
const last = controls[controls.length - 1];
if (e.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) {
e.preventDefault(); last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault(); first?.focus();
}
}
}; };
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
return () => { return () => {
@@ -169,7 +182,7 @@ export function Dialog({
prev?.focus(); prev?.focus();
}; };
}, [onClose]); }, [onClose]);
return ( return createPortal(
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}> <div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}> <div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className={s.dialogHead}> <div className={s.dialogHead}>
@@ -181,7 +194,8 @@ export function Dialog({
<div className={s.dialogBody}>{children}</div> <div className={s.dialogBody}>{children}</div>
{footer && <div className={s.dialogFoot}>{footer}</div>} {footer && <div className={s.dialogFoot}>{footer}</div>}
</div> </div>
</div> </div>,
document.body,
); );
} }
@@ -205,17 +219,18 @@ export function Menu({
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
const onDown = (e: MouseEvent) => { const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false); if (!ref.current?.contains(e.target as Node) && !menuRef.current?.contains(e.target as Node)) setOpen(false);
}; };
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false); if (e.key === 'Escape') setOpen(false);
}; };
document.addEventListener('mousedown', onDown); document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey); document.addEventListener('keydown', onKey);
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus(); menuRef.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
return () => { return () => {
document.removeEventListener('mousedown', onDown); document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey); document.removeEventListener('keydown', onKey);
@@ -224,11 +239,31 @@ export function Menu({
const onMenuKey = (e: ReactKeyboardEvent) => { const onMenuKey = (e: ReactKeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return; if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault(); e.preventDefault();
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])]; const els = [...(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
const i = els.indexOf(document.activeElement as HTMLElement); const i = els.indexOf(document.activeElement as HTMLElement);
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length; const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
els[next]?.focus(); els[next]?.focus();
}; };
const menu = (
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
key={it.label}
type="button"
role="menuitem"
className={cx(s.menuItem, it.danger && s.menuDanger)}
onClick={() => {
setOpen(false);
it.onSelect();
}}
>
{it.icon && <Icon src={it.icon} size={18} />}
{it.label}
</button>
))}
</div>
);
return ( return (
<div className={s.menuWrap} ref={ref}> <div className={s.menuWrap} ref={ref}>
<button <button
@@ -241,26 +276,9 @@ export function Menu({
> >
{trigger} {trigger}
</button> </button>
{open && ( {open && (window.matchMedia?.('(max-width: 767px)').matches
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}> ? createPortal(menu, document.body)
{header} : menu)}
{items.map((it) => (
<button
key={it.label}
type="button"
role="menuitem"
className={cx(s.menuItem, it.danger && s.menuDanger)}
onClick={() => {
setOpen(false);
it.onSelect();
}}
>
{it.icon && <Icon src={it.icon} size={18} />}
{it.label}
</button>
))}
</div>
)}
</div> </div>
); );
} }
+22
View File
@@ -0,0 +1,22 @@
import { fakeApi, makeUser } from './fakeApi';
import { mobileFixtures } from './mobileFixtures';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
export function installE2EFetch() {
const api = fakeApi([
makeUser('admin', 'admin', { must_change_credentials: true }),
makeUser('alice', 'user'),
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
makeUser('moderator', 'moderator', { reviews_uploads: true }),
]);
const orig = window.fetch.bind(window);
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url, window.location.origin).pathname;
if (req.method === 'GET' && path in mobileFixtures) {
return new Response(JSON.stringify(mobileFixtures[path]), { headers: { 'Content-Type': 'application/json' } });
}
if (new URL(req.url, window.location.origin).pathname.startsWith('/api/')) return api.fetch(req);
return orig(input, init);
};
}
+65 -3
View File
@@ -56,6 +56,7 @@ export function makeUser(username: string, role: Role, extra: Partial<FakeUser>
is_blocked: false, is_blocked: false,
must_change_password: false, must_change_password: false,
must_change_credentials: false, must_change_credentials: false,
reviews_uploads: false,
created_at: '2026-01-01T00:00:00Z', created_at: '2026-01-01T00:00:00Z',
last_login_at: null, last_login_at: null,
password: `${username}-password`, password: `${username}-password`,
@@ -74,12 +75,14 @@ export function fakeApi(initial: FakeUser[]) {
let token = 0; let token = 0;
const calls: { method: string; path: string; body: unknown }[] = []; const calls: { method: string; path: string; body: unknown }[] = [];
const reactions = new Map<string, 'like' | 'dislike'>(); const reactions = new Map<string, 'like' | 'dislike'>();
const playlists = new Map<string, components['schemas']['Playlist']>();
const me = (u: FakeUser): Me => ({ const me = (u: FakeUser): Me => ({
id: u.id, id: u.id,
username: u.username, username: u.username,
display_name: u.display_name, display_name: u.display_name,
role: u.role, role: u.role,
reviews_uploads: u.reviews_uploads,
locale: u.locale, locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {}, ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password, must_change_password: u.must_change_password,
@@ -125,6 +128,23 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized'); if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
const old = playlists.get(id);
if (playlistPath && !old) return problem(404, 'playlist.not_found');
if (req.method === 'GET') return json(old);
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
const item = { id, name: body?.name as string, description: body?.description as string, tracks,
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
playlists.set(id, item);
return json(item, old ? 200 : 201);
}
switch (route) { switch (route) {
case 'POST /api/v1/auth/logout': case 'POST /api/v1/auth/logout':
case 'POST /api/v1/auth/logout-all': case 'POST /api/v1/auth/logout-all':
@@ -153,6 +173,16 @@ export function fakeApi(initial: FakeUser[]) {
return tokens(user); return tokens(user);
case 'GET /api/v1/me/sessions': case 'GET /api/v1/me/sessions':
return json({ items: [] }); return json({ items: [] });
case 'GET /api/v1/uploads/unseen':
return json({ count: 0 });
case 'POST /api/v1/uploads/seen':
return new Response(null, { status: 204 });
case 'GET /api/v1/onboarding':
return json({ required: false, artists: [], genres: [] });
case 'GET /api/v1/me/likes':
case 'GET /api/v1/me/dislikes':
case 'GET /api/v1/me/history':
return json({ items: [], next_cursor: null, total: 0 });
case 'GET /api/v1/admin/users': { case 'GET /api/v1/admin/users': {
if (user.role === 'user') return problem(403, 'auth.forbidden'); if (user.role === 'user') return problem(403, 'auth.forbidden');
const q = (url.searchParams.get('q') ?? '').toLowerCase(); const q = (url.searchParams.get('q') ?? '').toLowerCase();
@@ -169,6 +199,7 @@ export function fakeApi(initial: FakeUser[]) {
display_name: (body?.display_name as string) ?? null, display_name: (body?.display_name as string) ?? null,
must_change_password: true, must_change_password: true,
password: 'Temp-Pass-123', password: 'Temp-Pass-123',
reviews_uploads: body?.reviews_uploads === true && user.role === 'admin',
}); });
users.set(u.id, u); users.set(u.id, u);
return json({ user: admin(u), temporary_password: u.password }, 201); return json({ user: admin(u), temporary_password: u.password }, 201);
@@ -180,11 +211,14 @@ export function fakeApi(initial: FakeUser[]) {
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление. // «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
const q = url.searchParams.get('q') ?? ''; const q = url.searchParams.get('q') ?? '';
const hit = q === 'кино' || q === 'rbyj'; const hit = q === 'кино' || q === 'rbyj';
const tracks = path === '/api/v1/search' && hit
? albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null }))
: [];
return json({ return json({
artists: hit ? [kino] : [], artists: hit ? [kino] : [],
albums: hit ? [albumSummary] : [], albums: hit ? [albumSummary] : [],
tracks: [], tracks,
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 }, totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: tracks.length },
corrected_query: q === 'rbyj' ? 'кино' : null, corrected_query: q === 'rbyj' ? 'кино' : null,
next_cursor: null, next_cursor: null,
}); });
@@ -223,7 +257,35 @@ export function fakeApi(initial: FakeUser[]) {
case `GET /api/v1/artists/${kino.id}/albums`: case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] }); return json({ items: [albumSummary] });
} }
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path))
return new Response(null, { status: 204 });
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
if (userPath && req.method === 'PATCH') {
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
const target = users.get(userPath[1]!);
if (!target) return problem(404, 'common.not_found');
if (typeof body?.username === 'string') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
const username = body.username.trim().toLowerCase();
if ([...users.values()].some((u) => u.id !== target.id && u.username.toLowerCase() === username))
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
target.username = username;
}
if (typeof body?.reviews_uploads === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.reviews_uploads = body.reviews_uploads;
}
if (typeof body?.role === 'string' && user.role === 'admin') target.role = body.role as Role;
if (typeof body?.is_blocked === 'boolean') target.is_blocked = body.is_blocked;
users.set(target.id, target);
return json(admin(target));
}
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path); const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
if (trackPath && req.method === 'GET') {
const track = albumTracks.find(t => t.id === trackPath[1]);
return track ? json({ ...track, reaction: reactions.get(track.id) ?? null }) : problem(404, 'common.not_found');
}
if (reactionPath) { if (reactionPath) {
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike'); if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
else reactions.delete(reactionPath[1]!); else reactions.delete(reactionPath[1]!);
@@ -232,5 +294,5 @@ export function fakeApi(initial: FakeUser[]) {
return problem(404, 'common.not_found'); return problem(404, 'common.not_found');
}; };
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) }; return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
} }
+31
View File
@@ -0,0 +1,31 @@
import type { components } from '@pipu/api-client';
const source: components['schemas']['Source'] = {
id: '00000000-0000-4000-8000-000000000099', name: 'Музыкальное хранилище', type: 'local',
nfs_server: null, nfs_export: null, nfs_options: '', local_path: 'music/very-long-library-folder/collection',
tag_priority: 'tags_first', scan_interval_minutes: null, accepts_uploads: true,
write_template: null, status: 'mounted', status_message: null, last_scan_at: null,
created_at: '2026-01-01T00:00:00Z', scan: null,
};
const file: components['schemas']['UploadFile'] = {
id: '00000000-0000-4000-8000-000000000098', batch_id: '00000000-0000-4000-8000-000000000097',
batch_comment: 'Альбом для проверки', author: { id: '00000000-0000-4000-8000-000000000096', username: 'moderator' },
status: 'pending_review', original_name: 'Длинное_название_трека_для_проверки_переноса.flac',
client_rel_path: 'music/album/Длинное_название_трека_для_проверки_переноса.flac', size_bytes: 42000000,
received_bytes: 42000000, format: 'FLAC', codec: 'flac', bitrate: 1000000, sample_rate: 44100, bit_depth: 16,
duration_ms: 200000, tags: {}, metadata: null, metadata_edited: false, matched_artist_id: null,
matched_album_id: null, duplicate_of_track_id: null, duplicate_level: null, possible_duplicate: true,
quality_upgrade: false, target_source_id: source.id, target_rel_path: null, status_reason: null,
reviewed_at: null, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', tus_url: null,
stream_url: '/api/v1/stream/mobile-preview',
};
/** Populated staff screens for browser layout checks; loaded only by the E2E bridge. */
export const mobileFixtures: Record<string, unknown> = {
'/api/v1/admin/sources': { items: [source] },
[`/api/v1/admin/sources/${source.id}`]: source,
[`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] },
'/api/v1/admin/uploads': { items: [file], next_cursor: null },
'/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null,
user_used_bytes: 42000000, max_file_bytes: 1000000000 },
};
+3 -1
View File
@@ -11,8 +11,9 @@
.ambient span { .ambient span {
position: absolute; position: absolute;
border-radius: 50%; border-radius: 50%;
filter: blur(30px);
will-change: transform; will-change: transform;
scale: var(--music-scale, 1);
filter: blur(30px) brightness(var(--music-light, 1));
} }
.a { .a {
@@ -104,5 +105,6 @@
.ambient, .ambient,
.ambient span { .ambient span {
animation: none; animation: none;
scale: 1;
} }
} }
+26 -3
View File
@@ -1,4 +1,4 @@
import type { CSSProperties } from 'react'; import { useEffect, useRef, type CSSProperties } from 'react';
import s from './Ambient.module.css'; import s from './Ambient.module.css';
@@ -6,9 +6,32 @@ import s from './Ambient.module.css';
* Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека. * Ambient-фон (docs/design.md#1): три размытых дрейфующих пятна; третье — в цвете обложки текущего трека.
* Отключается в настройках; при prefers-reduced-motion пятна неподвижны. * Отключается в настройках; при prefers-reduced-motion пятна неподвижны.
*/ */
export function Ambient({ color }: { color: string }) { export function Ambient({ color, readAudio }: {
color: string;
readAudio?: () => { bass: number; mid: number; treble: number };
}) {
const root = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!readAudio) return;
const mq = window.matchMedia?.('(prefers-reduced-motion: reduce)');
let raf = 0;
let last = performance.now();
let level = 0;
const frame = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const audio = !mq?.matches && !document.hidden ? readAudio() : undefined;
const target = audio ? audio.bass * 0.65 + audio.mid * 0.25 + audio.treble * 0.1 : 0;
level += (target - level) * (1 - Math.exp(-dt * 7));
root.current?.style.setProperty('--music-scale', String(1 + level * 0.16));
root.current?.style.setProperty('--music-light', String(1 + level * 0.3));
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [readAudio]);
return ( return (
<div className={s.ambient} aria-hidden> <div ref={root} className={s.ambient} aria-hidden>
<span className={s.a} /> <span className={s.a} />
<span className={s.b} /> <span className={s.b} />
<span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} /> <span className={s.c} style={{ '--amb-c3': `${color}99` } as CSSProperties} />
+121 -33
View File
@@ -1,13 +1,15 @@
.shell { .shell {
--side-w: 240px;
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: 240px 1fr; grid-template-columns: var(--side-w) minmax(0, 1fr);
grid-template-rows: 60px 1fr 88px; grid-template-rows: 60px minmax(0, 1fr) 88px;
grid-template-areas: grid-template-areas:
'top top' 'top top'
'side content' 'side content'
'player player'; 'player player';
height: 100%; height: 100%;
height: 100dvh;
} }
.shell > :where(:not([aria-hidden])) { .shell > :where(:not([aria-hidden])) {
@@ -17,7 +19,7 @@
/* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */ /* Плеер-остров: плавает над контентом, контенту — запас снизу (docs/design.md#4). */
.island { .island {
grid-template-rows: 60px 1fr; grid-template-rows: 60px minmax(0, 1fr);
grid-template-areas: grid-template-areas:
'top top' 'top top'
'side content'; 'side content';
@@ -84,7 +86,7 @@
.profileHead { .profileHead {
display: grid; display: grid;
grid-template-columns: auto 1fr; grid-template-columns: auto max-content;
gap: 4px 12px; gap: 4px 12px;
align-items: center; align-items: center;
padding: 10px 10px 12px; padding: 10px 10px 12px;
@@ -100,11 +102,13 @@
.profileName { .profileName {
font-weight: 600; font-weight: 600;
white-space: nowrap;
} }
.profileMeta { .profileMeta {
font-size: 12px; font-size: 12px;
color: var(--text3); color: var(--text3);
white-space: nowrap;
} }
.side { .side {
@@ -118,12 +122,52 @@
} }
.navSection { .navSection {
margin: 18px 12px 6px; display: flex;
align-items: center;
gap: 8px;
margin: 12px 0 2px;
padding: 6px 12px;
border: 0;
border-radius: 8px;
background: none;
font: inherit;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
letter-spacing: 0.08em; letter-spacing: 0.08em;
text-transform: uppercase; text-transform: uppercase;
color: var(--text3); color: var(--text3);
text-align: left;
cursor: pointer;
}
.navSection:hover {
color: var(--text2);
}
.navSection:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.navSectionTitle {
flex: 1;
}
.navChevron {
transition: transform 0.2s;
}
.navChevronOpen {
transform: rotate(90deg);
}
/* Обёртка не мешает раскладке: пункты остаются детьми .side (на телефоне это нижняя панель). */
.navGroup {
display: contents;
}
.navGroupFolded {
display: none;
} }
.navItem { .navItem {
@@ -185,6 +229,8 @@
.content { .content {
grid-area: content; grid-area: content;
min-width: 0;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 32px 36px; padding: 32px 36px;
animation: pipuIn 0.2s ease-out; animation: pipuIn 0.2s ease-out;
@@ -192,7 +238,7 @@
@media (max-width: 1023px) { @media (max-width: 1023px) {
.shell { .shell {
grid-template-columns: 72px 1fr; --side-w: 72px;
} }
.navItem { .navItem {
@@ -209,41 +255,83 @@
.navSection { .navSection {
margin: 14px 0 6px; margin: 14px 0 6px;
text-align: center; padding: 0;
font-size: 0; border-radius: 0;
border-top: 1px solid var(--line); border-top: 1px solid var(--line);
} pointer-events: none;
}
@media (max-width: 767px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: 60px 1fr 72px 64px;
grid-template-areas:
'top'
'content'
'player'
'side';
} }
.side { .navSection > * {
flex-direction: row;
justify-content: space-around;
padding: 6px 8px;
border-right: 0;
border-top: 1px solid var(--line);
}
.navSection {
display: none; display: none;
} }
.navItem { /* Без подписей свернуть раздел нельзя — пункты видны всегда. */
flex: 1; .navGroupFolded {
display: contents;
} }
}
.content { .mobileBottom { display: none; }
padding: 20px 16px;
@media (max-width: 767px) {
.shell, .shell.island {
--side-w: 0px;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) 72px calc(64px + env(safe-area-inset-bottom, 0px));
grid-template-areas: 'top' 'content' 'player' 'mobile';
} }
.top {
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px 12px;
padding: calc(8px + env(safe-area-inset-top, 0px)) 16px 10px;
}
.topLeft, .topCenter, .topRight { display: contents; }
.logoLink { grid-area: 1 / 1; justify-self: start; }
.topRight > :last-child { grid-area: 1 / 2; justify-self: end; }
.topLeft > :not(.logoLink), .topCenter > *, .topRight > :not(:last-child) {
grid-column: 1 / -1;
grid-row: 2;
min-width: 0;
}
.profileButton { min-width: 44px; min-height: 44px; }
.profileHead { grid-template-columns: auto minmax(0, 1fr); }
.profileName, .profileMeta { white-space: normal; overflow-wrap: anywhere; }
.side { display: none; }
.mobileBottom {
grid-area: mobile;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
padding: 4px 8px calc(4px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--line);
background: var(--bar-a);
}
.mobileBottom .navItem {
position: relative;
flex-direction: column;
gap: 3px;
height: 56px;
padding: 6px 2px;
font-size: 11px;
}
.mobileBottom .navLabel { display: block; flex: none; max-width: 100%; }
.mobileDot {
position: absolute; top: 6px; right: 24%;
width: 8px; height: 8px; border-radius: 50%; background: var(--accent2);
}
.mobileMenu { display: flex; flex-direction: column; gap: 4px; }
.mobileMenu .navItem {
justify-content: flex-start; height: auto; min-height: 48px; padding: 8px 12px; font-size: 14px;
}
.mobileMenu .navLabel, .mobileMenu .navCount, .mobileMenu .navBadge { display: block; }
.mobileMenu .navLabel { white-space: normal; }
.mobileMenu .navGroup, .mobileMenu .navGroupFolded { display: contents; }
.mobileMenu .navSection {
display: flex; margin: 12px 0 0; padding: 10px 12px 4px;
border: 0; pointer-events: none;
}
.mobileMenu .navSectionTitle { display: block; }
.mobileMenu .navChevron { display: none; }
.content, .island .content { padding: 20px 16px 24px; }
} }
+162 -42
View File
@@ -1,11 +1,14 @@
import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate } from '@tanstack/react-router'; import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { Suspense, useCallback, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session'; import { useAuthState, useSession, useUser } from '../app/session';
import { useCollectionCounts } from '../entities/collection'; import { useCollectionCounts } from '../entities/collection';
import { import {
moreIcon,
auditFillIcon, auditFillIcon,
auditIcon, auditIcon,
groupFillIcon, groupFillIcon,
@@ -23,11 +26,12 @@ import {
dislikeIcon, dislikeIcon,
settingsIcon, settingsIcon,
androidIcon, androidIcon,
chevronRightIcon,
storageFillIcon, storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons'; storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui'; import { Avatar, cx, Dialog, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch'; import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider, usePlayerState } from '../player/context'; import { PlayerProvider, usePlayer, usePlayerState } from '../player/context';
import { Ambient } from './Ambient'; import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar'; import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox'; import { SearchBox } from './SearchBox';
@@ -43,6 +47,7 @@ type NavPath =
| '/uploads/new' | '/uploads/new'
| '/uploads/mine' | '/uploads/mine'
| '/admin/settings' | '/admin/settings'
| '/collection/playlists'
| '/collection/history' | '/collection/history'
| '/collection/likes' | '/collection/likes'
| '/collection/dislikes'; | '/collection/dislikes';
@@ -63,7 +68,7 @@ function NavLink({
badge?: number; // яркий бейдж (ждут решения) badge?: number; // яркий бейдж (ждут решения)
}) { }) {
return ( return (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}> <Link to={to} aria-label={label} title={label} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => ( {({ isActive }) => (
<> <>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} /> <Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
@@ -76,6 +81,67 @@ function NavLink({
); );
} }
const FOLDED_KEY = 'pipu.nav.folded';
const TOUCHED_KEY = 'pipu.nav.touched';
function readIds(key: string): string[] {
try {
const v: unknown = JSON.parse(localStorage.getItem(key) ?? '[]');
return Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : [];
} catch {
return [];
}
}
/** Раздел меню: заголовок сворачивает пункты; явный выбор помнит браузер. Свёрнутый раздел показывает сумму бейджей. */
function NavSection({
id,
title,
badge,
children,
defaultFolded = false,
mobile = false,
}: {
id: string;
title: string;
badge?: number;
children: ReactNode;
defaultFolded?: boolean;
mobile?: boolean;
}) {
const [folded, setFolded] = useState(() => {
if (readIds(FOLDED_KEY).includes(id)) return true;
if (readIds(TOUCHED_KEY).includes(id)) return false;
return defaultFolded;
});
const toggle = () => {
const next = !folded;
setFolded(next);
try {
const rest = readIds(FOLDED_KEY).filter((x) => x !== id);
localStorage.setItem(FOLDED_KEY, JSON.stringify(next ? [...rest, id] : rest));
const touched = readIds(TOUCHED_KEY);
if (!touched.includes(id)) localStorage.setItem(TOUCHED_KEY, JSON.stringify([...touched, id]));
} catch {
// Без хранилища раздел просто не запомнит состояние.
}
};
return (
<>
{mobile ? (
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
) : (
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}>
<span className={s.navSectionTitle}>{title}</span>
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
</button>
)}
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
</>
);
}
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */ /** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() { export function AppShell() {
return useAuthState().status === 'signed_in' ? ( return useAuthState().status === 'signed_in' ? (
@@ -94,9 +160,66 @@ function Shell() {
const staff = user.role !== 'user'; const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island'; const island = user.ui_prefs.web_player === 'island';
const searchAt = user.ui_prefs.search_position ?? 'center'; const searchAt = user.ui_prefs.search_position ?? 'center';
const search = <SearchBox align={searchAt} />; const search = <SearchBox align={searchAt} collapsible={user.ui_prefs.search_collapse === true} />;
const counts = useCollectionCounts(); const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin'); const reviews = user.reviews_uploads;
const pending = usePendingUploads(reviews);
const unseen = useUnseenUploads(staff);
const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const navigation = (mobile = false) => (
<>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink
to="/collection/dislikes"
label={t('nav.dislikes')}
icon={dislikeIcon}
activeIcon={dislikeFillIcon}
count={counts.dislikes}
/>
</NavSection>
{!staff && reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
{reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{user.role === 'admin' && (
<>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</NavSection>
)}
</>
);
return ( return (
<div className={cx(s.shell, island && s.island)}> <div className={cx(s.shell, island && s.island)}>
@@ -136,47 +259,31 @@ function Shell() {
</div> </div>
</header> </header>
<nav className={s.side} aria-label={t('nav.main')}> <nav className={s.side} aria-label={t('nav.main')}>{navigation()}</nav>
<nav className={s.mobileBottom} aria-label={t('nav.main')}>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} /> <NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<div className={s.navSection}>{t('nav.collection')}</div>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} /> <NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} /> <NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
<NavLink <button type="button" className={s.navItem} aria-label={t('nav.more')}
to="/collection/dislikes" aria-haspopup="dialog" aria-expanded={mobileOpen} onClick={() => setMobileOpen(true)}>
label={t('nav.dislikes')} <Icon src={moreIcon} className={s.navIcon} />
icon={dislikeIcon} <span className={s.navLabel}>{t('nav.more')}</span>
activeIcon={dislikeFillIcon} {(pending ?? 0) + (unseen ?? 0) > 0 && <span className={s.mobileDot} />}
count={counts.dislikes} </button>
/>
{staff && (
<>
<div className={s.navSection}>{t('nav.admin')}</div>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} />
{user.role === 'admin' && (
<>
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</>
)}
</nav> </nav>
{mobileOpen && (
<Dialog title={t('nav.main')} onClose={closeMobile}>
<nav className={s.mobileMenu} aria-label={t('nav.main')}
onClick={(e) => { if ((e.target as HTMLElement).closest('a')) closeMobile(); }}>
{navigation(true)}
</nav>
</Dialog>
)}
<main className={s.content}> <main className={s.content}>
<Outlet /> <Suspense fallback={null}>
<Outlet />
</Suspense>
</main> </main>
<PlayerBar variant={island ? 'island' : 'bar'} /> <PlayerBar variant={island ? 'island' : 'bar'} />
@@ -186,8 +293,9 @@ function Shell() {
/** Третье пятно фона — в цвете обложки текущего трека. */ /** Третье пятно фона — в цвете обложки текущего трека. */
function ShellAmbient() { function ShellAmbient() {
const player = usePlayer();
const track = usePlayerState().track; const track = usePlayerState().track;
return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} />; return <Ambient color={track?.album.cover_colors[0] ?? '#35D3FF'} readAudio={player.readAudio} />;
} }
/** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */ /** Файлов на рассмотрении — бейдж «Очереди» у админа (docs/uploads.md#10). */
@@ -204,3 +312,15 @@ function usePendingUploads(enabled: boolean) {
}); });
return q.data; return q.data;
} }
/** Новые решения по своим загрузкам — бейдж «Мои предложения» у персонала. */
function useUnseenUploads(enabled: boolean) {
const session = useSession();
const q = useQuery({
queryKey: ['uploads-unseen'],
enabled,
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/unseen')).count,
refetchInterval: 60_000,
});
return q.data || undefined;
}
+35
View File
@@ -1,4 +1,5 @@
.bar { .bar {
position: relative;
grid-area: player; grid-area: player;
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
@@ -297,3 +298,37 @@ a.source:hover {
.queueCurrent .title { .queueCurrent .title {
color: var(--accent2); color: var(--accent2);
} }
@media (max-width: 767px) {
.bar { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 0 8px; }
.bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto;
transform: none; margin: 0 8px; border-radius: 16px; }
.empty { grid-column: 1 / -1; }
.placeholder { flex: none; }
.now { gap: 8px; }
.now > :first-child { width: 44px !important; height: 44px !important; }
.source { display: none; }
.controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; }
.right > :not(.queueButton):not(.shareButton) { display: none; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 359px) {
.now > :first-child { display: none; }
}
.shareStatus {
position: absolute;
bottom: calc(100% + 8px);
right: 8px;
max-width: calc(100% - 16px);
padding: 10px 16px;
border-radius: 12px;
background: var(--surface3);
color: var(--text);
box-shadow: var(--shadow);
font-size: 13px;
}
+30 -2
View File
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog'; import { ArtistLinks, Cover } from '../entities/catalog';
import { usePlayer, usePlayerState } from '../player/context'; import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
import { import {
albumIcon, albumIcon,
closeIcon, closeIcon,
@@ -22,6 +23,7 @@ import {
repeatIcon, repeatIcon,
repeatOneIcon, repeatOneIcon,
shuffleIcon, shuffleIcon,
shareIcon,
volumeIcon, volumeIcon,
volumeOffIcon, volumeOffIcon,
waveIcon, waveIcon,
@@ -35,6 +37,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const player = usePlayer(); const player = usePlayer();
const st = usePlayerState(); const st = usePlayerState();
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
useEffect(() => {
setShareStatus(null);
}, [st.track?.id]);
useEffect(() => {
if (!shareStatus) return;
const timer = setTimeout(() => setShareStatus(null), 4000);
return () => clearTimeout(timer);
}, [shareStatus]);
const queueButton = useRef<HTMLButtonElement>(null); const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId(); const queueId = useId();
const closeQueue = useCallback(() => { const closeQueue = useCallback(() => {
@@ -57,6 +69,18 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
} }
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined); const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
setSharing(true);
setShareStatus(null);
try {
const result = await shareTrack(track.id, track.title);
if (result === 'copied') setShareStatus('copied');
} catch {
setShareStatus('error');
} finally {
setSharing(false);
}
};
const repeatLabel = t(`player.repeat_${st.repeat}`); const repeatLabel = t(`player.repeat_${st.repeat}`);
const ctx = st.context; const ctx = st.context;
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string; const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
@@ -67,7 +91,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
? t('player.source_wave_seed', { label: ctx.label }) ? t('player.source_wave_seed', { label: ctx.label })
: t('player.source_wave') : t('player.source_wave')
: ctx.type === 'collection' : ctx.type === 'collection'
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`) ? ctx.label ?? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' }); : tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
return ( return (
@@ -154,6 +178,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
</div> </div>
<div className={s.right}> <div className={s.right}>
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button <button
type="button" type="button"
className={cx(s.icon, st.shuffle && s.accent)} className={cx(s.icon, st.shuffle && s.accent)}
@@ -175,7 +202,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<button <button
ref={queueButton} ref={queueButton}
type="button" type="button"
className={cx(s.icon, queueOpen && s.accent)} className={cx(s.icon, s.queueButton, queueOpen && s.accent)}
aria-label={t('player.queue')} aria-label={t('player.queue')}
aria-expanded={queueOpen} aria-expanded={queueOpen}
aria-controls={queueOpen ? queueId : undefined} aria-controls={queueOpen ? queueId : undefined}
@@ -204,6 +231,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
/> />
</div> </div>
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />} {queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer> </footer>
); );
} }
+11
View File
@@ -135,6 +135,7 @@
cursor: pointer; cursor: pointer;
} }
.option:hover,
.active { .active {
background: var(--hover); background: var(--hover);
} }
@@ -185,3 +186,13 @@
display: none; display: none;
} }
} }
@media (max-width: 767px) {
.box, .expanded { width: 100%; min-width: 0; }
.input { height: 44px; font-size: 16px; }
.box:not(.expanded) .input { padding: 0 40px; color: var(--text); }
.box:not(.expanded) .input::placeholder { color: var(--text3); }
.left .popover, .center .popover, .right .popover { width: 100%; left: 0; right: auto; transform: none; max-height: 55dvh; }
.option { min-height: 48px; }
.all { min-height: 44px; height: auto; padding: 10px; }
}
+9 -4
View File
@@ -25,9 +25,15 @@ type Item = {
/** /**
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. * Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией. * Без фокуса и текста свёрнут в кнопку с лупой (если collapsible); поле раскрывается с анимацией.
*/ */
export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'right' }) { export function SearchBox({
align = 'center',
collapsible = false,
}: {
align?: 'left' | 'center' | 'right';
collapsible?: boolean;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -40,7 +46,7 @@ export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'r
const [focused, setFocused] = useState(false); const [focused, setFocused] = useState(false);
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined); const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K. // Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
const expanded = focused || q !== ''; const expanded = !collapsible || focused || q !== '';
useEffect(() => { useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS); const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
@@ -203,7 +209,6 @@ export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'r
aria-selected={i === active} aria-selected={i === active}
className={cx(s.option, i === active && s.active)} className={cx(s.option, i === active && s.active)}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActive(i)}
onClick={() => choose(i)} onClick={() => choose(i)}
> >
<Cover album={it.cover} size={36} round={it.round} /> <Cover album={it.cover} size={36} round={it.round} />
+16 -1
View File
@@ -5,6 +5,7 @@ import { drawLiquid, drawOrb, step, type WaveState } from './draw';
export interface WaveButtonProps { export interface WaveButtonProps {
variant?: 'orb' | 'liquid'; variant?: 'orb' | 'liquid';
playing?: boolean; playing?: boolean;
readAudio?: () => { bass: number; mid: number; treble: number };
bpm?: number; bpm?: number;
energy?: number; energy?: number;
colors?: [string, string, string]; colors?: [string, string, string];
@@ -28,6 +29,8 @@ function prefersReducedMotion(): boolean {
*/ */
export function WaveButton(props: WaveButtonProps) { export function WaveButton(props: WaveButtonProps) {
const size = props.size ?? 320; const size = props.size ?? 320;
const audioRef = useRef(props.readAudio);
audioRef.current = props.readAudio;
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const labelRef = useRef<HTMLDivElement>(null); const labelRef = useRef<HTMLDivElement>(null);
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
@@ -55,6 +58,7 @@ export function WaveButton(props: WaveButtonProps) {
const onMq = () => (st.current.reduced = prefersReducedMotion()); const onMq = () => (st.current.reduced = prefersReducedMotion());
mq?.addEventListener?.('change', onMq); mq?.addEventListener?.('change', onMq);
let raf = 0; let raf = 0;
let livePulse = 0;
let last = performance.now(); let last = performance.now();
const loop = (now: number) => { const loop = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000); const dt = Math.min(0.05, (now - last) / 1000);
@@ -67,12 +71,23 @@ export function WaveButton(props: WaveButtonProps) {
cv.height = Math.round(S * dpr); cv.height = Math.round(S * dpr);
} }
step(w, dt); step(w, dt);
const audio = !w.reduced && w.playing ? audioRef.current?.() : undefined;
if (audioRef.current && !w.reduced) {
const target = audio ? Math.min(1, audio.bass * 0.7 + audio.mid * 0.2) : 0;
livePulse += (target - livePulse) * (1 - Math.exp(-dt * (target > livePulse ? 35 : 9)));
w.pulse = livePulse;
w.amp = audio ? Math.min(1, audio.mid * 0.8 + audio.treble * 0.7) : 0;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, S, S); ctx.clearRect(0, 0, S, S);
const sc = 1 - w.press * 0.07 + w.hover * 0.02; const sc = 1 - w.press * 0.07 + w.hover * 0.02;
const r = w.size * 0.5 * sc; const r = w.size * 0.5 * sc * (1 + w.pulse * 0.055);
const cx = S / 2 + w.mx * w.hover * 6; const cx = S / 2 + w.mx * w.hover * 6;
const cy = S / 2 + w.my * w.hover * 6; const cy = S / 2 + w.my * w.hover * 6;
const ripple = audio ? w.amp * 0.025 * Math.sin(w.t * 13) : 0;
ctx.translate(cx, cy);
ctx.scale(1 + ripple, 1 - ripple);
ctx.translate(-cx, -cy);
if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t); if (w.variant === 'liquid') drawLiquid(ctx, cx, cy, r, w, w.t);
else drawOrb(ctx, cx, cy, r, w, w.t); else drawOrb(ctx, cx, cy, r, w, w.t);
if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`; if (labelRef.current) labelRef.current.style.transform = `scale(${sc + w.pulse * 0.03})`;
+22 -13
View File
@@ -65,16 +65,9 @@ export function drawOrb(ctx: CanvasRenderingContext2D, cx: number, cy: number, r
export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) { export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, s: WaveState, t: number) {
const cols = s.colors.map(rgb) as [Rgb, Rgb, Rgb]; const cols = s.colors.map(rgb) as [Rgb, Rgb, Rgb];
const glow = ctx.createRadialGradient(cx, cy, r * 0.4, cx, cy, r * 1.6); // Reuse the animated silhouettes for the halo so it follows every deformation.
glow.addColorStop(0, rgba(cols[0], 0.3 + s.pulse * 0.3)); const shapes = Array.from({ length: 3 }, (_, L) => {
glow.addColorStop(1, rgba(cols[0], 0)); const path = new Path2D();
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; const P = 140;
for (let j = 0; j <= P; j++) { for (let j = 0; j <= P; j++) {
const th = (j / P) * Math.PI * 2; const th = (j / P) * Math.PI * 2;
@@ -84,13 +77,29 @@ export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number
0.04 * Math.sin(5 * th - t * (1.3 + L * 0.1) + L) + 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.03 * Math.sin(7 * th + t * 1.7) * (0.5 + s.amp) +
0.08 * s.pulse * Math.sin(2 * th + L) + 0.08 * s.pulse * Math.sin(2 * th + L) +
0.018 * s.amp * Math.sin(11 * th + t * 18 + L) +
0.16 * s.hover * Math.pow(Math.max(0, Math.cos(th - s.ang)), 3); 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 rr = r * (0.86 - L * 0.07) * w;
const x = cx + Math.cos(th) * rr; const x = cx + Math.cos(th) * rr;
const y = cy + Math.sin(th) * rr; const y = cy + Math.sin(th) * rr;
if (j) ctx.lineTo(x, y); if (j) path.lineTo(x, y);
else ctx.moveTo(x, y); else path.moveTo(x, y);
} }
path.closePath();
return path;
});
ctx.save();
// Two soft falloffs avoid a visible circular boundary, even at peak pulse.
for (const [blur, opacity] of [[0.2, 0.14], [0.09, 0.18]] as const) {
ctx.filter = `blur(${r * blur}px)`;
ctx.fillStyle = rgba(cols[0], opacity * (0.8 + s.pulse * 0.6));
ctx.fill(shapes[0]!);
}
ctx.restore();
ctx.globalCompositeOperation = s.dark ? 'lighter' : 'source-over';
for (let L = 0; L < shapes.length; L++) {
const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3; const ox = Math.cos(t * 0.4 + L * 2) * r * 0.3;
const oy = Math.sin(t * 0.5 + L) * r * 0.3; const oy = Math.sin(t * 0.5 + L) * r * 0.3;
const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1); const g = ctx.createRadialGradient(cx + ox, cy + oy, 0, cx, cy, r * 1.1);
@@ -98,7 +107,7 @@ export function drawLiquid(ctx: CanvasRenderingContext2D, cx: number, cy: number
g.addColorStop(1, rgba(cols[L]!, 0.25)); g.addColorStop(1, rgba(cols[L]!, 0.25));
ctx.fillStyle = g; ctx.fillStyle = g;
ctx.globalAlpha = s.dark ? 0.6 : 0.75; ctx.globalAlpha = s.dark ? 0.6 : 0.75;
ctx.fill(); ctx.fill(shapes[L]!);
} }
ctx.globalAlpha = 1; ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over'; ctx.globalCompositeOperation = 'source-over';
+1
View File
@@ -20,5 +20,6 @@ export default defineConfig({
test: { test: {
environment: 'jsdom', environment: 'jsdom',
setupFiles: ['src/test/setup.ts'], setupFiles: ['src/test/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
}, },
}); });
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
-139
View File
@@ -1,139 +0,0 @@
#!/usr/bin/env bash
# Резервные копии PostgreSQL (docs/deployment.md#бэкапы). Работает в контейнере backup (compose.yaml) и в CI;
# подключение — стандартные PGHOST, PGUSER, PGPASSWORD, PGDATABASE.
# backup.sh loop — каждый день в BACKUP_HOUR (UTC): копия и ротация; раз в неделю — проверка восстановления
# backup.sh once — копия сейчас; печатает путь к файлу
# backup.sh verify FILE — восстановить FILE во временную базу и сверить таблицы и число строк
# backup.sh restore FILE — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены);
# прежняя база остаётся рядом как <db>_before_restore_<время>
set -euo pipefail
DIR="${BACKUP_DIR:-/backups}"
DB="${PGDATABASE:-pipu}"
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
log() { echo "$(date -u +%FT%TZ) backup: $*" >&2; }
# Число строк в каждой таблице базы $1: «таблица<TAB>строк», по алфавиту.
counts() {
psql -X -d "$1" -Atq -F $'\t' -c "
SELECT table_name,
(xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name),
false, true, '')))[1]::text
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY 1"
}
rotate() {
# shellcheck disable=SC2012 # имена файлов наши, без пробелов
ls -1t "$DIR"/daily/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_DAILY + 1)) | xargs -r rm -f --
# shellcheck disable=SC2012
ls -1t "$DIR"/weekly/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_WEEKLY + 1)) | xargs -r rm -f --
find "$DIR/daily" "$DIR/weekly" -name '*.counts' | while read -r c; do
[ -f "${c%.counts}.dump" ] || rm -f -- "$c"
done
}
once() {
mkdir -p "$DIR/daily" "$DIR/weekly"
local name file
name="pipu-$(date -u +%Y%m%d-%H%M%S)"
file="$DIR/daily/$name.dump"
pg_dump -Fc -d "$DB" -f "$file.part"
pg_restore -l "$file.part" >/dev/null # оглавление читается — архив не обрезан
counts "$DB" >"$file.counts" # для verify: что было в базе в момент копии
mv "$file.part" "$file"
# Еженедельная копия — первая за 7 дней (не зависит от того, в какой день сервер был выключен).
if ! find "$DIR/weekly" -name 'pipu-*.dump' -mtime -7 | grep -q .; then
cp "$file" "$DIR/weekly/$name.dump"
cp "$file.counts" "$DIR/weekly/$name.dump.counts"
fi
rotate
log "$file ($(du -h "$file" | cut -f1))"
echo "$file"
}
verify() {
local file="$1" tmpdb="${DB}_verify" got want
[ -f "$file" ] || { log "нет файла $file"; return 1; }
dropdb --if-exists "$tmpdb"
createdb "$tmpdb"
if ! pg_restore --no-owner --exit-on-error -d "$tmpdb" "$file"; then
dropdb --if-exists "$tmpdb"
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file не восстанавливается"
return 1
fi
got="$(counts "$tmpdb")"
dropdb "$tmpdb"
want="$(cat "$file.counts" 2>/dev/null || counts "$DB")"
# Таблица пропала или опустела — копия негодна. Расхождение в числе строк — рабочая база менялась во время
# копии (предупреждение); VERIFY_EXACT=1 (CI, база в покое) — тоже ошибка.
local bad=0 table n m
while IFS=$'\t' read -r table n; do
m="$(printf '%s\n' "$got" | awk -F'\t' -v t="$table" '$1 == t { print $2 }')"
if [ -z "$m" ]; then
log "в копии нет таблицы $table"
bad=1
elif [ "$m" = 0 ] && [ "$n" != 0 ]; then
log "в копии пуста таблица $table (было $n)"
bad=1
elif [ "$m" != "$n" ]; then
log "$table: $m строк в копии, $n при копировании"
[ "${VERIFY_EXACT:-}" = 1 ] && bad=1
fi
done <<<"$want"
if [ "$bad" = 1 ]; then
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file"
return 1
fi
log "проверено: $file восстанавливается ($(printf '%s\n' "$got" | wc -l) таблиц)"
date -u +%FT%TZ >"$DIR/.last-verify"
}
restore() {
local file="$1" old
verify "$file"
old="${DB}_before_restore_$(date -u +%Y%m%d%H%M%S)"
psql -X -d postgres -Atqc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '$DB' AND pid <> pg_backend_pid()" >/dev/null
psql -X -d postgres -qc "ALTER DATABASE \"$DB\" RENAME TO \"$old\""
createdb "$DB"
if ! pg_restore --no-owner --exit-on-error -d "$DB" "$file"; then
dropdb "$DB"
psql -X -d postgres -qc "ALTER DATABASE \"$old\" RENAME TO \"$DB\""
log "восстановление не удалось — рабочая база не тронута"
return 1
fi
log "база $DB восстановлена из $file; прежняя сохранена как $old (удалить: dropdb $old)"
}
loop() {
local hour="${BACKUP_HOUR:-3}" now next file
log "ежедневная копия в $hour:30 UTC, хранится $KEEP_DAILY ежедневных и $KEEP_WEEKLY еженедельных"
while true; do
now="$(date -u +%s)"
next="$(date -u -d "today $hour:30" +%s)"
[ "$next" -gt "$now" ] || next="$(date -u -d "tomorrow $hour:30" +%s)"
sleep $((next - now))
if ! file="$(once)"; then
log "КОПИЯ НЕ СДЕЛАНА"
continue
fi
if [ -z "$(find "$DIR" -maxdepth 1 -name .last-verify -mtime -7)" ]; then
verify "$file" || true
fi
done
}
case "${1:-}" in
loop) loop ;;
once) once ;;
verify) verify "${2:?укажите файл копии}" ;;
restore) restore "${2:?укажите файл копии}" ;;
*)
sed -n '2,9p' "$0" >&2
exit 2
;;
esac
-20
View File
@@ -145,26 +145,6 @@ services:
interval: 5s interval: 5s
retries: 20 retries: 20
# Ежедневная копия базы и еженедельная проверка восстановления (deploy/backup.sh, docs/deployment.md#бэкапы).
backup:
<<: *restart
image: pgvector/pgvector:0.8.7-pg17
entrypoint: ["bash", "/backup.sh"]
command: ["loop"]
environment:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: pipu
BACKUP_HOUR: ${BACKUP_HOUR:-3}
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
volumes:
- ./backup.sh:/backup.sh:ro
- ${PIPU_DATA:-/srv/pipu}/backups:/backups
depends_on:
postgres: { condition: service_healthy }
redis: redis:
<<: *restart <<: *restart
image: redis:7.4-alpine image: redis:7.4-alpine
+1 -1
View File
@@ -10,7 +10,7 @@ OWNER="${SUDO_USER:-root}"
DATA="${PIPU_DATA:-/srv/pipu}" DATA="${PIPU_DATA:-/srv/pipu}"
echo "→ каталоги в $DATA" echo "→ каталоги в $DATA"
mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,backups,caddy/data,caddy/config} mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,meili,redis,models,updates,uploads,caddy/data,caddy/config}
# Каталоги, куда пишут сервисы без root: api (distroless nonroot, uid 65532) и analyzer (uid 10001). # Каталоги, куда пишут сервисы без root: api (distroless nonroot, uid 65532) и analyzer (uid 10001).
# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш. # Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш.
-34
View File
@@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Восстановление базы из резервной копии (docs/deployment.md#бэкапы).
# ./restore.sh — список копий
# ./restore.sh daily/pipu-20261020-033000.dump
# Сервисы, пишущие в базу, останавливаются на время восстановления; прежняя база сохраняется рядом.
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
DATA="$(sed -n 's/^PIPU_DATA=//p' .env)"
DATA="${DATA:-/srv/pipu}"
if [ -z "${1:-}" ]; then
echo "Копии в $DATA/backups (новые сверху):"
(cd "$DATA/backups" && ls -1t daily/*.dump weekly/*.dump 2>/dev/null) || echo " копий нет"
exit 0
fi
rel="${1#"$DATA/backups/"}"
[ -f "$DATA/backups/$rel" ] || { echo "нет файла $DATA/backups/$rel" >&2; exit 1; }
echo "→ восстановление из $rel: api, рекомендации и анализ будут остановлены на время восстановления"
read -r -p "Продолжить? [y/N] " yes
[ "$yes" = y ] || [ "$yes" = Y ] || exit 1
docker compose stop api recommender analyzer storage-agent
if docker compose run --rm backup restore "/backups/$rel"; then
status=0
else
status=1
fi
docker compose up -d
[ "$status" = 0 ] || { echo "восстановление не удалось — база не изменена" >&2; exit 1; }
echo "готово. Поиск переиндексируется при старте api (или вручную: «Администрирование → Библиотека»)."
+1 -7
View File
@@ -88,19 +88,13 @@ tgt_core="$(core "$TARGET" || true)"
if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] && if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
[ "$(printf '%s\n%s\n' "$cur_core" "$tgt_core" | sort -V | head -1)" = "$tgt_core" ]; then [ "$(printf '%s\n%s\n' "$cur_core" "$tgt_core" | sort -V | head -1)" = "$tgt_core" ]; then
echo "внимание: $CURRENT → $TARGET — это откат. Миграции БД не откатываются;" >&2 echo "внимание: $CURRENT → $TARGET — это откат. Миграции БД не откатываются;" >&2
echo " сделайте резервную копию (docs/deployment.md) и проверьте, что старая версия совместима со схемой." >&2 echo " копию базы сделайте внешними средствами и проверьте, что старая версия совместима со схемой." >&2
if [ "${PIPU_ALLOW_DOWNGRADE:-}" != 1 ]; then if [ "${PIPU_ALLOW_DOWNGRADE:-}" != 1 ]; then
echo " Чтобы продолжить: PIPU_ALLOW_DOWNGRADE=1 ./update.sh $REF" >&2 echo " Чтобы продолжить: PIPU_ALLOW_DOWNGRADE=1 ./update.sh $REF" >&2
exit 1 exit 1
fi fi
fi fi
# Копия базы перед обновлением: миграции назад не откатываются (deploy/restore.sh — вернуть).
if [ -n "$(docker compose ps -q --status running postgres 2>/dev/null)" ]; then
echo "→ резервная копия базы"
docker compose run --rm --no-deps backup once >/dev/null
fi
echo "→ переключаемся на $REF" echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF" git checkout --quiet "$REF"
+3 -6
View File
@@ -1,7 +1,6 @@
# Pipu Music — исходная идея продукта # Pipu Music — исходная идея продукта
> Документ описывает **что** должно быть в приложении с точки зрения пользователя. > Как продукт выглядит для пользователя. Устройство системы — в [README документации](README.md).
> Используется как входные данные для создания дизайна. Технические решения — в [README документации](README.md).
## Суть ## Суть
@@ -58,19 +57,19 @@
### Левая панель навигации ### Левая панель навигации
- **Главная** - **Главная**
- **Коллекция** — с подкатегориями: - **Коллекция** — с подкатегориями:
- **Плейлисты** — личные подборки треков;
- **История** — прослушанные треки; - **История** — прослушанные треки;
- **Лайки** — понравившиеся треки; - **Лайки** — понравившиеся треки;
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк); - **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна. - **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
- **Админка** — видна только администраторам и модераторам. - **Админка** — видна только администраторам и модераторам.
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль), На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер. плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
### Плеер (всегда внизу) ### Плеер (всегда внизу)
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд, - Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь. прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь.
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки - **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
и даже на другом устройстве можно продолжить с того же места. и даже на другом устройстве можно продолжить с того же места.
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие). - Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
@@ -165,5 +164,3 @@
- Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки. - Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки.
- Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок. - Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок.
- Один репозиторий: веб, мобильное приложение, бэкенд, тесты, CI/CD.
- CI/CD на Gitea: по тегу собирается релиз.
+6 -10
View File
@@ -6,31 +6,27 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| Документ | О чём | | Документ | О чём |
|---|---| |---|---|
| [IDEA.md](IDEA.md) | Исходная идея продукта глазами пользователя (вход для дизайна) | | [IDEA.md](IDEA.md) | Продукт глазами пользователя |
| [design.md](design.md) | Дизайн-система (токены, шрифты, анимация), кнопка волны, решения по вариантам прототипа, ссылки на макеты | | [design.md](design.md) | Дизайн-система: токены, шрифты, анимация, кнопка волны, каркас экранов |
| [architecture.md](architecture.md) | Общая архитектура, компоненты, потоки данных, структура репозитория | | [architecture.md](architecture.md) | Общая архитектура, компоненты, потоки данных, структура репозитория |
| [decisions.md](decisions.md) | ADR — принятые архитектурные решения и их обоснование |
| [data-model.md](data-model.md) | Схема БД PostgreSQL | | [data-model.md](data-model.md) | Схема БД PostgreSQL |
| [api.md](api.md) | REST API: соглашения, эндпоинты, ошибки | | [api.md](api.md) | REST API: соглашения, эндпоинты, ошибки |
| [auth-and-roles.md](auth-and-roles.md) | Аутентификация, токены, роли и права, первый запуск | | [auth-and-roles.md](auth-and-roles.md) | Аутентификация, токены, роли и права, первый запуск |
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск | | [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище | | [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн | | [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» | | [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент | | [web.md](web.md) | Веб-клиент |
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление | | [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) | | [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [testing.md](testing.md) | Стратегия тестирования |
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android | | [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, бэкапы, диагностика | | [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы | | [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
## Стек в одном абзаце ## Стек в одном абзаце
Backend API — **Go**; рекомендации и аудиоанализ — **Python**; БД — **PostgreSQL + pgvector**; Backend API — **Go**; рекомендации и аудиоанализ — **Python**; БД — **PostgreSQL + pgvector**;
поиск — **Meilisearch**; кэш / очереди событий — **Redis**; раздача аудио — **nginx (X-Accel-Redirect)**; поиск — **Meilisearch**; кэш / очереди событий — **Redis**; раздача аудио — **nginx (X-Accel-Redirect)**;
веб — **React + TypeScript + Vite**; мобильное — **React Native (Expo, Android)**; веб — **React + TypeScript + Vite**; мобильное — **React Native (Expo, Android)**;
монорепо — **pnpm + Turborepo**; CI/CD — **Gitea Actions**; деплой — **Docker Compose**. деплой — **Docker Compose**.
+17 -25
View File
@@ -1,7 +1,7 @@
# Руководство администратора # Руководство администратора
Повседневная работа с сервером после установки ([install-guide.md](install-guide.md)): пользователи, музыка, Повседневная работа с сервером после установки ([install-guide.md](install-guide.md)): пользователи, музыка,
очередь на добавление, «Моя волна», обновления, бэкапы и диагностика. Все команды — из каталога очередь на добавление, «Моя волна», обновления и диагностика. Все команды — из каталога
`/opt/pipu-music/deploy`. Справочник по сервисам и переменным — [deployment.md](deployment.md). `/opt/pipu-music/deploy`. Справочник по сервисам и переменным — [deployment.md](deployment.md).
## 1. Первый вход ## 1. Первый вход
@@ -20,13 +20,21 @@
|---|---| |---|---|
| Пользователь | Слушать, искать, собирать коллекцию, «Моя волна» | | Пользователь | Слушать, искать, собирать коллекцию, «Моя волна» |
| Модератор | Плюс: заводить пользователей и сбрасывать им пароли, смотреть статус библиотеки, предлагать треки | | Модератор | Плюс: заводить пользователей и сбрасывать им пароли, смотреть статус библиотеки, предлагать треки |
| Администратор | Всё: хранилища, сканы, очередь на добавление, настройки сервера, роли, аудит | | Администратор | Всё: хранилища, сканы, настройки сервера, роли, аудит |
**Аудит загрузок** — отдельное право, не роль: рассматривать очередь на добавление (одобрять, отклонять, править
метаданные). Выдаёт и забирает только администратор — при создании пользователя или в меню строки
(«Дать / Забрать аудит загрузок»), в том числе себе. Право может быть у нескольких людей сразу и у любой роли;
у администратора его может не быть. Первый (bootstrap) администратор и админы, существовавшие до появления права,
получают его автоматически.
Полная матрица прав — [auth-and-roles.md](auth-and-roles.md#матрица-прав). Полная матрица прав — [auth-and-roles.md](auth-and-roles.md#матрица-прав).
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один - **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
раз, передайте его человеку. При первом входе пароль придётся сменить. раз, передайте его человеку. При первом входе пароль придётся сменить.
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются. - **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
- **Волна «застряла» во вкусе:** «Сбросить рекомендации» — профиль вкуса, статистика прослушиваний и показы волны
стираются, онбординг покажется снова. Лайки, дизлайки и история остаются (и по-прежнему влияют на подбор).
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей. - **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
- Последнего администратора нельзя понизить, заблокировать или удалить. - Последнего администратора нельзя понизить, заблокировать или удалить.
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы - **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
@@ -55,8 +63,8 @@
## 4. Очередь на добавление ## 4. Очередь на добавление
Модераторы и администраторы предлагают треки («Предложить треки»), администратор решает, что попадёт Модераторы и администраторы предлагают треки («Предложить треки»), что попадёт в библиотеку, решают люди
в библиотеку. Подробно — [uploads.md](uploads.md). с правом **аудита загрузок**. Подробно — [uploads.md](uploads.md).
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать» - **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
@@ -88,7 +96,7 @@
./update.sh ./update.sh
``` ```
Скрипт делает копию базы, переключается на последний релиз, собирает образы, перезапускает сервисы и ждёт Скрипт переключается на последний релиз, собирает образы, перезапускает сервисы и ждёт
готовности; миграции применяются при старте api. Конкретная версия — `./update.sh v1.0.0`; откат на старую версию готовности; миграции применяются при старте api. Конкретная версия — `./update.sh v1.0.0`; откат на старую версию
требует `PIPU_ALLOW_DOWNGRADE=1` (миграции не откатываются — сначала проверьте совместимость). Перед обновлением требует `PIPU_ALLOW_DOWNGRADE=1` (миграции не откатываются — сначала проверьте совместимость). Перед обновлением
прочитайте заметки к релизу на странице релизов Gitea. прочитайте заметки к релизу на странице релизов Gitea.
@@ -97,33 +105,17 @@ Android-приложение обновляется само: при запус
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза, Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля. адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
## 7. Бэкапы и восстановление ## 7. Наблюдение
- Каждую ночь (`BACKUP_HOUR`, по умолчанию 3:30 UTC) контейнер `backup` делает копию базы в
`/srv/pipu/backups/daily` (14 последних) и раз в неделю — в `weekly` (8 последних).
- Раз в неделю копия **проверяется восстановлением** во временную базу. Результат:
`docker compose logs backup | tail`; при ошибке в логе — «ПРОВЕРКА НЕ ПРОЙДЕНА».
- Копия сейчас: `docker compose run --rm backup once`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/<файл>` — восстановить. Прежняя база
остаётся рядом как `pipu_before_restore_<время>`; убедившись, что всё в порядке, удалите её:
`docker compose exec postgres dropdb -U pipu pipu_before_restore_<время>`.
- В копию входит всё, что нельзя пересоздать: пользователи, история, лайки, состояние плеера, очередь на добавление,
результаты аудиоанализа и модели волны. Поиск, обложки и транскоды восстанавливаются сами.
- **Сама музыка не бэкапится** приложением — это ваш диск или NFS. Каталог бэкапов тоже лежит на сервере:
копируйте `/srv/pipu/backups` на другую машину, например `rsync` по cron.
## 8. Наблюдение
| Что | Как | | Что | Как |
|---|---| |---|---|
| Сервер жив и готов | `curl -s https://music.example.com/readyz` → все зависимости `ok` | | Сервер жив и готов | `curl -s https://music.example.com/readyz` → все зависимости `ok` |
| Состояние сервисов | `docker compose ps` | | Состояние сервисов | `docker compose ps` |
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`, `backup`) | | Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`) |
| Место на диске | `df -h /srv/pipu`; больше всего занимают `cache/transcode` (чистится сам) и `backups` | | Место на диске | `df -h /srv/pipu`; больше всего занимает `cache/transcode` (чистится сам) |
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» | | Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
## 9. Частые ситуации ## 8. Частые ситуации
| Ситуация | Что делать | | Ситуация | Что делать |
|---|---| |---|---|
+4 -1
View File
@@ -91,6 +91,8 @@
| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` | | PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` |
| DELETE | `/me/reactions/tracks/{id}` | Снять реакцию | | DELETE | `/me/reactions/tracks/{id}` | Снять реакцию |
| PUT/DELETE | `/me/reactions/artists/{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) ### Моя волна — [recommendations.md](recommendations.md)
| Метод | Путь | Описание | | Метод | Путь | Описание |
@@ -114,6 +116,7 @@
| POST | `/admin/users` `{username, display_name, role}` → `{user, temporary_password}` (пароль генерирует сервер, `must_change_password = true`) | moderator: только `role=user`; admin: любые | | 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 | | PATCH | `/admin/users/{id}` `{display_name?, role?, is_blocked?}` | role — только admin |
| POST | `/admin/users/{id}/reset-password` → `{temporary_password}` | moderator — только для user | | POST | `/admin/users/{id}/reset-password` → `{temporary_password}` | moderator — только для user |
| POST | `/admin/users/{id}/reset-recommendations` → 204: рекомендации с нуля (профиль, статистика, показы волны) | moderator — только для user |
| DELETE | `/admin/users/{id}` | admin | | DELETE | `/admin/users/{id}` | admin |
| GET/POST | `/admin/sources` | admin | | GET/POST | `/admin/sources` | admin |
| GET/PATCH/DELETE | `/admin/sources/{id}` | admin | | GET/PATCH/DELETE | `/admin/sources/{id}` | admin |
@@ -135,7 +138,7 @@
| GET | `/uploads/quota` | moderator, admin | `{limit_bytes, used_bytes, per_user_limit_bytes, user_used_bytes, max_file_bytes}` | | 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/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` для не поместившихся | | 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`, ядро tus 1.0.0 — ADR-015) | | tus | `/uploads/files/{upload_file_id}` | автор | Докачка (`HEAD`/`PATCH`, ядро tus 1.0.0) |
| GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) | | GET | `/uploads/mine?status=&cursor=` | moderator, admin | Мои предложения (`status` — через запятую) |
| DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения | | DELETE | `/uploads/files/{id}` | автор | Отозвать (`withdrawn`), только до решения |
| GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам | | GET | `/admin/uploads?status=&author=&possible_duplicate=&cursor=` | admin | Очередь, сгруппированная по пачкам |
+2 -6
View File
@@ -101,7 +101,7 @@ pipu-music/
│ └── recommender/ # Python (uv) │ └── recommender/ # Python (uv)
│ ├── pipu_rec/ │ ├── pipu_rec/
│ │ ├── api/ # FastAPI │ │ ├── api/ # FastAPI
│ │ ├── analysis/ # Essentia │ │ ├── analysis/ # признаки и эмбеддинги (numpy)
│ │ ├── candidates/ ranking/ wave/ training/ │ │ ├── candidates/ ranking/ wave/ training/
│ │ └── storage/ # доступ к Postgres / Redis │ │ └── storage/ # доступ к Postgres / Redis
│ └── tests/ │ └── tests/
@@ -111,10 +111,6 @@ pipu-music/
│ ├── compose.dev.yaml │ ├── compose.dev.yaml
│ ├── nginx/ │ ├── nginx/
│ └── .env.example │ └── .env.example
├── tests/
│ ├── e2e/ # Playwright
│ └── fixtures/music/ # генерируемые тестовые аудиофайлы
├── .gitea/workflows/ # ci-*.yml, docker.yml (по областям), release.yml
├── docs/ ├── docs/
├── turbo.json pnpm-workspace.yaml package.json ├── turbo.json pnpm-workspace.yaml package.json
└── README.md └── README.md
@@ -127,7 +123,7 @@ pipu-music/
не лезут в чужие таблицы напрямую. не лезут в чужие таблицы напрямую.
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс - **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`. хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
CI падает, если сгенерированный код не совпадает с контрактом. Сгенерированный код совпадает с контрактом.
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети, - **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
описан отдельными `internal-*.yaml` спеками. описан отдельными `internal-*.yaml` спеками.
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7), - **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
+8 -3
View File
@@ -17,16 +17,21 @@
| Открыть админку | — | ✅ | ✅ | | Открыть админку | — | ✅ | ✅ |
| Видеть список пользователей | — | ✅ | ✅ | | Видеть список пользователей | — | ✅ | ✅ |
| Создать пользователя с ролью `user` | — | ✅ | ✅ | | Создать пользователя с ролью `user` | — | ✅ | ✅ |
| Сбросить пароль / заблокировать `user` | — | ✅ | ✅ | | Сбросить пароль / рекомендации, заблокировать `user` | — | ✅ | ✅ |
| Создать `moderator` / `admin`, менять роли | — | — | ✅ | | Создать `moderator` / `admin`, менять роли | — | — | ✅ |
| Сбросить пароль / заблокировать `moderator` | — | — | ✅ | | Менять логин любого пользователя, включая себя | — | — | ✅ |
| Сбросить пароль / рекомендации, заблокировать `moderator` | — | — | ✅ |
| Удалить пользователя | — | — | ✅ | | Удалить пользователя | — | — | ✅ |
| Хранилища, сканирование, переиндексация | — | — | ✅ | | Хранилища, сканирование, переиндексация | — | — | ✅ |
| Статус библиотеки (только просмотр) | — | ✅ | ✅ | | Статус библиотеки (только просмотр) | — | ✅ | ✅ |
| Предлагать треки (загрузка в очередь), видеть свои предложения | — | ✅ | ✅ | | Предлагать треки (загрузка в очередь), видеть свои предложения | — | ✅ | ✅ |
| Рассматривать очередь, одобрять / отклонять, править метаданные | — | — | ✅ |
| Лимит очереди, целевое хранилище, шаблон раскладки | — | — | ✅ | | Лимит очереди, целевое хранилище, шаблон раскладки | — | — | ✅ |
| Статус рекомендательной системы, аудит | — | — | ✅ | | Статус рекомендательной системы, аудит | — | — | ✅ |
| Выдать / забрать аудит загрузок | — | — | ✅ |
**Аудит загрузок** (рассматривать очередь, одобрять / отклонять, править метаданные) — не строка матрицы, а
отдельный флаг пользователя `reviews_uploads`, не зависящий от роли: его может иметь кто угодно, сразу несколько
человек, а у админа его может не быть. Проверка — `Principal.Allowed(PermReviewUploads)`.
Реализация: в Go — политики в `internal/auth/policy.go` вида `CanManageUser(actor, target, action) bool`, Реализация: в Go — политики в `internal/auth/policy.go` вида `CanManageUser(actor, target, action) bool`,
проверяются в сервисном слое (не только в middleware), покрываются табличными тестами по этой матрице. проверяются в сервисном слое (не только в middleware), покрываются табличными тестами по этой матрице.
-138
View File
@@ -1,138 +0,0 @@
# CI/CD (Gitea Actions)
Workflows лежат в `.gitea/workflows/`. Синтаксис совместим с GitHub Actions; runner — существующий `act_runner`
с доступом к Docker.
Сборке APK нужно ~15 ГБ свободного места на хосте раннера (Android SDK, NDK ~2.5 ГБ, кэш Gradle,
C++-сборка React Native). APK собирается только под `arm64-v8a`. Если job падает с `No space left on device`,
освободите место на хосте раннера: `docker system prune -af` и старые кэши act в `~/.cache/act`.
## Версионирование
- SemVer, тег `vMAJOR.MINOR.PATCH` (предрелизы: `v1.2.0-rc.1` — помечаются как pre-release, мобильное их
предлагает только при включённой настройке «бета-версии»).
- Одна версия на весь продукт: образы, APK и веб собираются с одним номером.
- Android `versionCode = MAJOR·1 000 000 + MINOR·10 000 + PATCH·100 + (99 для релиза | N для -rc.N)`,
так что `1.2.0-rc.1 < 1.2.0-rc.2 < 1.2.0 < 1.2.1`; MINOR и PATCH ≤ 99. Считает `scripts/version.mjs`
(тесты — `pnpm test:scripts`).
- Changelog — `git-cliff` из Conventional Commits.
## CI — на каждый push в `main` и PR
Проверки разбиты на workflow по областям, у каждого фильтр путей (`on.push.paths`): запускается только то,
что затронуто коммитом. Коммиты только в `docs/` и `*.md` CI не запускают вовсе.
| Workflow | Job | Что делает | Когда |
|---|---|---|---|
| `ci-api.yml` | `api` | gofmt, go vet, `go test -race`, сборка | `services/api/**`, `api/openapi.yaml` |
| `ci-contract.yml` | `contract` | регенерация Go-сервера, TS-клиента и токенов; всё ли закоммичено | `openapi.yaml`, `gen/`, `packages/api-client`, `packages/design-tokens`, общие файлы JS |
| `ci-recommender.yml` | `recommender` | ruff, mypy, pytest | `services/recommender/**` |
| `ci-ts.yml` | `ts` | i18n, тесты скриптов, typecheck (вкл. mobile), vitest, сборка веба | `apps/**`, `packages/**`, `scripts/**`, общие файлы JS |
| `ci-mobile.yml` | `mobile` | `expo-doctor`, Metro-бандл Android | `apps/mobile/**`, `packages/**`, общие файлы JS |
| `docker.yml` | `docker` | сборка всех 4 образов без push | `Dockerfile`, `.dockerignore`, `deploy/**`, манифесты зависимостей |
Общие файлы JS: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`.
Изменение самого workflow запускает его. У каждого workflow своя группа `concurrency`: новый push в ту же
ветку отменяет устаревший прогон.
`docker` собирает образы обычным `docker build` через Docker хоста раннера, а не в buildx-контейнере:
кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки:
`docker builder prune -af --filter until=72h` (см. [настройку раннера](#настройка-раннера)).
### Кэши
| Что | Где кэшируется | Чем |
|---|---|---|
| pnpm store | все job с `pnpm install` | `setup-node` `cache: pnpm` (кэш actions раннера) |
| Go-модули и build cache | `api`, `contract` | `setup-go` |
| uv | `recommender` | `setup-uv` `enable-cache` |
| Результаты turbo (typecheck, test, build) | `ts` | `actions/cache` для `.turbo/cache` |
| `npx expo-doctor`, трансформации Metro | `mobile` | `actions/cache` для `~/.npm/_npx` и `/tmp/metro-cache` |
| Слои Docker и `--mount=type=cache` | `docker`, сборка на сервере | Docker хоста; манифесты копируются раньше кода |
| Gradle (дистрибутив, зависимости, build cache), Android SDK и NDK | `android` в `release.yml` | тома `/opt/ci-cache/*` |
| Node, Go, Java, скачанные setup-action'ами | все job | том `act-toolcache` раннера |
Кэш actions работает, только если он включён на раннере ([настройка](#настройка-раннера)); без него в логах
`Cache action is only supported on GHES…`, и всё скачивается заново.
## `release.yml` — по тегу
```yaml
on:
push:
tags: ['v*.*.*']
jobs:
release: # версия из тега (scripts/version.mjs) и сразу Gitea Release (scripts/release.mjs create)
android: # needs: release — собирает APK и докладывает его в релиз
```
Тесты в релизе не повторяются: тег ставится на коммит, который CI уже проверил в `main`. **Ставьте тег
после зелёного CI** — релиз сам этого не проверяет.
Релиз появляется в первую же минуту, не дожидаясь APK. Текст строит `scripts/release.mjs`:
- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер,
versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог;
- **изменения** — коммиты (Conventional Commits) с прошлого тега по разделам «Новое», «Исправления»,
«Сборка и CI» и т. д., отдельно — несовместимые (`feat!:`); для финального релиза отсчёт от прошлого
финального, а не от последнего `-rc`; ссылка на полный diff;
- **обновление сервера** — бэкап, `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256.
Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease --build-cache` (только arm64-v8a), подпись `apksigner`,
затем `release.mjs android success <apk>` прикладывает APK и `.sha256` и меняет статус. При любом сбое —
`release.mjs android failure`. Перезапуск job безопасен: статус снова «собирается», файлы заменяются.
Перезапуск всего workflow обновляет текст существующего релиза, а не создаёт второй.
Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013).
Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR.
## Настройка раннера
Раннер — `act_runner` на отдельной VM. Что ускоряет CI/CD (`config.yaml` раннера, затем перезапуск сервиса):
```yaml
runner:
capacity: 2 # два job параллельно; только если у VM ≥ 8 ГБ RAM (иначе APK-сборка упирается в OOM)
cache:
enabled: true # кэш actions/cache: pnpm store, Go-модули, uv
host: 10.10.1.x # IP хоста раннера, доступный из job-контейнеров (не 127.0.0.1)
port: 8088 # фиксированный порт; открыть его для docker-сетей
container:
valid_volumes:
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
```
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
Без `valid_volumes` раннер пропускает тома из `release.yml`, и каждый релиз заново качает Gradle
и NDK (~4 мин). Признак выключенного кэша actions в логах — `Cache action is only supported on GHES…`.
Обслуживание хоста раннера (cron, раз в сутки):
```
0 4 * * * docker image prune -af --filter "until=72h"; docker builder prune -af --filter "until=72h"
```
Кэши в `/opt/ci-cache` занимают ~5–6 ГБ (Gradle, SDK, NDK); если место кончается — их можно удалить целиком,
следующий релиз скачает всё заново.
## Секреты
| Секрет | Назначение |
|---|---|
| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий |
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
Переменная репозитория `PUBLIC_URL` (Settings → Actions → Variables) — внешний адрес Gitea для ссылок
в тексте релиза, по умолчанию `https://gitea.hsrv.site`. Нужна потому, что job обращается к API по внутреннему
адресу, и ссылки из ответа API ведут туда же.
## Деплой
Автодеплой не обязателен. На сервере в клоне репозитория: `deploy/update.sh [vX.Y.Z]` — checkout тега,
`docker compose build` и `up -d`; миграции применяются при старте `api` ([install-guide.md](install-guide.md)).
Опционально — job `deploy` в `release.yml` по SSH на сервер для не-pre-release тегов (с ручным approval через environment).
Loaded 100 of 177 files, more files were not shown because too many files have changed in this diff. Show more