Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0cbd0f149 | ||
|
|
4c2286e70d | ||
|
|
9223516547 | ||
|
|
7b7ce78de8 | ||
|
|
4d2c6ce22f | ||
|
|
dca7173a88 | ||
|
|
1e3b350dfc | ||
|
|
74da457362 | ||
|
|
066ba24834 | ||
|
|
2b76060ea3 | ||
|
|
dfc72b1d69 | ||
|
|
978f03b8f8 | ||
|
|
b27b915b63 | ||
|
|
73e642c1ec | ||
|
|
66f5749fc1 | ||
|
|
823d1eb841 | ||
|
|
860072e542 | ||
|
|
d2f17d8d62 | ||
|
|
4763339335 | ||
|
|
fda6ed48ab |
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:
|
||||||
|
|||||||
+17
-25
@@ -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:
|
||||||
@@ -54,6 +52,23 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||||
|
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
|
||||||
|
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
|
||||||
|
run: |
|
||||||
|
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
|
||||||
|
free=$(df -Pm / | awk 'NR==2 {print $4}')
|
||||||
|
echo "свободно на раннере: ${free} МБ"
|
||||||
|
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
|
||||||
|
for svc in postgres:5432 redis:6379 meilisearch:7700; do
|
||||||
|
host=${svc%:*}; port=${svc#*:}
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
|
||||||
|
echo "$svc готов"; break
|
||||||
|
fi
|
||||||
|
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
done
|
||||||
- name: Go — миграции и запросы store
|
- name: Go — миграции и запросы store
|
||||||
working-directory: services/api
|
working-directory: services/api
|
||||||
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
||||||
@@ -67,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`.
|
||||||
+190
-5
@@ -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
|
||||||
@@ -1006,6 +1110,24 @@ paths:
|
|||||||
$ref: "#/components/schemas/SearchResults"
|
$ref: "#/components/schemas/SearchResults"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/admin/library/analysis/retry:
|
||||||
|
post:
|
||||||
|
operationId: retryAnalysis
|
||||||
|
tags: [library]
|
||||||
|
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Сколько задач снова в очереди
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [requeued]
|
||||||
|
properties:
|
||||||
|
requeued:
|
||||||
|
type: integer
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/admin/search/reindex:
|
/api/v1/admin/search/reindex:
|
||||||
post:
|
post:
|
||||||
operationId: reindexSearch
|
operationId: reindexSearch
|
||||||
@@ -1379,6 +1501,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: getAndroidLatest
|
operationId: getAndroidLatest
|
||||||
tags: [app]
|
tags: [app]
|
||||||
|
security: [] # публично: страница /android раздаёт приложение без входа
|
||||||
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||||
description: |
|
description: |
|
||||||
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
||||||
@@ -1396,6 +1519,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: downloadAndroidApk
|
operationId: downloadAndroidApk
|
||||||
tags: [app]
|
tags: [app]
|
||||||
|
security: []
|
||||||
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
||||||
parameters:
|
parameters:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -1584,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]
|
||||||
@@ -1697,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
|
||||||
@@ -1771,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
|
||||||
@@ -1796,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]
|
||||||
@@ -1815,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]
|
||||||
@@ -1854,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
|
||||||
@@ -1870,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 — выключить / не задано).
|
||||||
@@ -1893,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]
|
||||||
@@ -1994,6 +2150,19 @@ components:
|
|||||||
web_player:
|
web_player:
|
||||||
type: string
|
type: string
|
||||||
enum: [bar, island]
|
enum: [bar, island]
|
||||||
|
search_position:
|
||||||
|
type: string
|
||||||
|
enum: [center, left, right]
|
||||||
|
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]
|
||||||
@@ -2007,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
|
||||||
@@ -2019,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:
|
||||||
@@ -2059,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
|
||||||
@@ -2071,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:
|
||||||
@@ -2108,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]
|
||||||
@@ -2128,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]
|
||||||
@@ -3081,7 +3262,7 @@ components:
|
|||||||
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
|
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
|
||||||
AnalysisStatus:
|
AnalysisStatus:
|
||||||
type: object
|
type: object
|
||||||
required: [queued, running, done, failed, analyzer_ver]
|
required: [queued, running, done, failed, analyzer_ver, top_error]
|
||||||
properties:
|
properties:
|
||||||
queued:
|
queued:
|
||||||
type: integer
|
type: integer
|
||||||
@@ -3094,6 +3275,10 @@ components:
|
|||||||
analyzer_ver:
|
analyzer_ver:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
top_error:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
|
||||||
WaveMetrics:
|
WaveMetrics:
|
||||||
type: object
|
type: object
|
||||||
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
|
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
+18
-10
@@ -7,20 +7,22 @@ import {
|
|||||||
redirect,
|
redirect,
|
||||||
type ParsedLocation,
|
type ParsedLocation,
|
||||||
} from '@tanstack/react-router';
|
} from '@tanstack/react-router';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
import { AuditPage } from '../pages/admin/AuditPage';
|
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
||||||
import { LibraryPage } from '../pages/admin/LibraryPage';
|
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
||||||
import { PatternsPage } from '../pages/admin/PatternsPage';
|
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
||||||
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
|
const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
|
||||||
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
|
const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
|
||||||
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
|
const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
|
||||||
import { SuggestPage } from '../pages/uploads/SuggestPage';
|
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||||
import { SourcesPage } from '../pages/admin/SourcesPage';
|
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||||
import { UsersPage } from '../pages/admin/UsersPage';
|
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||||
import { AlbumPage } from '../pages/AlbumPage';
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
import { HomePage } from '../pages/HomePage';
|
import { HomePage } from '../pages/HomePage';
|
||||||
|
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||||
import { LoginPage } from '../pages/LoginPage';
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
||||||
import { SettingsPage } from '../pages/SettingsPage';
|
import { SettingsPage } from '../pages/SettingsPage';
|
||||||
@@ -59,6 +61,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|||||||
errorComponent: RouteError,
|
errorComponent: RouteError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||||
|
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||||
|
|
||||||
const loginRoute = createRoute({
|
const loginRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -221,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,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -233,6 +240,7 @@ const adminSettingsRoute = createRoute({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
|
androidAppRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupAdminRoute,
|
setupAdminRoute,
|
||||||
setupPasswordRoute,
|
setupPasswordRoute,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { formatBytes } from '../shared/lib/format';
|
||||||
|
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||||
|
import { Icon } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
||||||
|
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
||||||
|
*/
|
||||||
|
export function AppDownloadPage() {
|
||||||
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const latest = useQuery({
|
||||||
|
queryKey: ['android-latest'],
|
||||||
|
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
||||||
|
staleTime: 10 * 60_000,
|
||||||
|
});
|
||||||
|
const server = location.origin;
|
||||||
|
const copy = () =>
|
||||||
|
void navigator.clipboard?.writeText(server).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
});
|
||||||
|
const r = latest.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||||
|
<span className={s.badge}>Android</span>
|
||||||
|
<h1>{t('app.title')}</h1>
|
||||||
|
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||||
|
{latest.isPending ? null : r ? (
|
||||||
|
<>
|
||||||
|
<a className={s.download} href={r.download_url} download>
|
||||||
|
<Icon src={downloadIcon} size={22} />
|
||||||
|
<span>
|
||||||
|
{t('app.download')}
|
||||||
|
<span className={s.downloadMeta}>
|
||||||
|
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<ol className={s.steps}>
|
||||||
|
<li>{t('app.step_install')}</li>
|
||||||
|
<li>
|
||||||
|
{t('app.step_server')}
|
||||||
|
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||||
|
<code>{server}</code>
|
||||||
|
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>{t('app.step_updates')}</li>
|
||||||
|
</ol>
|
||||||
|
<details className={s.details}>
|
||||||
|
<summary>{t('app.checksum')}</summary>
|
||||||
|
<code>{r.sha256}</code>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||||
|
)}
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -37,7 +37,15 @@ export function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
|
<AuthLayout
|
||||||
|
footer={
|
||||||
|
info.data ? (
|
||||||
|
<>
|
||||||
|
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<h1>{t('login.title')}</h1>
|
<h1>{t('login.title')}</h1>
|
||||||
<p className={s.lead}>{t('login.subtitle')}</p>
|
<p className={s.lead}>{t('login.subtitle')}</p>
|
||||||
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
||||||
|
|||||||
@@ -126,6 +126,55 @@ function AppearanceSection() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={s.settingRow}>
|
||||||
|
<span>{t('settings.search_position')}</span>
|
||||||
|
<Segmented<'left' | 'center' | 'right'>
|
||||||
|
label={t('settings.search_position')}
|
||||||
|
value={user.ui_prefs.search_position ?? 'center'}
|
||||||
|
onChange={(v) => void setPrefs({ search_position: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'left', label: t('settings.search_left') },
|
||||||
|
{ value: 'center', label: t('settings.search_center') },
|
||||||
|
{ value: 'right', label: t('settings.search_right') },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</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'>
|
||||||
@@ -138,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 />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useId } from 'react';
|
import { useId } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -32,6 +32,11 @@ export function LibraryPage() {
|
|||||||
const reindex = useMutation({
|
const reindex = useMutation({
|
||||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
||||||
});
|
});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const retry = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
|
||||||
|
});
|
||||||
const d = status.data;
|
const d = status.data;
|
||||||
const tiles = d
|
const tiles = d
|
||||||
? ([
|
? ([
|
||||||
@@ -77,7 +82,19 @@ export function LibraryPage() {
|
|||||||
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
|
{d.analysis.failed > 0 && (
|
||||||
|
<div className={l.retryRow}>
|
||||||
|
<span className={l.scanError}>
|
||||||
|
{t('library.analysis_failed', { count: d.analysis.failed })}
|
||||||
|
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
||||||
|
</span>
|
||||||
|
{admin && (
|
||||||
|
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||||
|
{t('library.analysis_retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -118,6 +118,20 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.retryRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorDetail {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text3);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -306,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; }
|
||||||
|
}
|
||||||
@@ -161,6 +161,83 @@
|
|||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Страница Android-приложения (/android). */
|
||||||
|
.download {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--accent-fill);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download:hover {
|
||||||
|
background: var(--accent-fill2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadMeta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
@@ -7,6 +9,18 @@
|
|||||||
gap: 28px;
|
gap: 28px;
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
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 {
|
||||||
@@ -95,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.sv
|
|||||||
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
||||||
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
||||||
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
||||||
|
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
|
||||||
|
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
|
||||||
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
|
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
|
||||||
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
|
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
|
||||||
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
|
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,19 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Элементы управления не выделяются при кликах и двойных кликах — текст остаётся выделяемым на страницах. */
|
||||||
|
button,
|
||||||
|
[role='button'],
|
||||||
|
[role='tab'],
|
||||||
|
[role='option'],
|
||||||
|
[role='menuitem'],
|
||||||
|
[role='listitem'],
|
||||||
|
nav a,
|
||||||
|
label {
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -31,10 +33,11 @@
|
|||||||
.top {
|
.top {
|
||||||
grid-area: top;
|
grid-area: top;
|
||||||
z-index: 30;
|
z-index: 30;
|
||||||
display: flex;
|
/* Три зоны: логотип, середина, профиль; поиск — в одной из них (настройка «Расположение поиска»). */
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 16px;
|
||||||
justify-content: space-between;
|
|
||||||
padding: 0 20px;
|
padding: 0 20px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
background: var(--bar-a);
|
background: var(--bar-a);
|
||||||
@@ -53,9 +56,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topLeft {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topCenter {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.topRight {
|
.topRight {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,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;
|
||||||
@@ -86,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 {
|
||||||
@@ -104,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 {
|
||||||
@@ -171,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;
|
||||||
@@ -178,7 +238,7 @@
|
|||||||
|
|
||||||
@media (max-width: 1023px) {
|
@media (max-width: 1023px) {
|
||||||
.shell {
|
.shell {
|
||||||
grid-template-columns: 72px 1fr;
|
--side-w: 72px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navItem {
|
.navItem {
|
||||||
@@ -195,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,
|
||||||
@@ -22,11 +24,13 @@ import {
|
|||||||
dislikeFillIcon,
|
dislikeFillIcon,
|
||||||
dislikeIcon,
|
dislikeIcon,
|
||||||
settingsIcon,
|
settingsIcon,
|
||||||
|
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';
|
||||||
@@ -62,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} />
|
||||||
@@ -75,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' ? (
|
||||||
@@ -92,18 +157,80 @@ function Shell() {
|
|||||||
const name = user.display_name ?? user.username;
|
const name = user.display_name ?? user.username;
|
||||||
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 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)}>
|
||||||
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
{user.ui_prefs.ambient !== false && <ShellAmbient />}
|
||||||
<header className={s.top}>
|
<header className={s.top}>
|
||||||
<Link to="/" className={s.logoLink}>
|
<div className={s.topLeft}>
|
||||||
<Logo />
|
<Link to="/" className={s.logoLink}>
|
||||||
</Link>
|
<Logo />
|
||||||
<SearchBox />
|
</Link>
|
||||||
|
{searchAt === 'left' && search}
|
||||||
|
</div>
|
||||||
|
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
|
||||||
<div className={s.topRight}>
|
<div className={s.topRight}>
|
||||||
|
{searchAt === 'right' && search}
|
||||||
<Menu
|
<Menu
|
||||||
label={t('profile.menu')}
|
label={t('profile.menu')}
|
||||||
triggerClassName={s.profileButton}
|
triggerClassName={s.profileButton}
|
||||||
@@ -122,53 +249,38 @@ function Shell() {
|
|||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||||
|
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
||||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</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'} />
|
||||||
@@ -178,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). */
|
||||||
@@ -196,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}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
.box {
|
.box {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: none;
|
||||||
max-width: 520px;
|
width: 40px;
|
||||||
|
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded {
|
||||||
|
width: min(520px, 46vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 11px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
@@ -16,13 +21,36 @@
|
|||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 40px 0 42px;
|
padding: 0 40px 0 40px;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Свёрнутое поле — круглая кнопка: без подсказки и курсора ввода. */
|
||||||
|
.box:not(.expanded) .input {
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box:not(.expanded) .input::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box:not(.expanded) .input:hover {
|
||||||
|
background: var(--surface3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.box:not(.expanded) .kbd {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
@@ -56,8 +84,7 @@
|
|||||||
|
|
||||||
.popover {
|
.popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
width: min(520px, calc(100vw - 32px));
|
||||||
right: 0;
|
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
z-index: 45;
|
z-index: 45;
|
||||||
max-height: min(70vh, 560px);
|
max-height: min(70vh, 560px);
|
||||||
@@ -70,6 +97,20 @@
|
|||||||
animation: pipuIn 0.15s ease-out;
|
animation: pipuIn 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Подсказки раскрываются в сторону свободного места: слева — вправо, справа — влево, по центру — от середины. */
|
||||||
|
.left .popover {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right .popover {
|
||||||
|
right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.center .popover {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
.corrected {
|
.corrected {
|
||||||
margin: 4px 10px 8px;
|
margin: 4px 10px 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -94,6 +135,7 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.option:hover,
|
||||||
.active {
|
.active {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
}
|
}
|
||||||
@@ -144,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; }
|
||||||
|
}
|
||||||
@@ -23,8 +23,17 @@ type Item = {
|
|||||||
go: () => void;
|
go: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
|
/**
|
||||||
export function SearchBox() {
|
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
|
||||||
|
* Без фокуса и текста свёрнут в кнопку с лупой (если collapsible); поле раскрывается с анимацией.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
@@ -34,6 +43,10 @@ export function SearchBox() {
|
|||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [active, setActive] = useState(-1);
|
const [active, setActive] = useState(-1);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||||
|
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
|
||||||
|
const expanded = !collapsible || focused || q !== '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
||||||
@@ -141,7 +154,7 @@ export function SearchBox() {
|
|||||||
const optionId = (i: number) => `${listId}-${i}`;
|
const optionId = (i: number) => `${listId}-${i}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.box}>
|
<div className={cx(s.box, expanded && s.expanded, s[align])}>
|
||||||
<Icon src={searchIcon} size={18} className={s.icon} />
|
<Icon src={searchIcon} size={18} className={s.icon} />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -160,8 +173,16 @@ export function SearchBox() {
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
setActive(-1);
|
setActive(-1);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => {
|
||||||
onBlur={() => setTimeout(close, 120)}
|
// Вернулись в поле раньше, чем сработало закрытие по blur, — подсказки не прячем.
|
||||||
|
clearTimeout(blurTimer.current);
|
||||||
|
setFocused(true);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
blurTimer.current = setTimeout(close, 120);
|
||||||
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
<kbd className={s.kbd} aria-hidden>
|
<kbd className={s.kbd} aria-hidden>
|
||||||
@@ -188,7 +209,6 @@ export function SearchBox() {
|
|||||||
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
|
||||||
+4
-2
@@ -45,6 +45,8 @@ SUPPORTED_LOCALES=ru,en
|
|||||||
MIN_ANDROID_VERSION_CODE=0
|
MIN_ANDROID_VERSION_CODE=0
|
||||||
|
|
||||||
# --- Самообновление Android (M5) ---
|
# --- Самообновление Android (M5) ---
|
||||||
GITEA_URL=https://git.example.lan
|
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
|
||||||
GITEA_REPO=owner/pipu-music
|
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
|
||||||
|
GITEA_URL=
|
||||||
|
GITEA_REPO=
|
||||||
GITEA_TOKEN=
|
GITEA_TOKEN=
|
||||||
@@ -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
|
||||||
|
|||||||
+34
-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).
|
||||||
# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш.
|
# Без рекурсии: файлы внутри создают сами сервисы, а повторный запуск не обходит весь кэш.
|
||||||
@@ -76,6 +76,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
|
||||||
|
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
|
||||||
|
fill_gitea() {
|
||||||
|
local url repo origin host path
|
||||||
|
url="$(sed -n 's/^GITEA_URL=//p' .env)"
|
||||||
|
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
|
||||||
|
case "$url" in "" | *example.*) url="" ;; esac
|
||||||
|
case "$repo" in "" | owner/*) repo="" ;; esac
|
||||||
|
[ -n "$url" ] && [ -n "$repo" ] && return 0
|
||||||
|
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
|
||||||
|
[ -n "$origin" ] || return 0
|
||||||
|
case "$origin" in
|
||||||
|
http://* | https://*)
|
||||||
|
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
|
||||||
|
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
|
||||||
|
ssh://*)
|
||||||
|
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
|
||||||
|
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
|
||||||
|
*:*)
|
||||||
|
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
|
||||||
|
path="${origin#*:}" ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
path="${path%.git}"
|
||||||
|
[ -n "$url" ] || url="$host"
|
||||||
|
[ -n "$repo" ] || repo="$path"
|
||||||
|
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
|
||||||
|
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
|
||||||
|
done
|
||||||
|
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
|
||||||
|
}
|
||||||
|
fill_gitea
|
||||||
|
|
||||||
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
|
||||||
if [ -n "$LIBRARY_HOST" ]; then
|
if [ -n "$LIBRARY_HOST" ]; then
|
||||||
if grep -q '^LIBRARY_HOST=' .env; then
|
if grep -q '^LIBRARY_HOST=' .env; then
|
||||||
|
|||||||
@@ -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 (или вручную: «Администрирование → Библиотека»)."
|
|
||||||
+34
-7
@@ -26,6 +26,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
|
||||||
|
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
|
||||||
|
fill_gitea() {
|
||||||
|
local url repo origin host path
|
||||||
|
url="$(sed -n 's/^GITEA_URL=//p' .env)"
|
||||||
|
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
|
||||||
|
case "$url" in "" | *example.*) url="" ;; esac
|
||||||
|
case "$repo" in "" | owner/*) repo="" ;; esac
|
||||||
|
[ -n "$url" ] && [ -n "$repo" ] && return 0
|
||||||
|
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
|
||||||
|
[ -n "$origin" ] || return 0
|
||||||
|
case "$origin" in
|
||||||
|
http://* | https://*)
|
||||||
|
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
|
||||||
|
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
|
||||||
|
ssh://*)
|
||||||
|
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
|
||||||
|
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
|
||||||
|
*:*)
|
||||||
|
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
|
||||||
|
path="${origin#*:}" ;;
|
||||||
|
*) return 0 ;;
|
||||||
|
esac
|
||||||
|
path="${path%.git}"
|
||||||
|
[ -n "$url" ] || url="$host"
|
||||||
|
[ -n "$repo" ] || repo="$path"
|
||||||
|
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
|
||||||
|
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
|
||||||
|
done
|
||||||
|
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
|
||||||
|
}
|
||||||
|
fill_gitea
|
||||||
|
|
||||||
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
|
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
|
||||||
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
|
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
|
||||||
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
|
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
|
||||||
@@ -55,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**.
|
||||||
+22
-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#матрица-прав).
|
||||||
|
|
||||||
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
|
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
|
||||||
раз, передайте его человеку. При первом входе пароль придётся сменить.
|
раз, передайте его человеку. При первом входе пароль придётся сменить.
|
||||||
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
|
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
|
||||||
|
- **Волна «застряла» во вкусе:** «Сбросить рекомендации» — профиль вкуса, статистика прослушиваний и показы волны
|
||||||
|
стираются, онбординг покажется снова. Лайки, дизлайки и история остаются (и по-прежнему влияют на подбор).
|
||||||
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
|
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
|
||||||
- Последнего администратора нельзя понизить, заблокировать или удалить.
|
- Последнего администратора нельзя понизить, заблокировать или удалить.
|
||||||
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
|
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
|
||||||
@@ -49,11 +57,14 @@
|
|||||||
индекс обновляется сам и пересобирается при старте api).
|
индекс обновляется сам и пересобирается при старте api).
|
||||||
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
|
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
|
||||||
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
|
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
|
||||||
|
Не удалось проанализировать часть треков (например, пока диск не был смонтирован) — «Повторить» под счётчиком
|
||||||
|
ошибок возвращает их в очередь; рядом показана самая частая ошибка. Пропавшие файлы анализатор не считает
|
||||||
|
попыткой и ждёт, пока диск вернётся.
|
||||||
|
|
||||||
## 4. Очередь на добавление
|
## 4. Очередь на добавление
|
||||||
|
|
||||||
Модераторы и администраторы предлагают треки («Предложить треки»), администратор решает, что попадёт
|
Модераторы и администраторы предлагают треки («Предложить треки»), что попадёт в библиотеку, решают люди
|
||||||
в библиотеку. Подробно — [uploads.md](uploads.md).
|
с правом **аудита загрузок**. Подробно — [uploads.md](uploads.md).
|
||||||
|
|
||||||
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
|
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
|
||||||
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
|
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
|
||||||
@@ -85,40 +96,26 @@
|
|||||||
./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.
|
||||||
|
|
||||||
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
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 файла сверяется с заявленным клиентом.
|
|
||||||
|
|
||||||
+2
-25
@@ -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)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -77,7 +76,7 @@
|
|||||||
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
||||||
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
||||||
| `TRANSCODE_CONCURRENCY` | `2` | |
|
| `TRANSCODE_CONCURRENCY` | `2` | |
|
||||||
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | `https://git.home.lan`, `owner/pipu-music`, read-only токен | Для самообновления Android |
|
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
|
||||||
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
||||||
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
||||||
| `LOG_LEVEL` | `info` | |
|
| `LOG_LEVEL` | `info` | |
|
||||||
@@ -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 запускаются минимум на двух локалях (длинные русские строки ловят проблемы вёрстки).
|
|
||||||
+19
-39
@@ -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,12 +239,14 @@ 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#самообновление)). Для этого в `.env` сервера нужно заполнить
|
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
||||||
`GITEA_URL`, `GITEA_REPO` и `GITEA_TOKEN` (токен только на чтение).
|
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
||||||
|
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
||||||
|
ссылка `https://<ваш-домен>/android`.
|
||||||
|
|
||||||
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
||||||
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
||||||
@@ -302,7 +297,7 @@ curl -s https://music.example.com/readyz
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 11. Обновление, бэкапы, удаление
|
## 11. Обновление и удаление
|
||||||
|
|
||||||
Обновление до последнего релиза:
|
Обновление до последнего релиза:
|
||||||
|
|
||||||
@@ -312,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`)
|
|
||||||
восстанавливает базу, сохранив прежнюю рядом.
|
|
||||||
|
|
||||||
Остановить сервис, сохранив данные:
|
Остановить сервис, сохранив данные:
|
||||||
|
|
||||||
|
|||||||
+22
-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 — проверить на устройстве.
|
|
||||||
- **Офлайн-баннер** вверху, когда нет связи с сервером.
|
- **Офлайн-баннер** вверху, когда нет связи с сервером.
|
||||||
- Админку в мобильном **не делаем** на первом этапе (ссылка «открыть в браузере»).
|
- Админка открывается в браузере (ссылка в профиле у персонала).
|
||||||
|
|
||||||
## Воспроизведение
|
## Воспроизведение
|
||||||
|
|
||||||
@@ -82,6 +95,8 @@
|
|||||||
|
|
||||||
## Самообновление
|
## Самообновление
|
||||||
|
|
||||||
|
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
||||||
|
|
||||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||||
|
|
||||||
**Поток:**
|
**Поток:**
|
||||||
@@ -104,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"` для данных с токенами.
|
||||||
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user