Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0cbd0f149 | ||
|
|
4c2286e70d | ||
|
|
9223516547 | ||
|
|
7b7ce78de8 | ||
|
|
4d2c6ce22f | ||
|
|
dca7173a88 | ||
|
|
1e3b350dfc | ||
|
|
74da457362 | ||
|
|
066ba24834 | ||
|
|
2b76060ea3 | ||
|
|
dfc72b1d69 | ||
|
|
978f03b8f8 | ||
|
|
b27b915b63 | ||
|
|
73e642c1ec |
No files matched your search
+32
-3
@@ -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,4 +1,4 @@
|
|||||||
# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта.
|
# Go: сервис api и storage-agent. Только при изменении их кода или контракта.
|
||||||
name: ci / api
|
name: ci / api
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
|
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
|
||||||
name: ci / contract
|
name: ci / contract
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -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,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,4 +1,4 @@
|
|||||||
# Python: recommender и analyzer (docs/ci-cd.md).
|
# Python: recommender и analyzer.
|
||||||
name: ci / recommender
|
name: ci / recommender
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -143,6 +143,11 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
./gradlew --stop
|
./gradlew --stop
|
||||||
|
|
||||||
|
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
|
||||||
|
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
|
||||||
|
- name: Проверка APK (ABI, 16 КБ страницы)
|
||||||
|
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
|
||||||
- name: Диагностика сбоя (диск, память, OOM)
|
- name: Диагностика сбоя (диск, память, OOM)
|
||||||
if: failure()
|
if: failure()
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
+161
-4
@@ -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
|
||||||
@@ -1604,6 +1708,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 +1824,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 +1898,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 +1926,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 +1949,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 +2005,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 +2021,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 +2047,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]
|
||||||
@@ -2018,6 +2154,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 +2176,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 +2188,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 +2231,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 +2243,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 +2283,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]
|
||||||
@@ -2152,6 +2306,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]
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
appId: app.pipu.music
|
||||||
|
---
|
||||||
|
- runFlow: login.yaml
|
||||||
|
- tapOn: "Коллекция"
|
||||||
|
- assertVisible: "Лайки"
|
||||||
@@ -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: "Бегаю"
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
appId: app.pipu.music
|
||||||
|
---
|
||||||
|
- runFlow: login.yaml
|
||||||
|
- tapOn: "Профиль"
|
||||||
|
- assertVisible: "Проверить обновления"
|
||||||
|
- tapOn: "Проверить обновления"
|
||||||
@@ -30,7 +30,11 @@ 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).
|
||||||
|
'./plugins/withArm64Only',
|
||||||
],
|
],
|
||||||
extra: { versionCode },
|
extra: { versionCode },
|
||||||
experiments: { typedRoutes: false },
|
experiments: { typedRoutes: false },
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
|
||||||
|
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
|
||||||
|
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
|
||||||
|
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
|
||||||
|
const { withAppBuildGradle } = require('expo/config-plugins');
|
||||||
|
|
||||||
|
const MARKER = '// pipu: arm64-v8a only';
|
||||||
|
|
||||||
|
module.exports = function withArm64Only(config) {
|
||||||
|
return withAppBuildGradle(config, (cfg) => {
|
||||||
|
if (cfg.modResults.contents.includes(MARKER)) return cfg;
|
||||||
|
cfg.modResults.contents = cfg.modResults.contents.replace(
|
||||||
|
/defaultConfig\s*\{/,
|
||||||
|
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
|
||||||
|
);
|
||||||
|
return cfg;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,29 @@ 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="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||||
|
</Stack.Protected>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,9 @@ 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';
|
||||||
|
|
||||||
|
export { offlineWave };
|
||||||
|
|
||||||
export interface WaveInfo {
|
export interface WaveInfo {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -45,15 +49,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 относительные — для приложения нужен полный адрес сервера. */
|
||||||
@@ -159,6 +154,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 +166,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 +209,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();
|
||||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ['test/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -8,6 +8,8 @@ WORKDIR /repo
|
|||||||
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||||
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||||
|
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
|
||||||
|
COPY patches/ patches/
|
||||||
COPY apps/web/package.json apps/web/
|
COPY apps/web/package.json apps/web/
|
||||||
COPY apps/mobile/package.json apps/mobile/
|
COPY apps/mobile/package.json apps/mobile/
|
||||||
COPY packages/api-client/package.json packages/api-client/
|
COPY packages/api-client/package.json packages/api-client/
|
||||||
|
|||||||
@@ -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') });
|
||||||
|
});
|
||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
});
|
});
|
||||||
@@ -164,7 +166,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: 'Создать пользователя' }));
|
||||||
@@ -313,6 +315,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'],
|
||||||
|
|||||||
+13
-10
@@ -7,16 +7,17 @@ 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 { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
@@ -225,7 +226,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -270,3 +270,11 @@
|
|||||||
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; }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
@@ -129,82 +130,115 @@ export function TrackList({
|
|||||||
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.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}>
|
||||||
|
{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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
@@ -47,6 +49,7 @@ export function UsersPage() {
|
|||||||
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 +82,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 +102,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);
|
||||||
};
|
};
|
||||||
@@ -101,6 +114,12 @@ export function UsersPage() {
|
|||||||
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
||||||
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 +132,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 +178,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 +197,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 +209,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} />
|
||||||
)}
|
)}
|
||||||
@@ -226,14 +252,14 @@ export function UsersPage() {
|
|||||||
|
|
||||||
{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 +291,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 +354,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>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
@@ -102,6 +106,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 } }));
|
||||||
@@ -273,6 +279,7 @@ export class Player {
|
|||||||
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 +331,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() {
|
||||||
@@ -382,6 +391,7 @@ export class Player {
|
|||||||
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||||
this.audio.src = track.stream_url;
|
this.audio.src = track.stream_url;
|
||||||
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
||||||
|
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({
|
||||||
@@ -396,6 +406,7 @@ export class Player {
|
|||||||
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 +440,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', () => {
|
||||||
@@ -451,10 +607,12 @@ export class Player {
|
|||||||
this.update({ positionMs: ms });
|
this.update({ positionMs: ms });
|
||||||
this.updatePositionState();
|
this.updatePositionState();
|
||||||
this.maybePreload(ms);
|
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) return;
|
||||||
this.finishAttempt('play_end');
|
this.finishAttempt('play_end');
|
||||||
if (this.queue.next(true)) {
|
if (this.queue.next(true)) {
|
||||||
this.syncQueue();
|
this.syncQueue();
|
||||||
@@ -465,7 +623,6 @@ 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 ч) — один раз берём свежую; иначе пропускаем трек. */
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
@@ -80,6 +81,7 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
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,
|
||||||
@@ -153,6 +155,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 +181,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);
|
||||||
@@ -223,6 +236,22 @@ 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?.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);
|
||||||
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');
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
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 +25,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';
|
||||||
@@ -63,7 +66,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 +79,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 +158,65 @@ 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/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 +256,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 +290,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 +309,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;
|
||||||
|
}
|
||||||
@@ -297,3 +297,24 @@ 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) { 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; }
|
||||||
|
}
|
||||||
@@ -175,7 +175,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}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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})`;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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/**'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
||||||
@@ -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
|
|
||||||
@@ -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
@@ -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).
|
||||||
# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш.
|
# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш.
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
+1
-4
@@ -1,7 +1,6 @@
|
|||||||
# Pipu Music — исходная идея продукта
|
# Pipu Music — исходная идея продукта
|
||||||
|
|
||||||
> Документ описывает **что** должно быть в приложении с точки зрения пользователя.
|
> Как продукт выглядит для пользователя. Устройство системы — в [README документации](README.md).
|
||||||
> Используется как входные данные для создания дизайна. Технические решения — в [README документации](README.md).
|
|
||||||
|
|
||||||
## Суть
|
## Суть
|
||||||
|
|
||||||
@@ -165,5 +164,3 @@
|
|||||||
|
|
||||||
- Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки.
|
- Музыка хранится на сервере, подключение к хранилищу по NFS; хранилища подключает админ из админки.
|
||||||
- Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок.
|
- Одобренные новые треки приложение само записывает на файловый сервер, соблюдая принятую там структуру папок.
|
||||||
- Один репозиторий: веб, мобильное приложение, бэкенд, тесты, CI/CD.
|
|
||||||
- CI/CD на Gitea: по тегу собирается релиз.
|
|
||||||
+5
-10
@@ -6,10 +6,9 @@ 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) | Аутентификация, токены, роли и права, первый запуск |
|
||||||
@@ -20,17 +19,13 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
|||||||
| [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
@@ -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. Частые ситуации
|
||||||
|
|
||||||
| Ситуация | Что делать |
|
| Ситуация | Что делать |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
+2
-1
@@ -114,6 +114,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 +136,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 | Очередь, сгруппированная по пачкам |
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -17,16 +17,20 @@
|
|||||||
| Открыть админку | — | ✅ | ✅ |
|
| Открыть админку | — | ✅ | ✅ |
|
||||||
| Видеть список пользователей | — | ✅ | ✅ |
|
| Видеть список пользователей | — | ✅ | ✅ |
|
||||||
| Создать пользователя с ролью `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
@@ -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).
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
# Соглашения
|
|
||||||
|
|
||||||
## Git
|
|
||||||
|
|
||||||
- Trunk-based: короткие ветки от `main`, merge через PR (squash).
|
|
||||||
- Имена веток: `feat/wave-moods`, `fix/scan-rename`, `chore/ci-cache`.
|
|
||||||
- Conventional Commits: `feat(wave): add running profile`, `fix(api): ...`. Скоупы: `api`, `agent`, `rec`, `web`,
|
|
||||||
`mobile`, `core`, `i18n`, `ci`, `deploy`, `docs`. Breaking change — `!` и раздел `BREAKING CHANGE:`.
|
|
||||||
- Изменение API = изменение `openapi.yaml` + регенерация клиентов в том же PR.
|
|
||||||
- Существенные архитектурные решения — новый ADR в [decisions.md](decisions.md).
|
|
||||||
|
|
||||||
## Go (`services/api`)
|
|
||||||
|
|
||||||
- `gofumpt`, `golangci-lint` (конфиг в репо). Ошибки оборачиваются `fmt.Errorf("...: %w", err)`;
|
|
||||||
доменные ошибки — типизированные (`ErrForbidden`, `ErrNotFound`) и маппятся в problem+json в одном месте.
|
|
||||||
- Контекст первым аргументом, без глобального состояния, зависимости через конструкторы (`NewService(repo, clock, ...)`).
|
|
||||||
- `clock` и генератор ID — интерфейсы (детерминированные тесты).
|
|
||||||
- SQL — только в `queries/*.sql` (sqlc), без ORM.
|
|
||||||
|
|
||||||
## Python (`services/recommender`)
|
|
||||||
|
|
||||||
- `uv`, `ruff` (lint + format), `mypy --strict`, Pydantic v2 для схем.
|
|
||||||
- Тяжёлые вычисления — `numpy`, без циклов Python по трекам.
|
|
||||||
- Конфиги контекстов и весов — YAML, валидируются Pydantic при старте.
|
|
||||||
|
|
||||||
## TypeScript
|
|
||||||
|
|
||||||
- `strict`, ESLint (flat config) + Prettier, без `any` (кроме сгенерированного кода).
|
|
||||||
- Общая логика — в `packages/core`, платформенная — только адаптеры в `apps/*`.
|
|
||||||
- Все строки UI — через i18n, хардкод текста ловит `eslint-plugin-i18next`.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
- `snake_case` в JSON, курсорная пагинация, problem+json с `code`.
|
|
||||||
- Новые поля — только дополнительные; удаление / переименование — через новую версию эндпоинта или депрекацию на один минорный релиз.
|
|
||||||
|
|
||||||
## Безопасность
|
|
||||||
|
|
||||||
- Секреты только через env / docker secrets, никогда в репозитории.
|
|
||||||
- Любой ввод, попадающий в команды (`mount`, `ffmpeg`), — только как отдельные аргументы `exec.Command`, без shell,
|
|
||||||
с whitelist-валидацией.
|
|
||||||
- Пути файлов — проверка, что итоговый путь внутри корня источника (`filepath.Rel` без `..`).
|
|
||||||
+10
-2
@@ -136,6 +136,7 @@ CREATE TABLE upload_batches ( -- одна «пачка» загруз
|
|||||||
id uuid PRIMARY KEY,
|
id uuid PRIMARY KEY,
|
||||||
author_id uuid NOT NULL REFERENCES users(id),
|
author_id uuid NOT NULL REFERENCES users(id),
|
||||||
comment text, -- комментарий автора для админа
|
comment text, -- комментарий автора для админа
|
||||||
|
cover_path text, -- обложка пачки (JPEG/PNG/WebP), вшивается при импорте
|
||||||
created_at timestamptz NOT NULL DEFAULT now()
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -160,6 +161,7 @@ CREATE TABLE upload_files (
|
|||||||
duplicate_of_track_id uuid REFERENCES tracks(id),
|
duplicate_of_track_id uuid REFERENCES tracks(id),
|
||||||
duplicate_level text, -- sha256 | metadata | fingerprint
|
duplicate_level text, -- sha256 | metadata | fingerprint
|
||||||
possible_duplicate boolean NOT NULL DEFAULT false,
|
possible_duplicate boolean NOT NULL DEFAULT false,
|
||||||
|
quality_upgrade boolean NOT NULL DEFAULT false, -- тот же трек, но лучше по качеству
|
||||||
target_source_id uuid REFERENCES library_sources(id),
|
target_source_id uuid REFERENCES library_sources(id),
|
||||||
target_rel_path text,
|
target_rel_path text,
|
||||||
status_reason text, -- код причины: uploads.duplicate, uploads.invalid_audio, текст отклонения
|
status_reason text, -- код причины: uploads.duplicate, uploads.invalid_audio, текст отклонения
|
||||||
@@ -173,12 +175,18 @@ CREATE INDEX ON upload_files (status);
|
|||||||
CREATE INDEX ON upload_files (author_id, created_at DESC);
|
CREATE INDEX ON upload_files (author_id, created_at DESC);
|
||||||
CREATE INDEX ON upload_files (sha256);
|
CREATE INDEX ON upload_files (sha256);
|
||||||
|
|
||||||
-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса;
|
-- Счётчик квоты (одна строка). Обновляется в той же транзакции, что и смена статуса.
|
||||||
-- ночная задача сверяет его с SUM(size_bytes) по «занимающим» статусам.
|
-- При старте api и каждые 10 минут сверяется с SUM(size_bytes) по «занимающим» статусам.
|
||||||
CREATE TABLE upload_quota (
|
CREATE TABLE upload_quota (
|
||||||
id boolean PRIMARY KEY DEFAULT true CHECK (id),
|
id boolean PRIMARY KEY DEFAULT true CHECK (id),
|
||||||
used_bytes bigint NOT NULL DEFAULT 0
|
used_bytes bigint NOT NULL DEFAULT 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- Когда автор последний раз открыл «Мои предложения» (бейдж новых решений).
|
||||||
|
CREATE TABLE upload_visits (
|
||||||
|
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
seen_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
## Каталог
|
## Каталог
|
||||||
|
|||||||
@@ -1,205 +0,0 @@
|
|||||||
# Архитектурные решения (ADR)
|
|
||||||
|
|
||||||
Формат: контекст → решение → последствия. Новое решение — новый раздел с номером; старые не переписываются,
|
|
||||||
а помечаются `Заменено ADR-NNN`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-001. Монорепозиторий
|
|
||||||
|
|
||||||
**Контекст.** Веб, мобильное, бэкенд, тесты и CI должны жить в одном репозитории; у веба и мобильного много общего
|
|
||||||
(API-клиент, логика очереди и событий, словари i18n).
|
|
||||||
|
|
||||||
**Решение.** Монорепо: pnpm workspaces + Turborepo для TS-части; Go-модуль в `services/api`; Python-проект
|
|
||||||
(uv) в `services/recommender`. Единый `api/openapi.yaml`.
|
|
||||||
|
|
||||||
**Последствия.** Атомарные изменения контракта и клиентов в одном PR. CI использует фильтры путей,
|
|
||||||
чтобы не гонять всё на каждый коммит.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-002. Backend API на Go
|
|
||||||
|
|
||||||
**Контекст.** Нагрузка — IO (стриминг, NFS, БД), десятки одновременных слушателей; важна простота деплоя и
|
|
||||||
низкое потребление памяти.
|
|
||||||
|
|
||||||
**Решение.** Go 1.23+: роутер `chi`, `oapi-codegen` (strict server), `pgx` + `sqlc`, миграции `goose`,
|
|
||||||
фоновые задачи `River` (очередь в Postgres), логирование `log/slog`, конфиг через env (`caarlos0/env`).
|
|
||||||
|
|
||||||
**Последствия.** Один статический бинарник на сервис, быстрые холодные старты, тайпсейф SQL.
|
|
||||||
ML-код в Go не пишем — для этого отдельный сервис (ADR-003).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-003. Рекомендации и аудиоанализ — отдельный Python-сервис
|
|
||||||
|
|
||||||
**Контекст.** Нужны: аудиоанализ (Essentia и её предобученные модели), коллаборативная фильтрация (implicit ALS),
|
|
||||||
learning-to-rank (LightGBM). Экосистема есть только в Python.
|
|
||||||
|
|
||||||
**Решение.** `services/recommender`: FastAPI (онлайн-инференс), отдельный процесс `analyzer` (тот же образ),
|
|
||||||
обучение по расписанию (APScheduler внутри сервиса). Python 3.12, `uv`, `numpy`, `implicit`, `lightgbm`,
|
|
||||||
`essentia-tensorflow`, `psycopg`, `redis`.
|
|
||||||
|
|
||||||
**Последствия.** Два языка на бэкенде. Граница чёткая: Python не обслуживает пользователей напрямую,
|
|
||||||
только внутренние вызовы `api`. При падении сервиса работает fallback в `api`.
|
|
||||||
|
|
||||||
⚠️ Предобученные модели Essentia распространяются под CC BY-NC-SA 4.0 — допустимо для частного
|
|
||||||
некоммерческого self-hosted использования. При коммерческом использовании нужно заменить модели.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-004. Мобильное приложение — React Native (Expo), только Android
|
|
||||||
|
|
||||||
**Контекст.** Только Android, самообновление APK из релизов, фоновое воспроизведение, офлайн, анимированная кнопка волны.
|
|
||||||
Рассматривались Flutter и React Native.
|
|
||||||
|
|
||||||
**Решение.** React Native + Expo SDK (prebuild / dev client, не Expo Go), `expo-router`,
|
|
||||||
`react-native-track-player` (ExoPlayer под капотом, MediaSession, уведомление, кэш), `@shopify/react-native-skia`
|
|
||||||
+ `react-native-reanimated` для волны, `expo-sqlite` для офлайн-данных, `expo-file-system` для загрузок.
|
|
||||||
|
|
||||||
**Почему не Flutter.** Flutter не хуже по возможностям, но React Native даёт общий TypeScript-код с вебом:
|
|
||||||
API-клиент, типы, логику очереди / событий / офлайн-синхронизации, словари i18n, дизайн-токены.
|
|
||||||
Для команды из одного-двух человек это существенно сокращает объём работы.
|
|
||||||
|
|
||||||
**Последствия.** iOS не поддерживается (самообновление в обход стора там невозможно); при необходимости
|
|
||||||
iOS добавляется позже через TestFlight без смены стека.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-005. Раздача аудио через nginx X-Accel-Redirect
|
|
||||||
|
|
||||||
**Контекст.** Нужна перемотка (HTTP Range), высокая пропускная способность, минимум нагрузки на бэкенд.
|
|
||||||
|
|
||||||
**Решение.** Клиент обращается к `api`, `api` проверяет права и подпись ссылки и отвечает заголовком
|
|
||||||
`X-Accel-Redirect` на `internal` location nginx. Файл отдаёт nginx.
|
|
||||||
|
|
||||||
**Последствия.** Go не держит долгих соединений со стримами. Тома библиотеки и кэша транскодов
|
|
||||||
должны быть смонтированы и в `api`, и в `nginx` по одинаковым путям.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-006. Подписанные ссылки на стрим
|
|
||||||
|
|
||||||
**Контекст.** `<audio>` в браузере и ExoPlayer не всегда умеют передавать заголовок `Authorization`.
|
|
||||||
|
|
||||||
**Решение.** В DTO трека отдаётся `stream_url` с параметрами `exp` и `sig` = HMAC-SHA256(user_id, track_id, quality, exp).
|
|
||||||
Срок жизни — 12 ч. Для офлайн-загрузки — отдельная ссылка со сроком 1 ч.
|
|
||||||
|
|
||||||
**Последствия.** Ссылки нельзя использовать после блокировки пользователя дольше срока жизни — при блокировке
|
|
||||||
дополнительно проверяется статус пользователя в `api` (кэш в Redis, 60 с).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-007. Поиск — Meilisearch
|
|
||||||
|
|
||||||
**Контекст.** Мгновенный автокомплит по трём типам сущностей, опечатки, русский и английский, 11k–100k документов.
|
|
||||||
|
|
||||||
**Решение.** Meilisearch, три индекса (`artists`, `albums`, `tracks`), `multi-search` для выпадающего списка.
|
|
||||||
Postgres остаётся источником истины, индексы перестраиваемы.
|
|
||||||
|
|
||||||
**Альтернатива.** `pg_trgm` в Postgres — меньше компонентов, но хуже автокомплит и ранжирование. Оставлено как fallback.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-008. Подключение NFS из админки через storage-agent
|
|
||||||
|
|
||||||
**Контекст.** Админ должен подключать хранилища из UI. Монтирование требует `CAP_SYS_ADMIN`,
|
|
||||||
давать его публичному `api` небезопасно.
|
|
||||||
|
|
||||||
**Решение.** Отдельный минимальный сервис `storage-agent` с `CAP_SYS_ADMIN`, без публичных портов.
|
|
||||||
Монтирует в `/library/<source_id>` внутри общего bind-тома с `propagation: rshared`;
|
|
||||||
`api`, `nginx`, `analyzer` видят точки монтирования через `rslave`. Поддерживаются типы
|
|
||||||
`nfs` (монтирует агент) и `local` (папка, уже смонтированная на хосте).
|
|
||||||
|
|
||||||
**Последствия.** Требуется Linux-хост (mount propagation) и `nfs-common` в образе агента.
|
|
||||||
При рестарте агент перемонтирует все активные источники из БД. Дополнено ADR-012 (запись).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-009. Хранение векторов — pgvector
|
|
||||||
|
|
||||||
**Контекст.** Эмбеддинги треков (~11k–100k × 256 float) и векторы вкусов (~100). Нужен kNN.
|
|
||||||
|
|
||||||
**Решение.** pgvector, HNSW-индекс, косинусное расстояние. Отдельная векторная БД не нужна при таком объёме.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-010. Аутентификация — свои JWT + ротируемые refresh-токены
|
|
||||||
|
|
||||||
**Решение.** Пароли — argon2id. Access JWT (EdDSA, 15 мин), refresh — непрозрачный токен (хэш в БД), 30 дней,
|
|
||||||
ротация при каждом использовании с детекцией повторного использования (отзыв всей «семьи»).
|
|
||||||
Веб хранит refresh в `HttpOnly; Secure; SameSite=Strict` cookie, мобильное — в `expo-secure-store` (Android Keystore).
|
|
||||||
Подробно — [auth-and-roles.md](auth-and-roles.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-011. Ошибки API — RFC 9457 + коды для i18n
|
|
||||||
|
|
||||||
**Решение.** Ответы об ошибках — `application/problem+json` с машинным полем `code` (`auth.invalid_credentials`,
|
|
||||||
`validation.password_too_short`, …). Клиенты переводят `code` через словари; сервер не возвращает локализованных текстов.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-012. Очередь на добавление треков и запись в хранилище
|
|
||||||
|
|
||||||
**Контекст.** Модераторы предлагают новые треки; очередь ограничена по размеру (настраивает админ); дубли отсекаются;
|
|
||||||
одобренные файлы должны лечь на файловый сервер в принятой там структуре папок. До этого библиотека была строго read-only.
|
|
||||||
|
|
||||||
**Решение.**
|
|
||||||
- Загрузка по протоколу **tus** (resumable) в локальный staging, квота — счётчик в Postgres, резервируется до загрузки.
|
|
||||||
- Дубли — три уровня: SHA-256 (предпроверка на клиенте), нормализованные метаданные, акустический отпечаток **Chromaprint**.
|
|
||||||
- Запись в хранилище — **только `storage-agent`**, только в источники с `accepts_uploads`, через отдельную `rw`-точку
|
|
||||||
монтирования, невидимую другим контейнерам; только создание новых файлов (tmp → fsync → hash → rename).
|
|
||||||
- Путь — по `write_template` хранилища; шаблон автоподбирается по существующей структуре и проверяется на обратимость
|
|
||||||
регэкспами хранилища.
|
|
||||||
|
|
||||||
**Последствия.** Публичный `api` по-прежнему не может изменить библиотеку. Нужны права на запись в NFS-экспорт
|
|
||||||
для UID агента. Подробно — [uploads.md](uploads.md).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-013. Образы собираются на сервере, реестр не используется
|
|
||||||
|
|
||||||
**Контекст.** Публикация образов в Gitea Container Registry требует настройки реестра, токенов на push и pull,
|
|
||||||
`docker login` на сервере. Сервер один, и он сам может собрать образы.
|
|
||||||
|
|
||||||
**Решение.** На сервере клонируется репозиторий; `deploy/update.sh [vX.Y.Z]` переключается на тег и выполняет
|
|
||||||
`docker compose build` + `up -d` (`build:` у сервисов в `deploy/compose.yaml`). Релиз в Gitea содержит только APK.
|
|
||||||
CI на каждый PR собирает все образы без push, чтобы сборка на сервере не ломалась.
|
|
||||||
|
|
||||||
**Последствия.** Серверу нужен доступ к репозиторию и интернет на время сборки; первая сборка — 5–15 минут,
|
|
||||||
нужно ~5–10 ГБ под кэш сборки. Откат — `update.sh` на предыдущий тег (пересборка из кэша). Отпадают секреты
|
|
||||||
`REGISTRY_USER` / `REGISTRY_TOKEN`. Заменяет часть ADR-001 / ci-cd о публикации образов.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-014. Анализатор v1 — признаки на numpy, Essentia позже
|
|
||||||
|
|
||||||
**Контекст.** Нейросетевые модели Essentia (Discogs-EffNet, жанры, настроения) требуют essentia-tensorflow:
|
|
||||||
тяжёлый образ, ограниченный набор платформ и версий Python. Волне v1 нужны звучание и базовые признаки уже сейчас.
|
|
||||||
|
|
||||||
**Решение.** Анализатор `basic-1` (`services/recommender/pipu_rec/analysis/features.py`) считает признаки сам,
|
|
||||||
на numpy поверх декодирования ffmpeg: 3 фрагмента по 30 с; эмбеддинг 256 = форма спектра, разброс, динамика
|
|
||||||
и ритмичность по 64 mel-полосам (рекомендатель стандартизирует его по библиотеке); темп по автокорреляции онсетов;
|
|
||||||
тональность по хромаграмме; громкость, энергия, танцевальность, акустичность, длина вступления.
|
|
||||||
Valence — грубая оценка (лад + яркость); `mood_*`, `arousal`, жанры анализа не заполняются.
|
|
||||||
|
|
||||||
**Последствия.** Анализ работает на любом CPU без моделей. Контексты используют энергию, темп, танцевальность
|
|
||||||
и valence; настроения «весёлое» / «грустное» пока приблизительны. Переход на Essentia — новая версия
|
|
||||||
`analyzer_ver`: воркер сам ставит пересчёт всех треков.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADR-015. Приём загрузок — ядро tus своими силами, без tusd
|
|
||||||
|
|
||||||
**Контекст.** ADR-012 предполагал `tusd` как библиотеку. Но файл очереди создаёт `POST /uploads` вместе с резервом
|
|
||||||
квоты и проверкой дублей, а права (только автор, только `uploading`) проверяет `api`. От tus остаётся только ядро
|
|
||||||
протокола: `HEAD` (сколько принято) и `PATCH` (следующий кусок с `Upload-Offset`).
|
|
||||||
|
|
||||||
**Решение.** Ядро tus 1.0.0 — в `internal/uploads/tus.go` (~200 строк): смещение — фактический размер файла в staging,
|
|
||||||
один `PATCH` на файл одновременно, данные сверх заявленного размера не принимаются, куски до 64 МБ. Расширения
|
|
||||||
creation / termination / checksum не нужны. Клиент — `tus-js-client` с готовым `uploadUrl` из ответа `POST /uploads`.
|
|
||||||
|
|
||||||
**Последствия.** Нет новой зависимости и отдельного хранилища состояния tus; квота и права — в одной транзакции
|
|
||||||
с созданием файла. Целостность проверяется после приёма: SHA-256 файла сверяется с заявленным клиентом.
|
|
||||||
|
|
||||||
+1
-24
@@ -25,7 +25,6 @@
|
|||||||
├── models/ # модели Essentia (скачиваются при первом старте analyzer)
|
├── models/ # модели Essentia (скачиваются при первом старте analyzer)
|
||||||
├── updates/ # кэш APK релизов
|
├── updates/ # кэш APK релизов
|
||||||
├── uploads/ # staging очереди на добавление (размер ≈ лимит очереди, по умолчанию 10 ГБ)
|
├── uploads/ # staging очереди на добавление (размер ≈ лимит очереди, по умолчанию 10 ГБ)
|
||||||
├── backups/
|
|
||||||
└── caddy/ # сертификаты Let's Encrypt (если используется compose.tls.yaml)
|
└── caddy/ # сертификаты Let's Encrypt (если используется compose.tls.yaml)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -95,27 +94,6 @@
|
|||||||
|
|
||||||
Пошаговая установка на домен — [install-guide.md](install-guide.md).
|
Пошаговая установка на домен — [install-guide.md](install-guide.md).
|
||||||
|
|
||||||
## Бэкапы
|
|
||||||
|
|
||||||
- Контейнер `backup` (`deploy/backup.sh loop`): каждый день в `BACKUP_HOUR`:30 UTC (по умолчанию 3:30) —
|
|
||||||
`pg_dump -Fc` → `/srv/pipu/backups/daily/`, хранится `BACKUP_KEEP_DAILY` = 14 последних; первая копия за неделю
|
|
||||||
копируется в `weekly/` (`BACKUP_KEEP_WEEKLY` = 8). Рядом с копией — `*.counts`: число строк в каждой таблице.
|
|
||||||
- Раз в неделю копия **проверяется восстановлением**: разворачивается во временную базу `pipu_verify`, сверяются
|
|
||||||
таблицы и число строк, база удаляется; время последней успешной проверки — `backups/.last-verify`, ошибки —
|
|
||||||
в `docker compose logs backup` («ПРОВЕРКА НЕ ПРОЙДЕНА»). Тот же цикл копия → проверка → восстановление
|
|
||||||
выполняется в CI (`ci / db`) на базе после сквозного сценария.
|
|
||||||
- `update.sh` перед обновлением делает внеочередную копию.
|
|
||||||
- Вручную: `docker compose run --rm backup once` — копия сейчас; `docker compose run --rm backup verify /backups/daily/<файл>`.
|
|
||||||
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/pipu-….dump` — останавливает api, рекомендации и
|
|
||||||
анализ, проверяет копию, переименовывает текущую базу в `pipu_before_restore_<время>` (страховка; удалить —
|
|
||||||
`docker compose exec postgres dropdb -U pipu <имя>`), восстанавливает и запускает сервисы; поиск переиндексируется
|
|
||||||
при старте api. Redis (сессии волны, очереди) не восстанавливается — пользователи просто начнут новую волну.
|
|
||||||
- Копии лежат на том же сервере: для защиты от потери диска синхронизируйте `/srv/pipu/backups` на другую машину
|
|
||||||
(например, `rclone sync` или `rsync` по cron).
|
|
||||||
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
|
|
||||||
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
|
|
||||||
- Сама музыка — на NFS, вне зоны ответственности приложения.
|
|
||||||
|
|
||||||
## Обновление
|
## Обновление
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -123,5 +101,4 @@ cd /opt/pipu-music/deploy && ./update.sh # последний рели
|
|||||||
```
|
```
|
||||||
Скрипт: `git fetch` → checkout тега → `docker compose build --pull` → `up -d` → удаление старых образов →
|
Скрипт: `git fetch` → checkout тега → `docker compose build --pull` → `up -d` → удаление старых образов →
|
||||||
ожидание `/readyz`; миграции применяются при старте api.
|
ожидание `/readyz`; миграции применяются при старте api.
|
||||||
Конкретная версия или откат: `./update.sh v1.2.4` (миграции пишутся обратно-совместимыми в пределах минорной версии: сначала добавить,
|
Конкретная версия или откат кода: `./update.sh v1.2.4`. Откат тега не откатывает уже применённые миграции базы.
|
||||||
потом — в следующем релизе — удалить).
|
|
||||||
+12
-30
@@ -1,29 +1,14 @@
|
|||||||
# Дизайн и дизайн-система
|
# Дизайн и дизайн-система
|
||||||
|
|
||||||
Источник — кликабельные прототипы в [`Разработка дизайна приложения/`](Разработка%20дизайна%20приложения/):
|
## 1. Внешний вид
|
||||||
|
|
||||||
| Файл | Что внутри |
|
|
||||||
|---|---|
|
|
||||||
| `Pipu Web.dc.html` | Веб: вход, setup-экраны, главная, поиск, исполнитель, альбом, коллекция, профиль, вся админка, диалоги, очередь плеера |
|
|
||||||
| `Pipu Android.dc.html` | Android: подключение к серверу, вход, главная, поиск, коллекция (со «Скачанным»), альбом, исполнитель, профиль, полноэкранный плеер, листы (качество, пароль, очередь, обновление), офлайн-режим |
|
|
||||||
| `Wave Options.dc.html` | Три варианта кнопки «Моя волна» |
|
|
||||||
| `wave.js` | Реализация кнопки волны (Canvas 2D, React) — **референс для продакшен-компонента** |
|
|
||||||
| `pipu-data.js` | Демо-данные прототипа |
|
|
||||||
| `support.js`, `android-frame.jsx` | Рантайм прототипа и рамка устройства — в продукт не переносятся |
|
|
||||||
|
|
||||||
Просмотр прототипов: прототипам нужен HTTP-сервер (из `file://` скрипты не работают).
|
|
||||||
В репозитории есть конфиг `.claude/launch.json` (`python -m http.server 8765 --directory docs`),
|
|
||||||
вручную — та же команда, затем `http://localhost:8765/`.
|
|
||||||
|
|
||||||
## 1. Принятые решения по вариантам прототипа
|
|
||||||
|
|
||||||
| Развилка | Решение |
|
| Развилка | Решение |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Кнопка «Моя волна» | **Сфера с шумом (`orb`)** и **Жидкая форма (`liquid`)** — пользователь выбирает в профиле, по умолчанию `orb`. Вариант «Кольца» не реализуем. |
|
| Кнопка «Моя волна» | **Сфера с шумом (`orb`)** и **Жидкая форма (`liquid`)** — пользователь выбирает в профиле, по умолчанию `orb` |
|
||||||
| Каркас веба | **Classic**: сайдбар 240 px, топбар 60 px, разделители-линии, без «парящих» панелей |
|
| Каркас веба | **Classic**: сайдбар 240 px, топбар 60 px, разделители-линии, без «парящих» панелей |
|
||||||
| Плеер веба | **Оба**: `bar` (полоса 88 px внизу, по умолчанию) и `island` (плавающий остров) — выбор в профиле |
|
| Плеер веба | **Оба**: `bar` (полоса 88 px внизу, по умолчанию) и `island` (плавающий остров) — выбор в профиле |
|
||||||
| Главная | **Center**: волна по центру, под ней чипы «Занятие» и «Настроение» |
|
| Главная | **Center**: волна по центру, под ней чипы «Занятие» и «Настроение» |
|
||||||
| Очередь на добавление (админ) | `grouped` — группировка по альбомам (вариант прототипа по умолчанию) |
|
| Очередь на добавление (админ) | Группировка по пачкам (обычно альбом) |
|
||||||
| Полноэкранный плеер Android | `cover` (крупная обложка) по умолчанию, `wave` (визуализация волны) — переключатель в профиле |
|
| Полноэкранный плеер Android | `cover` (крупная обложка) по умолчанию, `wave` (визуализация волны) — переключатель в профиле |
|
||||||
| Тема | Тёмная (по умолчанию) и светлая, переключатель в профиле; `system` — следовать ОС |
|
| Тема | Тёмная (по умолчанию) и светлая, переключатель в профиле; `system` — следовать ОС |
|
||||||
| Ambient-фон | Включён: размытые дрейфующие пятна, третье пятно окрашено в цвет обложки текущего трека; отключается в профиле и при `prefers-reduced-motion` |
|
| Ambient-фон | Включён: размытые дрейфующие пятна, третье пятно окрашено в цвет обложки текущего трека; отключается в профиле и при `prefers-reduced-motion` |
|
||||||
@@ -34,7 +19,6 @@
|
|||||||
## 2. Токены
|
## 2. Токены
|
||||||
|
|
||||||
Источник истины — `packages/design-tokens/tokens.json`; из него генерируются CSS-переменные (веб) и TS-объект (React Native).
|
Источник истины — `packages/design-tokens/tokens.json`; из него генерируются CSS-переменные (веб) и TS-объект (React Native).
|
||||||
Значения взяты из прототипа без изменений.
|
|
||||||
|
|
||||||
### Цвета
|
### Цвета
|
||||||
|
|
||||||
@@ -91,12 +75,12 @@
|
|||||||
| Появление контента | `pipuIn`: opacity + translateY(6px), 200 мс |
|
| Появление контента | `pipuIn`: opacity + translateY(6px), 200 мс |
|
||||||
| Эквалайзер текущего трека | `pipuEq`: scaleY 0.25 → 1 |
|
| Эквалайзер текущего трека | `pipuEq`: scaleY 0.25 → 1 |
|
||||||
| Ambient-фон | 3 пятна 55–70vmax, blur 30 px, дрейф 16 / 21 / 26 с, `hue-rotate` ±35° за 30 с, прозрачность `amb-op` (0.8 тёмная / 0.45 светлая) |
|
| Ambient-фон | 3 пятна 55–70vmax, blur 30 px, дрейф 16 / 21 / 26 с, `hue-rotate` ±35° за 30 с, прозрачность `amb-op` (0.8 тёмная / 0.45 светлая) |
|
||||||
| Пружины (волна, нажатия) | Демпфированная пружина как в `wave.js` (`v += (target − x)·0.35; v *= 0.62`) |
|
| Пружины (волна, нажатия) | Демпфированная пружина (`v += (target − x)·0.35; v *= 0.62`) |
|
||||||
| `prefers-reduced-motion` | Ambient статичен, волна замедлена ×0.15 без пульса |
|
| `prefers-reduced-motion` | Ambient статичен, волна замедлена ×0.15 без пульса |
|
||||||
|
|
||||||
## 3. Кнопка «Моя волна»
|
## 3. Кнопка «Моя волна»
|
||||||
|
|
||||||
Продакшен-компонент — порт `wave.js` на TypeScript, **Canvas 2D** (прототип показал, что этого достаточно; WebGL не нужен).
|
Кнопка рисуется на **Canvas 2D** (веб) и Skia (Android). WebGL не используется.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
interface WaveButtonProps {
|
interface WaveButtonProps {
|
||||||
@@ -113,7 +97,7 @@ interface WaveButtonProps {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Поведение (как в прототипе):
|
Поведение:
|
||||||
- **Пульс в такт**: фаза растёт со скоростью `bpm/60`, импульс `(1 − phase)^4 · (0.35 + 0.65·energy)`; в покое — медленное «дыхание».
|
- **Пульс в такт**: фаза растёт со скоростью `bpm/60`, импульс `(1 − phase)^4 · (0.35 + 0.65·energy)`; в покое — медленное «дыхание».
|
||||||
- **Hover**: скорость анимации ×(1 + 1.4·hover), формы смещаются к курсору; **press**: сжатие до 0.93 с отскоком;
|
- **Hover**: скорость анимации ×(1 + 1.4·hover), формы смещаются к курсору; **press**: сжатие до 0.93 с отскоком;
|
||||||
клик / Enter / Space — запуск / пауза.
|
клик / Enter / Space — запуск / пауза.
|
||||||
@@ -134,7 +118,7 @@ interface WaveButtonProps {
|
|||||||
очередь, громкость. Очередь — выезжающая справа панель; в режиме волны внизу пометка «Волна бесконечна…».
|
очередь, громкость. Очередь — выезжающая справа панель; в режиме волны внизу пометка «Волна бесконечна…».
|
||||||
- Диалоги: стеклянные карточки по центру поверх `overlay`. Тосты — внизу над плеером.
|
- Диалоги: стеклянные карточки по центру поверх `overlay`. Тосты — внизу над плеером.
|
||||||
|
|
||||||
Экраны, которые есть в прототипе, и документы, где описана логика:
|
Экраны и документы, где описана логика:
|
||||||
|
|
||||||
| Экран | Логика |
|
| Экран | Логика |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -157,10 +141,8 @@ interface WaveButtonProps {
|
|||||||
- Лист обновления: «Что нового» → разрешение на установку (один раз) → загрузка → проверка SHA-256 → «Установить».
|
- Лист обновления: «Что нового» → разрешение на установку (один раз) → загрузка → проверка SHA-256 → «Установить».
|
||||||
- Офлайн-баннер при отсутствии сети.
|
- Офлайн-баннер при отсутствии сети.
|
||||||
|
|
||||||
## 5. Пробелы дизайна (дорабатываем при реализации)
|
## 5. Адаптив и онбординг
|
||||||
- **Адаптивность веба**: прототип рассчитан на десктоп (≥ 1200 px). Для < 1024 px сайдбар сворачивается в иконки,
|
- **Адаптивность веба**: при ширине < 1024 px сайдбар сворачивается в иконки, при < 768 px — нижняя навигация.
|
||||||
для < 768 px — нижняя навигация как в Android. Макетов нет — делаем по образцу Android-версии.
|
- **Онбординг вкуса** показывается на главной, пока `GET /onboarding` отвечает `required`.
|
||||||
- **Пустые состояния и ошибки** есть не везде (например, хранилище недоступно, нет сети в вебе) — по образцу существующих
|
- **Пустые состояния** — иконка, короткий текст и действие там, где список может быть пустым (поиск, коллекция, очередь).
|
||||||
(иконка + короткий текст + действие).
|
- Строки интерфейса есть на русском и английском; длинные подписи проверяются на обоих языках.
|
||||||
- **Онбординг вкуса** (выбор жанров / исполнителей при первом входе) в прототипе отсутствует — макет нужен до M6.
|
|
||||||
- **Английская локализация**: в прототипе только русские строки; при вёрстке проверять длинные строки на обоих языках.
|
|
||||||
+1
-6
@@ -28,9 +28,4 @@ packages/i18n/
|
|||||||
(`"validation.password_too_short": "Пароль должен быть не короче {{min}} символов"`).
|
(`"validation.password_too_short": "Пароль должен быть не короче {{min}} символов"`).
|
||||||
Неизвестный код → общий текст «Что-то пошло не так» + код в деталях.
|
Неизвестный код → общий текст «Что-то пошло не так» + код в деталях.
|
||||||
|
|
||||||
## Процесс
|
Ключи есть в каждом языке, лишних нет, плейсхолдеры совпадают — это проверяет `packages/i18n/scripts/check.ts`.
|
||||||
|
|
||||||
- CI запускает `pnpm i18n:check` — отсутствующий ключ в любом языке ломает сборку.
|
|
||||||
- Новые строки сначала добавляются в `en` и `ru` одновременно в том же PR.
|
|
||||||
- В будущем — подключение платформы переводов (Weblate, self-hosted) к папке `locales/`, формат JSON совместим.
|
|
||||||
- Тесты UI запускаются минимум на двух локалях (длинные русские строки ловят проблемы вёрстки).
|
|
||||||
+15
-37
@@ -13,10 +13,6 @@
|
|||||||
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
|
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
|
||||||
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
|
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
|
||||||
|
|
||||||
> **Что умеет текущая версия.** В `v0.1.0` (этап M0) работают развёртывание, HTTPS и проверка связи
|
|
||||||
> клиентов с сервером. Вход, библиотека и плеер появятся в следующих версиях
|
|
||||||
> (см. [roadmap.md](roadmap.md)). Шаги, которые зависят от версии, помечены.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Что нужно заранее
|
## 1. Что нужно заранее
|
||||||
@@ -192,12 +188,10 @@ LIBRARY_HOST=/mnt/music
|
|||||||
./update.sh
|
./update.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Скрипт переключает репозиторий на последний релизный тег (например, `v0.1.1`), собирает образы, запускает
|
Скрипт переключает репозиторий на последний релизный тег, собирает образы, запускает
|
||||||
все сервисы и ждёт готовности API (до 2 минут). Первая сборка занимает 5–15 минут, следующие — быстрее
|
все сервисы и ждёт готовности API (до 2 минут). Первая сборка занимает 5–15 минут, следующие — быстрее
|
||||||
за счёт кэша Docker. Конкретная версия: `./update.sh v0.1.1`.
|
за счёт кэша Docker. Конкретная версия: `./update.sh v1.2.0`.
|
||||||
|
|
||||||
> Локальная сборка поддерживается начиная с `v0.1.1`: в теге `v0.1.0` `compose.yaml` ещё ссылается на реестр образов.
|
|
||||||
> Пока `v0.1.1` не вышел, запускайте `./update.sh main`.
|
|
||||||
При первом запуске Caddy сам получит сертификат для `DOMAIN`, обычно за 10–30 секунд.
|
При первом запуске Caddy сам получит сертификат для `DOMAIN`, обычно за 10–30 секунд.
|
||||||
|
|
||||||
Проверка с любого компьютера:
|
Проверка с любого компьютера:
|
||||||
@@ -206,7 +200,7 @@ LIBRARY_HOST=/mnt/music
|
|||||||
curl -s https://music.example.com/api/v1/server-info
|
curl -s https://music.example.com/api/v1/server-info
|
||||||
```
|
```
|
||||||
|
|
||||||
Ожидаемый ответ: `{"name":"pipu-music","version":"0.1.0",...}`. Проверка всех зависимостей:
|
Ожидаемый ответ — JSON с полями `name` и `version`. Проверка всех зависимостей:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s https://music.example.com/readyz
|
curl -s https://music.example.com/readyz
|
||||||
@@ -218,11 +212,9 @@ curl -s https://music.example.com/readyz
|
|||||||
|
|
||||||
## 8. Веб-клиент
|
## 8. Веб-клиент
|
||||||
|
|
||||||
Откройте `https://music.example.com` в браузере.
|
Откройте `https://music.example.com` в браузере. На экране входа можно переключить язык RU / EN.
|
||||||
|
|
||||||
- **v0.1.0:** карточка «Сервер 0.1.0 на связи» и переключатель языка RU / EN.
|
Первый вход — логин `admin` и пароль, который сервер один раз выводит в лог при первом запуске:
|
||||||
- **С версии 0.2 (M1):** экран входа. Первый вход — логин `admin` и пароль, который сервер один раз
|
|
||||||
выводит в лог при первом запуске:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose logs api | grep -i bootstrap
|
docker compose logs api | grep -i bootstrap
|
||||||
@@ -231,10 +223,11 @@ curl -s https://music.example.com/readyz
|
|||||||
Сразу после входа приложение попросит сменить логин и пароль администратора
|
Сразу после входа приложение попросит сменить логин и пароль администратора
|
||||||
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
|
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
|
||||||
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
|
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
|
||||||
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» → выберите
|
|
||||||
подпапку на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей
|
Музыку подключает администратор: Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» →
|
||||||
→ «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
|
подпапка на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей →
|
||||||
Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
|
«Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
|
||||||
|
Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -246,10 +239,10 @@ curl -s https://music.example.com/readyz
|
|||||||
разрешите и вернитесь к установке.
|
разрешите и вернитесь к установке.
|
||||||
3. Запустите **Pipu Music**, на экране подключения введите адрес сервера: `music.example.com`
|
3. Запустите **Pipu Music**, на экране подключения введите адрес сервера: `music.example.com`
|
||||||
(`https://` подставится сам) → «Проверить».
|
(`https://` подставится сам) → «Проверить».
|
||||||
4. Ожидаемый результат — «Сервер 0.1.0 на связи». Если версия приложения слишком старая для сервера,
|
4. Приложение показывает, что сервер на связи. Если установленная версия слишком старая,
|
||||||
приложение попросит обновиться.
|
оно попросит обновиться.
|
||||||
|
|
||||||
Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
|
Дальше приложение обновляется само: проверяет новые версии через ваш сервер
|
||||||
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
||||||
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
||||||
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
||||||
@@ -304,7 +297,7 @@ curl -s https://music.example.com/readyz
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Обновление, бэкапы, удаление
|
## 11. Обновление и удаление
|
||||||
|
|
||||||
Обновление до последнего релиза:
|
Обновление до последнего релиза:
|
||||||
|
|
||||||
@@ -314,22 +307,7 @@ cd /opt/pipu-music/deploy && ./update.sh
|
|||||||
|
|
||||||
Конкретная версия или откат: `./update.sh v0.2.0`. Скрипт делает `git fetch`, переключается на тег,
|
Конкретная версия или откат: `./update.sh v0.2.0`. Скрипт делает `git fetch`, переключается на тег,
|
||||||
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
|
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
|
||||||
автоматически при старте API.
|
автоматически при старте API. Копии базы и музыки приложение не делает.
|
||||||
|
|
||||||
Бэкапы базы делаются сами: каждую ночь в `/srv/pipu/backups`, раз в неделю копия проверяется восстановлением,
|
|
||||||
перед каждым `update.sh` — внеочередная копия. Всё остальное восстанавливается само — см.
|
|
||||||
[deployment.md](deployment.md#бэкапы). Копия сейчас и восстановление:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/pipu-music/deploy && docker compose run --rm backup once
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /opt/pipu-music/deploy && ./restore.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Без аргумента `restore.sh` показывает список копий; с именем файла (`./restore.sh daily/pipu-20261020-033000.dump`)
|
|
||||||
восстанавливает базу, сохранив прежнюю рядом.
|
|
||||||
|
|
||||||
Остановить сервис, сохранив данные:
|
Остановить сервис, сохранив данные:
|
||||||
|
|
||||||
|
|||||||
+20
-7
@@ -16,9 +16,23 @@
|
|||||||
| i18n | i18next, словари из `packages/i18n` |
|
| i18n | i18next, словари из `packages/i18n` |
|
||||||
| Минимальная версия | Android 8.0 (API 26), target — актуальный API |
|
| Минимальная версия | Android 8.0 (API 26), target — актуальный API |
|
||||||
|
|
||||||
|
## Сборка APK: ABI и 16 КБ страницы
|
||||||
|
|
||||||
|
- APK только под `arm64-v8a` (`plugins/withArm64Only.js`): нативные модули собираются под одну ABI, а без фильтра
|
||||||
|
в APK попадали неполные каталоги `x86`, `x86_64`, `armeabi-v7a` из библиотек React Native — эмулятор x86 и
|
||||||
|
32-битные устройства выбирали их и падали при старте (`couldn't find DSO to load: libexpo-modules-core.so`).
|
||||||
|
- Все `.so` выровнены по 16 КБ: устройства с Android 15+ и 16 КБ страницами памяти не загружают библиотеки
|
||||||
|
с выравниванием 4 КБ, приложение закрывается сразу после запуска.
|
||||||
|
- `expo-build-properties.android.useLegacyPackaging=true`: библиотеки сжаты в APK и распаковываются
|
||||||
|
при установке. Иначе SoLoader на x86_64-эмуляторе с ARM-трансляцией ищет `lib/x86_64` внутри APK
|
||||||
|
вместо `arm64-v8a` и падает с `couldn't find DSO to load: libreactnative.so`.
|
||||||
|
- Доступ к экранам ограничен через `Stack.Protected` до их отрисовки. Перенаправление из `useEffect`
|
||||||
|
выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с `Error: no session`.
|
||||||
|
Регрессия первого запуска, повторного открытия и защищённой ссылки: `.maestro/startup.yaml`.
|
||||||
|
|
||||||
## Экраны
|
## Экраны
|
||||||
|
|
||||||
Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
|
Сводка внешнего вида — [design.md](design.md#android).
|
||||||
|
|
||||||
- **Подключение и вход**: адрес сервера (проверка `GET /server-info`: версия сервера, HTTPS, поддерживается ли эта версия
|
- **Подключение и вход**: адрес сервера (проверка `GET /server-info`: версия сервера, HTTPS, поддерживается ли эта версия
|
||||||
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
|
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
|
||||||
@@ -41,9 +55,8 @@
|
|||||||
загрузки (high) → параметр `q` стрима.
|
загрузки (high) → параметр `q` стрима.
|
||||||
- **Кэш недавних треков** — кэш ExoPlayer (`cacheDir/TrackPlayer`, 500 МБ). Пока сервис плеера жив, SimpleCache держит
|
- **Кэш недавних треков** — кэш ExoPlayer (`cacheDir/TrackPlayer`, 500 МБ). Пока сервис плеера жив, SimpleCache держит
|
||||||
индекс в памяти, поэтому «Очистить» помечает кэш, а удаляется он при следующем холодном запуске до `setupPlayer`.
|
индекс в памяти, поэтому «Очистить» помечает кэш, а удаляется он при следующем холодном запуске до `setupPlayer`.
|
||||||
Путь каталога взят из KotlinAudio — проверить на устройстве.
|
|
||||||
- **Офлайн-баннер** вверху, когда нет связи с сервером.
|
- **Офлайн-баннер** вверху, когда нет связи с сервером.
|
||||||
- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
|
- Админка открывается в браузере (ссылка в профиле у персонала).
|
||||||
|
|
||||||
## Воспроизведение
|
## Воспроизведение
|
||||||
|
|
||||||
@@ -106,13 +119,13 @@
|
|||||||
- В манифесте: `REQUEST_INSTALL_PACKAGES` (FileProvider добавляет expo-file-system).
|
- В манифесте: `REQUEST_INSTALL_PACKAGES` (FileProvider добавляет expo-file-system).
|
||||||
- Сервер: `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN`, кэш APK — `UPDATES_DIR` (`/updates`). Без них — `404 app.update_unavailable`,
|
- Сервер: `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN`, кэш APK — `UPDATES_DIR` (`/updates`). Без них — `404 app.update_unavailable`,
|
||||||
приложение молча пропускает проверку.
|
приложение молча пропускает проверку.
|
||||||
- Все релизы подписываются **одним и тем же ключом** (keystore в секретах CI) — иначе Android откажет в обновлении.
|
- Все версии подписываются **одним и тем же ключом** — иначе Android откажет в обновлении.
|
||||||
- `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 | rc)` (см. [ci-cd.md](ci-cd.md#версионирование)).
|
- `versionCode` монотонно растёт: `major·1 000 000 + minor·10 000 + patch·100 + (99 для выпуска, N для -rc.N)`.
|
||||||
- Ассеты релиза: `pipu-music-<version>.apk`, `pipu-music-<version>.apk.sha256`.
|
- Ассеты релиза: `pipu-music-<version>.apk`, `pipu-music-<version>.apk.sha256`.
|
||||||
|
|
||||||
## Безопасность
|
## Безопасность
|
||||||
|
|
||||||
- Refresh-токен — `expo-secure-store` (Android Keystore).
|
- Refresh-токен — `expo-secure-store` (Android Keystore).
|
||||||
- HTTPS обязателен; для self-signed сертификатов — возможность добавить пользовательский CA через
|
- HTTPS обязателен. Самоподписанный сертификат принимается, если при подключении указан его отпечаток
|
||||||
`network_security_config` (собирается в CI по флагу) или пиннинг отпечатка, введённого при подключении к серверу.
|
или на устройство установлен пользовательский CA.
|
||||||
- `android:allowBackup="false"` для данных с токенами.
|
- `android:allowBackup="false"` для данных с токенами.
|
||||||
+2
-1
@@ -37,7 +37,8 @@ location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on;
|
|||||||
- Транскод трека 4 мин в Opus занимает ~1–2 с на одном ядре, поэтому делаем синхронно при первом запросе.
|
- Транскод трека 4 мин в Opus занимает ~1–2 с на одном ядре, поэтому делаем синхронно при первом запросе.
|
||||||
Клиенты **предзагружают следующий трек очереди** (запрос `HEAD`/`Range: bytes=0-0` за 20 с до конца текущего) —
|
Клиенты **предзагружают следующий трек очереди** (запрос `HEAD`/`Range: bytes=0-0` за 20 с до конца текущего) —
|
||||||
на сервере это прогревает транскод.
|
на сервере это прогревает транскод.
|
||||||
- Фоновая задача `pretranscode` для треков из выданных пачек волны (они почти наверняка будут сыграны).
|
- Фоновая подготовка: выдача пачки волны вызывает `playback.Prewarm` — для качества `high` транскод
|
||||||
|
строится сразу, не дожидаясь первого `Range` клиента.
|
||||||
- Кэш транскодов — LRU с лимитом `TRANSCODE_CACHE_LIMIT_GB` (по умолчанию 50 ГБ), чистка по `atime` раз в час.
|
- Кэш транскодов — LRU с лимитом `TRANSCODE_CACHE_LIMIT_GB` (по умолчанию 50 ГБ), чистка по `atime` раз в час.
|
||||||
- Пул ffmpeg ограничен (`TRANSCODE_CONCURRENCY`, по умолчанию = число ядер / 2).
|
- Пул ffmpeg ограничен (`TRANSCODE_CONCURRENCY`, по умолчанию = число ядер / 2).
|
||||||
|
|
||||||
|
|||||||
@@ -253,10 +253,7 @@ MMR-разнообразие и квоты остаются. Пока модел
|
|||||||
- `POST /wave/sessions/{id}/next`: p95 < 150 мс (включая вызов recommender).
|
- `POST /wave/sessions/{id}/next`: p95 < 150 мс (включая вызов recommender).
|
||||||
- Онлайн-обновление профиля по событию: < 50 мс, асинхронно из Redis Stream.
|
- Онлайн-обновление профиля по событию: < 50 мс, асинхронно из Redis Stream.
|
||||||
|
|
||||||
## 11. Эволюция
|
## 11. Что умеет волна
|
||||||
|
|
||||||
| Версия | Содержимое |
|
Эвристическое ранжирование, контексты (занятие и настроение), сессионный вектор и запасной алгоритм в `api` работают всегда.
|
||||||
|---|---|
|
ALS, LightGBM, бандит, центроиды вкуса и онбординг включаются на накопленных прослушиваниях: ранжировщик — после примерно 20 тысяч размеченных показов.
|
||||||
| v1 | Аудиоанализ, эвристическое ранжирование, контексты, сессионный вектор, правила, fallback |
|
|
||||||
| v2 | ALS, LightGBM-ранжировщик, бандит, центроиды вкуса, онбординг |
|
|
||||||
| v3 | Последовательная модель по сессиям (SASRec / небольшой трансформер) как дополнительный источник кандидатов и признак — когда накопится ≥ 1 млн событий; «волна по исполнителю / треку», ежедневные персональные подборки |
|
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Mobile Web Review — 2026-10-21 (1)
|
||||||
|
|
||||||
|
Scope: responsive web UI, especially administrator and moderator workflows. This is a focused mobile review, not a backend or native-app audit. The attached screenshot was unavailable locally; findings were verified against source and Chromium browser screenshots.
|
||||||
|
|
||||||
|
## Prior Reviews Summary
|
||||||
|
|
||||||
|
No previous review files existed. Carried forward: 0. Resolved prior issues: 0.
|
||||||
|
|
||||||
|
## Phase 1: Code Quality
|
||||||
|
|
||||||
|
### Correctness & Bugs
|
||||||
|
|
||||||
|
The fixed-size canvas halo could expand the home page horizontally. The shell grid also lacked explicit shrinking constraints. Fixed in the home and shell styles.
|
||||||
|
|
||||||
|
### SOLID / Performance / Code Quality
|
||||||
|
|
||||||
|
No additional actionable issues identified within this mobile UI scope. Desktop and mobile menus reuse the same role-filtered navigation definition.
|
||||||
|
|
||||||
|
## Phase 2: Logical Consistency
|
||||||
|
|
||||||
|
### State Management / Consistency
|
||||||
|
|
||||||
|
Staff navigation permissions are shared by both layouts. Mobile section headings no longer expose collapsed state while showing the links. Dialogs render through a body portal and keep keyboard focus inside; action menus include their portal contents in outside-click handling.
|
||||||
|
|
||||||
|
### Domain & Application Layer / Data Flow
|
||||||
|
|
||||||
|
No application or server contracts changed. Added typed fixtures are confined to the existing E2E bridge.
|
||||||
|
|
||||||
|
## Phase 3: UI/UX
|
||||||
|
|
||||||
|
Nine findings fixed below. Verified with browser screenshots and responsive tests at 320, 390 and 430 CSS pixels. Desktop search/bootstrap/settings scenarios remain covered. This does not substitute for physical iOS/Android keyboard and safe-area testing.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] 1. [UX] All staff links were compressed into an unlabelled bottom row. Keep three primary destinations plus More; show all permitted destinations, labels and pending badges in a scrollable navigation dialog — `apps/web/src/widgets/AppShell.tsx`, `Shell` / `NavSection`.
|
||||||
|
- [x] 2. [Visual] Search and profile competed for narrow header space. Use a dedicated full-width mobile search row for every search-position preference, and constrain the suggestion popup — `apps/web/src/widgets/AppShell.module.css`, mobile `.top`; `apps/web/src/widgets/SearchBox.module.css`, mobile popup.
|
||||||
|
- [x] 3. [UX] The user table required lateral scrolling to reach actions. Present labelled user cards on phones and open action menus in a body portal — `apps/web/src/pages/admin/UsersPage.tsx`, users table; `apps/web/src/shared/ui/ui.tsx`, `Menu`.
|
||||||
|
- [x] 4. [Bug] Dialog minimum-content width could push the close button outside a 320 px viewport. Constrain overlay grid tracks, allow content to shrink/wrap, and wrap footer actions — `apps/web/src/shared/ui/ui.module.css`, `.overlay` / mobile `.dialog`; `apps/web/src/shared/ui/ui.tsx`, `Dialog`.
|
||||||
|
- [x] 5. [UX] Moderation metadata/actions flowed into mismatched grid columns and native audio controls could expand the row. Put metadata and actions in the content column, constrain audio width, and compact the sticky bulk actions — `apps/web/src/pages/uploads/uploads.module.css`, mobile queue and bulk rules.
|
||||||
|
- [x] 6. [UX] Path-pattern tools competed with the expression input. Move tools below the input with a full-width enabled label and aligned touch buttons — `apps/web/src/pages/admin/library.module.css`, mobile `.patternRow` / `.patternTools`.
|
||||||
|
- [x] 7. [Bug] The floating player could overlap the mobile bottom navigation and its queue button was hidden. Reserve a player row for both variants, expose queue access, constrain the drawer, and preserve readable track text at 320 px — `apps/web/src/widgets/PlayerBar.module.css`, mobile player rules.
|
||||||
|
- [x] 8. [Accessibility] Settings, dialogs, track likes and mobile filters had small or hover-only targets. Increase key targets to 44 px, show track likes, wrap segmented controls and use 16 px form fields — `apps/web/src/shared/ui/ui.module.css`, `apps/web/src/pages/page.module.css`, `apps/web/src/entities/catalog.module.css`, mobile rules.
|
||||||
|
- [x] 9. [Visual] Oversized halo canvas and intrinsic grid widths caused horizontal scrolling. Clip only the home decoration horizontally, use shrinking shell tracks, and adapt catalogue columns and long titles — `apps/web/src/pages/home.module.css`, `.home`; `apps/web/src/widgets/AppShell.module.css`, `.shell`; `apps/web/src/pages/catalog.module.css`, mobile headings.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- TypeScript typecheck.
|
||||||
|
- Existing unit/integration suite: 28 tests.
|
||||||
|
- Playwright: existing 4 desktop scenarios plus 4 mobile scenarios covering role navigation, user actions/create dialog, search positions, settings, populated moderation queue, path patterns, and the island player/queue.
|
||||||
|
- Browser screenshots inspected for users, create dialog, moderation, patterns, and player queue.
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
# План разработки
|
|
||||||
|
|
||||||
Каждый этап заканчивается рабочей версией с тегом и релизом.
|
|
||||||
|
|
||||||
## M0 — Скелет (v0.1)
|
|
||||||
- Монорепо: pnpm + Turborepo, Go-модуль, Python-проект, `openapi.yaml` с `server-info`.
|
|
||||||
- `deploy/compose.dev.yaml`, nginx, Postgres, Redis, Meilisearch.
|
|
||||||
- `ci.yml` и `release.yml` (пока собирают пустые образы и debug-APK «hello»), генерация тестовой музыки.
|
|
||||||
- **Готово, когда:** тег `v0.1.0` создаёт релиз в Gitea с образами и APK.
|
|
||||||
|
|
||||||
## M1 — Доступ (v0.2)
|
|
||||||
- Bootstrap-админ, вход, refresh-ротация, смена пароля / учётных данных, сессии.
|
|
||||||
- Роли и политика прав, админка «Пользователи», аудит.
|
|
||||||
- Веб: каркас (топбар, сайдбар, пустой плеер), экраны входа и setup, i18n ru / en.
|
|
||||||
- **Готово, когда:** E2E-сценарии 1, 2, 8, 9, 10 зелёные.
|
|
||||||
|
|
||||||
## M2 — Библиотека и поиск (v0.3)
|
|
||||||
- storage-agent, источники NFS / local, тест подключения.
|
|
||||||
- Шаблоны путей с предпросмотром, сканер (инкрементальный, переименования, дубли), обложки.
|
|
||||||
- Meilisearch, suggest и страница поиска, страницы исполнителя и альбома.
|
|
||||||
- **Готово, когда:** реальная библиотека 11k треков сканируется, поиск < 50 мс, сценарии 3, 4.
|
|
||||||
|
|
||||||
## M3 — Воспроизведение (v0.4)
|
|
||||||
- Стриминг через X-Accel, подписанные ссылки, транскодинг и кэш.
|
|
||||||
- Плеер в вебе (очередь, Media Session, горячие клавиши, нормализация), сохранение состояния.
|
|
||||||
- События, история, лайки / дизлайки, раздел «Коллекция».
|
|
||||||
- **Готово, когда:** сценарии 5, 7; перемотка мгновенная; позиция восстанавливается на другом устройстве.
|
|
||||||
|
|
||||||
## M4 — Моя волна v1 (v0.5)
|
|
||||||
- analyzer (Essentia), признаки и эмбеддинги, прогресс в админке.
|
|
||||||
- recommender: кандидаты, эвристическое ранжирование, контексты (занятия / настроения), сессионный вектор,
|
|
||||||
правила разнообразия; fallback в Go.
|
|
||||||
- Анимированная кнопка волны и MoodPicker в вебе.
|
|
||||||
- **Готово, когда:** сценарий 6; метрики волны видны в админке.
|
|
||||||
|
|
||||||
## M5 — Android (v0.6)
|
|
||||||
- Вход с адресом сервера, главная с волной, поиск, коллекция, плеер с фоновым воспроизведением.
|
|
||||||
- Офлайн: загрузки, «Скачанное», очередь событий.
|
|
||||||
- Самообновление через сервер.
|
|
||||||
- **Готово, когда:** Maestro-сценарии зелёные; обновление 0.6.0 → 0.6.1 проходит на реальном устройстве.
|
|
||||||
|
|
||||||
## M5.5 — Очередь на добавление (v0.7)
|
|
||||||
- Отпечатки (SHA-256, Chromaprint) для существующей библиотеки.
|
|
||||||
- Запись через storage-agent, `accepts_uploads`, `write_template` с автоподбором и проверкой обратимости.
|
|
||||||
- tus-загрузка, предпроверка дублей, квота, обработка, экраны «Предложить треки», «Очередь на добавление», «Настройки».
|
|
||||||
- **Готово, когда:** E2E-сценарии 11, 12; импорт реального альбома ложится рядом с существующими по той же структуре.
|
|
||||||
|
|
||||||
## M6 — Моя волна v2 (v0.8)
|
|
||||||
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
|
|
||||||
- Персонализация suggest.
|
|
||||||
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
|
|
||||||
- **Состояние:** всё реализовано (v0.8, v1.0). Ранжировщик включается сам после ~20 тыс. размеченных показов;
|
|
||||||
критерий проверяется по метрикам версий в админке («Библиотека → Моя волна») и NDCG в `model_versions`.
|
|
||||||
|
|
||||||
## M7 — Стабилизация (v1.0)
|
|
||||||
- Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора,
|
|
||||||
доступность, полировка анимаций, багфиксы.
|
|
||||||
- **Состояние:** v1.0 — бэкапы с еженедельной проверкой восстановления (и цикл копия → проверка → восстановление
|
|
||||||
в CI), [руководство администратора](admin-guide.md), аудит доступности WCAG 2.1 AA, k6-smoke
|
|
||||||
(`tests/load/smoke.js`, запускается вручную на сервере перед релизом).
|
|
||||||
|
|
||||||
## Дальше (идеи)
|
|
||||||
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
|
|
||||||
Android Auto, iOS (TestFlight), Last.fm-скробблинг, импорт лайков.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
# Тестирование
|
|
||||||
|
|
||||||
## Пирамида
|
|
||||||
|
|
||||||
| Уровень | Где | Инструменты | Что покрываем |
|
|
||||||
|---|---|---|---|
|
|
||||||
| Unit | Go | `testing`, `testify`, табличные тесты | Политики прав (вся матрица из auth-and-roles.md), парсинг шаблонов путей и слияние с тегами, нормализация имён, подпись ссылок, расчёт `implicit_score`, рендер `write_template` и очистка имён, обратимость шаблон ↔ регэксп, учёт квоты при конкурентных резервах, переходы статусов загрузки |
|
|
||||||
| Unit | Python | `pytest`, `hypothesis` | Формулы скоринга, context_fit, MMR, бандит, обновление сессионного вектора, правила переранжирования |
|
|
||||||
| Unit | TS | `vitest` | `packages/core`: очередь, события (подсчёт `played_ms`, засчитанное прослушивание), single-flight refresh, офлайн-очередь |
|
|
||||||
| Компоненты | Web | `vitest` + Testing Library | Поиск (клавиатура, Enter / выбор), плеер, MoodPicker, формы админки |
|
|
||||||
| Компоненты | Mobile | `jest` + RNTL | Экраны входа, коллекции, диалог обновления |
|
|
||||||
| Интеграция | Go | `testcontainers-go` (Postgres + pgvector, Redis, Meilisearch) | Репозитории, миграции, сканер на тестовой библиотеке, API-хендлеры через `httptest` |
|
|
||||||
| Интеграция | Python | `testcontainers` | Чтение событий из Redis Stream, kNN в pgvector, ночной пайплайн на синтетических данных |
|
|
||||||
| Контракт | — | `schemathesis` против запущенного `api` | Ответы соответствуют `openapi.yaml`, нет 500 на фаззинге |
|
|
||||||
| E2E | Web | Playwright (Chromium + Firefox) | Сценарии ниже, на `deploy/compose.test.yaml` |
|
|
||||||
| E2E | Mobile | Maestro (эмулятор) | Вход, волна, фоновое воспроизведение, скачивание, офлайн — ночной прогон, не на каждый PR |
|
|
||||||
| Качество рекомендаций | Python | офлайн-оценка | NDCG / Recall на синтетическом датасете с известными «вкусами»; регрессия качества ломает тест |
|
|
||||||
|
|
||||||
## Тестовая музыка
|
|
||||||
|
|
||||||
Реальную музыку в репозиторий не кладём. `tests/fixtures/music/generate.sh` генерирует через `ffmpeg`
|
|
||||||
~200 коротких (5–20 с) файлов разных форматов (FLAC, MP3, Opus, M4A) с синусами / шумом разного темпа и громкости,
|
|
||||||
с полными, частичными и отсутствующими тегами, в нескольких структурах папок — для проверки шаблонов,
|
|
||||||
дублей, переименований, кириллицы в путях. Генерация детерминирована (фиксированные параметры), результат кэшируется в CI.
|
|
||||||
|
|
||||||
Для аудиоанализа — отдельный набор из нескольких клипов с известным BPM (метроном 90 / 128 / 170).
|
|
||||||
|
|
||||||
## E2E-сценарии (обязательные)
|
|
||||||
|
|
||||||
1. Первый запуск: вход bootstrap-админа → принудительная смена логина / пароля → главная.
|
|
||||||
2. Админ создаёт модератора; модератор создаёт пользователя; модератор **не может** создать модератора (UI и API).
|
|
||||||
3. Админ добавляет `local`-источник, задаёт два шаблона, предпросмотр, скан → треки появились в поиске.
|
|
||||||
4. Поиск: выпадающий список → переход к исполнителю; Enter → страница поиска.
|
|
||||||
5. Воспроизведение: запуск трека, перемотка, перезагрузка страницы → трек и позиция восстановлены.
|
|
||||||
6. Моя волна: запуск с занятием «Бегаю» → в выдаче треки с BPM из профиля; дизлайк → следующий трек, трек в «Дизлайках».
|
|
||||||
7. Коллекция: лайк → в «Лайках»; история содержит прослушанное.
|
|
||||||
8. Смена пароля пользователем → старые сессии разлогинены.
|
|
||||||
9. Переключение языка ru ↔ en.
|
|
||||||
10. Сброс пароля пользователю → обязательная смена при входе.
|
|
||||||
11. Очередь на добавление: модератор загружает папку из 5 файлов, где 1 — точная копия трека из библиотеки,
|
|
||||||
1 — тот же трек в другом формате → первый отброшен на предпроверке, второй — по отпечатку; оба не учтены в квоте;
|
|
||||||
оставшиеся 3 — `pending_review`. Админ правит альбом, одобряет → файлы появились в хранилище по `write_template`,
|
|
||||||
трек найден поиском; квота освобождена.
|
|
||||||
12. Квота: лимит 50 МБ, загрузка 80 МБ → часть файлов отклонена `uploads.queue_full`; модератор не может
|
|
||||||
одобрить собственную загрузку, а обычный пользователь — открыть загрузку вообще (UI и API).
|
|
||||||
|
|
||||||
## Правила
|
|
||||||
|
|
||||||
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
|
|
||||||
- Тест на каждый исправленный баг.
|
|
||||||
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
|
|
||||||
- Нагрузочный smoke (k6, вручную перед релизом) — см. ниже.
|
|
||||||
|
|
||||||
## Нагрузочный smoke
|
|
||||||
|
|
||||||
`tests/load/smoke.js` (k6 ≥ 0.50), 3 минуты: 100 одновременных слушателей волны (старт волны, начало потока
|
|
||||||
каждого трека — первые 64 КБ, события прослушивания, следующая пачка с отзывами) и 20 запросов подсказок поиска
|
|
||||||
в секунду. Пороги — цели из [api.md](api.md) и [recommendations.md](recommendations.md#10-требования-к-производительности):
|
|
||||||
|
|
||||||
| Запрос | p95 |
|
|
||||||
|---|---|
|
|
||||||
| `GET /search/suggest` | < 50 мс |
|
|
||||||
| `POST /wave/sessions/{id}/next` | < 150 мс |
|
|
||||||
| `POST /wave/sessions` | < 300 мс |
|
|
||||||
| начало потока (`Range: bytes=0-65535`) | < 300 мс |
|
|
||||||
| `POST /events` | < 100 мс |
|
|
||||||
|
|
||||||
Ошибок < 1%. k6 завершается с ненулевым кодом, если порог не выполнен.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
|
|
||||||
```
|
|
||||||
|
|
||||||
Пользователь — отдельный обычный (`role=user`): тест наполняет его историю и лайки. Нагрузку можно менять:
|
|
||||||
`-e LISTENERS=20 -e SUGGEST_RPS=5 -e DURATION=1m`. Запускать с машины в той же сети, что и сервер, —
|
|
||||||
иначе в цифрах будет задержка канала.
|
|
||||||
+22
-17
@@ -1,7 +1,7 @@
|
|||||||
# Предложение треков (загрузка с модерацией)
|
# Предложение треков (загрузка с модерацией)
|
||||||
|
|
||||||
Модераторы (и админы) предлагают новые треки. Файлы попадают в **очередь на добавление** с общим лимитом размера,
|
Модераторы (и админы) предлагают новые треки. Файлы попадают в **очередь на добавление** с общим лимитом размера,
|
||||||
дубли отбрасываются сразу, админ одобряет или отклоняет, одобренные файлы раскладываются в хранилище
|
дубли отбрасываются сразу, аудитор загрузок одобряет или отклоняет, одобренные файлы раскладываются в хранилище
|
||||||
**по той же структуре папок, что принята в этом хранилище**.
|
**по той же структуре папок, что принята в этом хранилище**.
|
||||||
|
|
||||||
## 1. Роли
|
## 1. Роли
|
||||||
@@ -10,10 +10,13 @@
|
|||||||
|---|:-:|:-:|:-:|
|
|---|:-:|:-:|:-:|
|
||||||
| Загрузить треки в очередь | — | ✅ | ✅ |
|
| Загрузить треки в очередь | — | ✅ | ✅ |
|
||||||
| Видеть свои предложения и их статус, отозвать до рассмотрения | — | ✅ | ✅ |
|
| Видеть свои предложения и их статус, отозвать до рассмотрения | — | ✅ | ✅ |
|
||||||
| Видеть всю очередь | — | — | ✅ |
|
|
||||||
| Править метаданные, слушать, одобрять / отклонять | — | — | ✅ |
|
|
||||||
| Настраивать лимит очереди, целевое хранилище и шаблон раскладки | — | — | ✅ |
|
| Настраивать лимит очереди, целевое хранилище и шаблон раскладки | — | — | ✅ |
|
||||||
|
|
||||||
|
Видеть всю очередь, слушать, править метаданные, одобрять и отклонять может только тот, у кого есть право
|
||||||
|
**аудита загрузок** (`users.reviews_uploads`, в API — `reviews_uploads` в `Me` / `AdminUser`). Это не роль: право
|
||||||
|
выдаёт админ, оно может быть у нескольких людей сразу, у модератора или даже у обычного пользователя, а у админа
|
||||||
|
его может не быть. Без него админ по-прежнему настраивает лимиты и хранилища, но очередь не видит.
|
||||||
|
|
||||||
Загрузки админа проходят ту же проверку на дубли; в очередь они тоже попадают, но админ может одобрить их сразу.
|
Загрузки админа проходят ту же проверку на дубли; в очередь они тоже попадают, но админ может одобрить их сразу.
|
||||||
|
|
||||||
## 2. Жизненный цикл загрузки
|
## 2. Жизненный цикл загрузки
|
||||||
@@ -81,7 +84,7 @@
|
|||||||
до 4 параллельных загрузок, прогресс по каждому файлу.
|
до 4 параллельных загрузок, прогресс по каждому файлу.
|
||||||
- Мобильный клиент загрузку не поддерживает (первая версия).
|
- Мобильный клиент загрузку не поддерживает (первая версия).
|
||||||
- Staging — локальный диск сервера `/srv/pipu/uploads/<upload_id>/<file_id>` (не NFS).
|
- Staging — локальный диск сервера `/srv/pipu/uploads/<upload_id>/<file_id>` (не NFS).
|
||||||
- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Проверяется не расширение, а содержимое (`ffprobe`):
|
- Разрешённые форматы: `flac mp3 m4a ogg opus wav aiff wv`. Просим присылать lossless — **FLAC, WAV или AIFF** (WavPack тоже считается lossless); MP3 и остальные lossy — крайний случай: форма предупреждает об этом, а очередь на добавление помечает такой файл «обратить внимание». Проверяется не расширение, а содержимое (`ffprobe`):
|
||||||
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины.
|
должен быть ровно один аудиопоток, длительность 5 с – 3 ч. Иначе `invalid` с кодом причины.
|
||||||
- Допускается отдельная картинка обложки (`cover.jpg|png`) в той же пачке — привязывается к файлам той же папки.
|
- Допускается отдельная картинка обложки (`cover.jpg|png`) в той же пачке — привязывается к файлам той же папки.
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@
|
|||||||
- Если файл по целевому пути уже существует — добавляется суффикс ` (2)`, а в задаче — предупреждение.
|
- Если файл по целевому пути уже существует — добавляется суффикс ` (2)`, а в задаче — предупреждение.
|
||||||
- Путь проверяется на выход за корень хранилища (`..`, абсолютные пути) — ошибка.
|
- Путь проверяется на выход за корень хранилища (`..`, абсолютные пути) — ошибка.
|
||||||
|
|
||||||
## 8. Рассмотрение (админ)
|
## 8. Рассмотрение (аудит загрузок)
|
||||||
|
|
||||||
Экран «Очередь на добавление»:
|
Экран «Очередь на добавление»:
|
||||||
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
|
- Группировка по пачке загрузки / по альбому; фильтры: статус, автор, `possible_duplicate`.
|
||||||
@@ -173,7 +176,7 @@
|
|||||||
|
|
||||||
## 10. Уведомления
|
## 10. Уведомления
|
||||||
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
|
- Автор видит статусы в «Мои предложения» (раздел админки модератора), бейдж с числом решений с последнего визита.
|
||||||
- Админ видит бейдж «N на рассмотрении» в сайдбаре.
|
- Аудитор загрузок видит бейдж «N на рассмотрении» в сайдбаре.
|
||||||
|
|
||||||
## Реализация (`services/api/internal/uploads`)
|
## Реализация (`services/api/internal/uploads`)
|
||||||
|
|
||||||
@@ -181,7 +184,7 @@
|
|||||||
очереди — `upload_files.fingerprint` (дубли внутри очереди ловятся так же, как с библиотекой).
|
очереди — `upload_files.fingerprint` (дубли внутри очереди ловятся так же, как с библиотекой).
|
||||||
- Квота: `Store.CreateBatch` резервирует под `SELECT … FOR UPDATE` строки `upload_quota`; любая смена статуса идёт через
|
- Квота: `Store.CreateBatch` резервирует под `SELECT … FOR UPDATE` строки `upload_quota`; любая смена статуса идёт через
|
||||||
`transition` — место освобождается в той же транзакции, если новый статус его не занимает (таблица раздела 2).
|
`transition` — место освобождается в той же транзакции, если новый статус его не занимает (таблица раздела 2).
|
||||||
- Приём — ядро tus (ADR-015): `HEAD` / `PATCH /api/v1/uploads/files/{id}`, только автор и только в `uploading`.
|
- Приём — ядро tus: `HEAD` / `PATCH /api/v1/uploads/files/{id}`, только автор и только в `uploading`.
|
||||||
Принят целиком → `processing`.
|
Принят целиком → `processing`.
|
||||||
- Обработка (`worker.go`, по одному файлу): размер → `ffprobe` (5 с – 3 ч) → SHA-256 (сверка с заявленным) →
|
- Обработка (`worker.go`, по одному файлу): размер → `ffprobe` (5 с – 3 ч) → SHA-256 (сверка с заявленным) →
|
||||||
дубль по SHA-256 → Chromaprint против библиотеки и очереди (длительность ±5 с, `library.Similarity` ≥ 0.75) →
|
дубль по SHA-256 → Chromaprint против библиотеки и очереди (длительность ±5 с, `library.Similarity` ≥ 0.75) →
|
||||||
@@ -197,17 +200,19 @@
|
|||||||
- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`).
|
- Настройки: `GET` / `PATCH /admin/settings/uploads` (ключ `uploads.settings` в `app_settings`).
|
||||||
- Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`):
|
- Веб (`apps/web/src/features/uploads`, `pages/uploads`, `pages/admin/UploadQueuePage.tsx`):
|
||||||
- «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути),
|
- «Предложить треки» (`/uploads/new`): drag-and-drop файлов и папок (FileSystemEntry API, относительные пути),
|
||||||
выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); предпроверка; `POST /uploads`; `tus-js-client`
|
выбор файлов / папки; SHA-256 потоком в Web Worker (`hash-wasm`); теги читает `music-metadata` и отдаёт их
|
||||||
с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно, 401 → обновление токена и повтор. Загрузчик — синглтон
|
в предпроверку; `POST /uploads`; `tus-js-client` с готовым `uploadUrl`, куски по 8 МБ, 4 файла параллельно,
|
||||||
модуля: переходы по приложению не прерывают загрузку; оборвавшиеся файлы продолжаются кнопкой
|
401 → обновление токена и повтор. Загрузчик — синглтон модуля: переходы по приложению не прерывают загрузку.
|
||||||
«Продолжить загрузку» (место уже зарезервировано).
|
Незавершённые tus-ссылки пишутся в `localStorage`; после перезагрузки пользователь выбирает те же файлы,
|
||||||
- «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота.
|
клиент сверяет SHA-256 и продолжает с принятого смещения.
|
||||||
|
- «Мои предложения» (`/uploads/mine`): статусы, причины отказа, отзыв до решения, квота. Бейдж — число решений
|
||||||
|
новее `upload_visits`; открытие страницы вызывает `POST /uploads/seen`.
|
||||||
- «Очередь на добавление» (`/admin/uploads`, admin): вкладки по статусам, фильтр возможных дублей, группировка
|
- «Очередь на добавление» (`/admin/uploads`, admin): вкладки по статусам, фильтр возможных дублей, группировка
|
||||||
по пачкам, правка метаданных и хранилища, массовое одобрение / отклонение с причиной, повтор импорта;
|
по пачкам, правка метаданных и хранилища, правка исполнителя / альбома / года / обложки на всю пачку,
|
||||||
настройки очереди — внизу страницы.
|
прослушивание staging по подписанной ссылке, флаг улучшения качества, массовое одобрение / отклонение с причиной,
|
||||||
- Пока не сделано: `allow_quality_upgrade`, правка «на всю пачку» (`PATCH /admin/uploads/batches/{id}`),
|
повтор импорта. Настройки очереди — лимиты, хранилище и `allow_quality_upgrade`.
|
||||||
прослушивание из staging, отдельная обложка пачки, ночная сверка счётчика квоты, чтение тегов на клиенте
|
- Сверка квоты: `used_bytes` обновляется в транзакции смены статуса и дополнительно пересчитывается по
|
||||||
(`music-metadata`) для предпроверки метаданных, продолжение загрузки после перезагрузки страницы.
|
`SUM(size_bytes)` занимающих статусов при старте `api` и в цикле очистки (раз в 10 минут).
|
||||||
|
|
||||||
## 11. Очистка
|
## 11. Очистка
|
||||||
- `uploading` старше 24 ч → `expired`, файлы удаляются.
|
- `uploading` старше 24 ч → `expired`, файлы удаляются.
|
||||||
|
|||||||
+8
-9
@@ -11,9 +11,9 @@
|
|||||||
| Клиентское состояние | Zustand (плеер, очередь, UI) |
|
| Клиентское состояние | Zustand (плеер, очередь, UI) |
|
||||||
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
|
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
|
||||||
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
|
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
|
||||||
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) |
|
| Компоненты-примитивы | Свои `Menu`, `Dialog`, поля и сегменты в `shared/ui` |
|
||||||
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
|
| Анимация | Canvas 2D для кнопки волны |
|
||||||
| Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) |
|
| Формы | Контролируемые поля; проверка на сервере, ошибки приходят по полям |
|
||||||
| i18n | i18next + react-i18next, словари из `packages/i18n` |
|
| i18n | i18next + react-i18next, словари из `packages/i18n` |
|
||||||
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
|
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
|
||||||
|
|
||||||
@@ -31,21 +31,20 @@ apps/web/src/
|
|||||||
└── shared/ # ui-kit, lib, hooks, config
|
└── shared/ # ui-kit, lib, hooks, config
|
||||||
```
|
```
|
||||||
|
|
||||||
Правило: слой может импортировать только из нижележащих (`pages → widgets → features → entities → shared`),
|
Слой импортирует только из нижележащих: `pages → widgets → features → entities → shared`.
|
||||||
проверяется `eslint-plugin-boundaries`.
|
|
||||||
|
|
||||||
## Ключевые компоненты
|
## Ключевые компоненты
|
||||||
|
|
||||||
### App shell
|
### App shell
|
||||||
Макет — `Pipu Web.dc.html`, каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
|
Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
|
||||||
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
|
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
|
||||||
Админка по роли) / контент / плеер.
|
Админка по роли) / контент / плеер.
|
||||||
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
|
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
|
||||||
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
|
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
|
||||||
- Адаптивность (макетов нет): < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
|
- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
|
||||||
|
|
||||||
### Поиск
|
### Поиск
|
||||||
- `SearchBox` — Radix Popover + combobox по WAI-ARIA (стрелки, Enter, Esc).
|
- `SearchBox` — combobox по WAI-ARIA (стрелки, Enter, Esc).
|
||||||
- Debounce 150 мс, отмена предыдущего запроса, `GET /search/suggest`.
|
- Debounce 150 мс, отмена предыдущего запроса, `GET /search/suggest`.
|
||||||
- Enter без выбранного элемента → `/search?q=…`; Enter / клик по элементу → исполнитель / альбом / запуск трека.
|
- Enter без выбранного элемента → `/search?q=…`; Enter / клик по элементу → исполнитель / альбом / запуск трека.
|
||||||
- Горячая клавиша `/` или `Ctrl+K` — фокус в поиск.
|
- Горячая клавиша `/` или `Ctrl+K` — фокус в поиск.
|
||||||
@@ -87,7 +86,7 @@ apps/web/src/
|
|||||||
Перед загрузкой — Web Worker считает SHA-256 и читает теги (`music-metadata`), `POST /uploads/precheck` →
|
Перед загрузкой — Web Worker считает SHA-256 и читает теги (`music-metadata`), `POST /uploads/precheck` →
|
||||||
дубли сразу показываются как «уже есть: <трек>» и не загружаются. Загрузка — `tus-js-client`, до 4 параллельно,
|
дубли сразу показываются как «уже есть: <трек>» и не загружаются. Загрузка — `tus-js-client`, до 4 параллельно,
|
||||||
пауза / возобновление. Ниже — «Мои предложения» со статусами и причинами отклонения.
|
пауза / возобновление. Ниже — «Мои предложения» со статусами и причинами отклонения.
|
||||||
- **Очередь на добавление** (admin): группы по альбомам (вариант `grouped` из прототипа), мини-плеер для прослушивания, inline-редактирование
|
- **Очередь на добавление** (admin): группы по пачкам, прослушивание из staging, правка метаданных
|
||||||
метаданных (поле на весь альбом), live-превью итогового пути, бейджи «возможный дубль» / «лучшее качество»,
|
метаданных (поле на весь альбом), live-превью итогового пути, бейджи «возможный дубль» / «лучшее качество»,
|
||||||
массовые одобрение / отклонение с причиной. Бейдж с числом ожидающих — в сайдбаре.
|
массовые одобрение / отклонение с причиной. Бейдж с числом ожидающих — в сайдбаре.
|
||||||
- **Настройки** (admin): лимит очереди (ГБ), лимит на модератора, макс. размер файла, хранилище по умолчанию.
|
- **Настройки** (admin): лимит очереди (ГБ), лимит на модератора, макс. размер файла, хранилище по умолчанию.
|
||||||
|
|||||||
Loaded 100 of 154 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user