Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02307802bc | ||
|
|
fff91c446b | ||
|
|
e158558da3 | ||
|
|
c84f90e2cc | ||
|
|
72567acd28 | ||
|
|
39aaaed969 | ||
|
|
8eb6b36888 | ||
|
|
83b604437c | ||
|
|
73ad61e7a5 | ||
|
|
fb20258489 | ||
|
|
78c006184b | ||
|
|
d3116aba96 | ||
|
|
b7518aa450 | ||
|
|
eb8031b126 | ||
|
|
e4b841a8d8 | ||
|
|
597067689a | ||
|
|
efe78d7e07 | ||
|
|
fa79394869 | ||
|
|
3a097c15c8 | ||
|
|
20abe20686 | ||
|
|
c34272d508 | ||
|
|
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
@@ -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:
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
name: ci / module boundaries
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'packages/core/src/**'
|
||||||
|
- 'apps/web/src/features/uploads/**'
|
||||||
|
- 'services/api/internal/uploads/**'
|
||||||
|
- 'scripts/check-boundaries.mjs'
|
||||||
|
- '.gitea/workflows/ci-boundaries.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'packages/core/src/**'
|
||||||
|
- 'apps/web/src/features/uploads/**'
|
||||||
|
- 'services/api/internal/uploads/**'
|
||||||
|
- 'scripts/check-boundaries.mjs'
|
||||||
|
- '.gitea/workflows/ci-boundaries.yml'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
boundaries:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: '22' }
|
||||||
|
- run: node scripts/check-boundaries.mjs
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
|
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками.
|
||||||
name: ci / contract
|
name: ci / contract
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
|||||||
@@ -10,13 +10,11 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'services/api/**'
|
- 'services/api/**'
|
||||||
- 'services/recommender/**'
|
- 'services/recommender/**'
|
||||||
- 'deploy/backup.sh'
|
|
||||||
- '.gitea/workflows/ci-db.yml'
|
- '.gitea/workflows/ci-db.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'services/api/**'
|
- 'services/api/**'
|
||||||
- 'services/recommender/**'
|
- 'services/recommender/**'
|
||||||
- 'deploy/backup.sh'
|
|
||||||
- '.gitea/workflows/ci-db.yml'
|
- '.gitea/workflows/ci-db.yml'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -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"
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
|
||||||
|
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
|
||||||
|
name: ci / desktop
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- 'apps/desktop/**'
|
||||||
|
- '.gitea/workflows/ci-desktop.yml'
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- 'apps/desktop/**'
|
||||||
|
- '.gitea/workflows/ci-desktop.yml'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
name: rust (desktop proxy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
|
||||||
|
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
|
||||||
@@ -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,15 @@ 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') }}
|
||||||
|
# git-core PPA на раннере иногда отвечает 503 и валит apt внутри --with-deps.
|
||||||
|
- run: |
|
||||||
|
sudo rm -f /etc/apt/sources.list.d/*git-core* || true
|
||||||
|
pnpm --filter @pipu/web exec playwright install --with-deps chromium ||
|
||||||
|
pnpm --filter @pipu/web exec playwright install 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,6 +1,8 @@
|
|||||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): Gitea Release с заметками → APK.
|
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
|
||||||
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
|
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||||
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||||
|
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
||||||
|
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
|
||||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||||
#
|
#
|
||||||
# Секреты (Settings → Actions → Secrets):
|
# Секреты (Settings → Actions → Secrets):
|
||||||
@@ -53,7 +55,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:
|
||||||
@@ -132,9 +134,10 @@ jobs:
|
|||||||
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||||
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||||
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
|
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
|
||||||
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
|
||||||
./gradlew --stop || true
|
./gradlew --stop || true
|
||||||
|
find ~/.gradle -name "*.lock" -delete || true
|
||||||
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||||
else
|
else
|
||||||
@@ -143,6 +146,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: |
|
||||||
@@ -191,3 +199,142 @@ jobs:
|
|||||||
- name: Статус в релизе — сборка отменена
|
- name: Статус в релизе — сборка отменена
|
||||||
if: cancelled()
|
if: cancelled()
|
||||||
run: node scripts/release.mjs android cancelled
|
run: node scripts/release.mjs android cancelled
|
||||||
|
|
||||||
|
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
|
||||||
|
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
|
||||||
|
desktop:
|
||||||
|
name: desktop (Linux и Windows в релиз)
|
||||||
|
needs: [release, android]
|
||||||
|
if: always() && needs.release.result == 'success'
|
||||||
|
timeout-minutes: 90
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
PIPU_VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
VERSION: ${{ needs.release.outputs.version }}
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||||
|
CARGO_BUILD_JOBS: "2"
|
||||||
|
XWIN_CACHE_DIR: /opt/ci-cache/xwin
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Статус в релизе — собирается
|
||||||
|
run: |
|
||||||
|
node scripts/release.mjs desktop linux building
|
||||||
|
node scripts/release.mjs desktop windows building
|
||||||
|
- name: Rust
|
||||||
|
run: |
|
||||||
|
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||||
|
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||||
|
- run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
|
||||||
|
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
|
||||||
|
libdbus-1-dev libsecret-1-dev patchelf \
|
||||||
|
nsis lld llvm clang
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
~/.cargo/bin/cargo-xwin
|
||||||
|
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
||||||
|
restore-keys: cargo-desktop-
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Сборка deb
|
||||||
|
id: linux
|
||||||
|
working-directory: apps/desktop
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pnpm bundle:linux
|
||||||
|
bundle=src-tauri/target/release/bundle
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in "$bundle"/deb/*.deb; do
|
||||||
|
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
|
done
|
||||||
|
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
|
rm -rf src-tauri/target/release
|
||||||
|
- name: Linux в релиз
|
||||||
|
id: linux-upload
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
f="pipu-music-${PIPU_VERSION}-linux.deb"
|
||||||
|
if [ ! -f "$f" ]; then
|
||||||
|
echo "::error::нет $f после сборки"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
node scripts/release.mjs desktop linux success "$f"
|
||||||
|
- name: Windows toolchain
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rustup target add x86_64-pc-windows-msvc
|
||||||
|
if ! command -v cargo-xwin >/dev/null; then
|
||||||
|
cargo install --locked cargo-xwin
|
||||||
|
fi
|
||||||
|
if [ ! -d /opt/ci-cache ]; then
|
||||||
|
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
|
||||||
|
mkdir -p "$HOME/.cache/xwin"
|
||||||
|
else
|
||||||
|
mkdir -p /opt/ci-cache/xwin
|
||||||
|
fi
|
||||||
|
- name: Сборка NSIS
|
||||||
|
id: win
|
||||||
|
if: ${{ !cancelled() }}
|
||||||
|
working-directory: apps/desktop
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pnpm bundle:windows:cross
|
||||||
|
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
|
||||||
|
shopt -s nullglob
|
||||||
|
copied=
|
||||||
|
for f in "$bundle"/*-setup.exe; do
|
||||||
|
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||||
|
copied=1
|
||||||
|
done
|
||||||
|
if [ -z "$copied" ]; then
|
||||||
|
echo "::error::нет NSIS setup.exe после сборки в $bundle"
|
||||||
|
ls -la "$bundle" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||||
|
- name: Windows в релиз
|
||||||
|
id: win-upload
|
||||||
|
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
|
||||||
|
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
|
||||||
|
- name: Статус Linux — сборка не удалась
|
||||||
|
if: failure() && steps.linux-upload.outcome != 'success'
|
||||||
|
run: node scripts/release.mjs desktop linux failure
|
||||||
|
- name: Статус Windows — сборка не удалась
|
||||||
|
if: failure() && steps.win-upload.outcome != 'success'
|
||||||
|
run: node scripts/release.mjs desktop windows failure
|
||||||
|
- name: Статус в релизе — сборка отменена
|
||||||
|
if: cancelled()
|
||||||
|
run: |
|
||||||
|
node scripts/release.mjs desktop linux cancelled
|
||||||
|
node scripts/release.mjs desktop windows cancelled
|
||||||
|
|
||||||
|
prune:
|
||||||
|
name: prune (старые файлы и прогоны)
|
||||||
|
needs: [release, android, desktop]
|
||||||
|
if: always() && needs.release.result == 'success'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "${{ env.NODE_VERSION }}" }
|
||||||
|
- name: Оставить 8 релизов и 8 прогонов
|
||||||
|
env:
|
||||||
|
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
|
||||||
|
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
|
run: node scripts/release.mjs prune
|
||||||
@@ -27,6 +27,8 @@ __pycache__/
|
|||||||
|
|
||||||
# generated test audio
|
# generated test audio
|
||||||
tests/fixtures/music/out/
|
tests/fixtures/music/out/
|
||||||
|
apps/web/test-results/
|
||||||
|
apps/web/playwright-report/
|
||||||
|
|
||||||
# os / editors
|
# os / editors
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -35,3 +37,10 @@ Thumbs.db
|
|||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.cache/
|
.cache/
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
|
apps/desktop/src-tauri/gen/
|
||||||
|
apps/desktop/src-tauri/tauri.release.json
|
||||||
|
apps/desktop/release/
|
||||||
|
|
||||||
|
# Intermediate platform icon renders (final assets live under apps/).
|
||||||
|
/artifacts/icon-build/
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Работа с проектом
|
||||||
|
|
||||||
|
- Начинай с ближайшего локального `AGENTS.md` и файлов конкретной возможности. Карта документов: `docs/README.md`; реализация архитектуры: `docs/architecture.md`.
|
||||||
|
- Web/desktop UI: `apps/web/src`; Android: `apps/mobile/src`; общие правила клиентов: `packages/core/src`; Go API: `services/api/internal`; рекомендации: `services/recommender/pipu_rec`.
|
||||||
|
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
|
||||||
|
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
|
||||||
|
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
|
||||||
|
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе.
|
||||||
|
- Общие проверки: `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
|
||||||
|
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
# pipu-music
|
# pipu-music
|
||||||
|
|
||||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||||
|
|
||||||
- Идея продукта: [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)
|
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
|
||||||
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
|
||||||
|
|
||||||
## Структура
|
## Структура
|
||||||
@@ -16,6 +16,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
|||||||
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
||||||
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
||||||
| `apps/web` | Веб-клиент (React + Vite) |
|
| `apps/web` | Веб-клиент (React + Vite) |
|
||||||
|
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
|
||||||
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
||||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||||
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
||||||
|
pnpm check:boundaries
|
||||||
```
|
```
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -67,4 +69,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`.
|
||||||
@@ -22,6 +22,51 @@ tags:
|
|||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
|
/api/v1/admin/genius:
|
||||||
|
get:
|
||||||
|
operationId: getGeniusStats
|
||||||
|
tags: [admin]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Persistent enrichment statistics (admin only)
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GeniusStats"
|
||||||
|
post:
|
||||||
|
operationId: controlGenius
|
||||||
|
tags: [admin]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [action]
|
||||||
|
properties:
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [start, pause, retry]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Updated statistics
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GeniusStats"
|
||||||
|
/api/v1/tracks/{id}/genius:
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/EntityId"
|
||||||
|
get:
|
||||||
|
operationId: getTrackGenius
|
||||||
|
tags: [catalog]
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Cached lyrics and annotations; never makes an upstream request
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/GeniusContent"
|
||||||
/healthz:
|
/healthz:
|
||||||
get:
|
get:
|
||||||
operationId: getHealthz
|
operationId: getHealthz
|
||||||
@@ -359,6 +404,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 +496,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 +609,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 +1155,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
|
||||||
@@ -1260,9 +1427,10 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: getOnboarding
|
operationId: getOnboarding
|
||||||
tags: [wave]
|
tags: [wave]
|
||||||
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
summary: Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
|
||||||
description: |
|
description: |
|
||||||
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
|
`required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
|
||||||
|
`choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Варианты и состояние
|
description: Варианты и состояние
|
||||||
@@ -1375,10 +1543,58 @@ paths:
|
|||||||
$ref: "#/components/schemas/WaveBatch"
|
$ref: "#/components/schemas/WaveBatch"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/app/clients:
|
||||||
|
get:
|
||||||
|
operationId: getAppClients
|
||||||
|
tags: [app]
|
||||||
|
security: [] # публично: страница /download без входа
|
||||||
|
summary: Клиенты последних релизов (Android и десктоп)
|
||||||
|
description: |
|
||||||
|
Тот же релиз Gitea, что и для Android: APK, установщик Windows, .deb, DMG.
|
||||||
|
У каждой платформы `older` — предыдущие сборки (до 8 релизов, сколько есть с файлами).
|
||||||
|
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Доступные сборки
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AppClients"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/app/download/{client}/{version}:
|
||||||
|
get:
|
||||||
|
operationId: downloadAppClient
|
||||||
|
tags: [app]
|
||||||
|
security: []
|
||||||
|
summary: Файл клиента (кэшируется на сервере, поддерживает Range)
|
||||||
|
parameters:
|
||||||
|
- name: client
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AppClient"
|
||||||
|
- name: version
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Установщик или APK
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/app/android/latest:
|
/api/v1/app/android/latest:
|
||||||
get:
|
get:
|
||||||
operationId: getAndroidLatest
|
operationId: getAndroidLatest
|
||||||
tags: [app]
|
tags: [app]
|
||||||
|
security: [] # публично: страница /download раздаёт приложение без входа
|
||||||
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||||
description: |
|
description: |
|
||||||
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
||||||
@@ -1396,6 +1612,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
|
||||||
@@ -1414,6 +1631,82 @@ paths:
|
|||||||
format: binary
|
format: binary
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/me/playlists:
|
||||||
|
get:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: listPlaylists
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistList'
|
||||||
|
post:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: createPlaylist
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistInput'
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'201':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
/api/v1/me/playlists/{id}:
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
get:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: getPlaylist
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
put:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: updatePlaylist
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistUpdate'
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
delete:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: deletePlaylist
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'204':
|
||||||
|
description: Deleted
|
||||||
|
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
SourceId:
|
SourceId:
|
||||||
@@ -1474,12 +1767,61 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Problem"
|
$ref: "#/components/schemas/Problem"
|
||||||
schemas:
|
schemas:
|
||||||
|
PlaylistInput:
|
||||||
|
type: object
|
||||||
|
required: [name, description, track_ids]
|
||||||
|
properties:
|
||||||
|
name: { type: string, minLength: 1, maxLength: 120 }
|
||||||
|
description: { type: string, maxLength: 2000 }
|
||||||
|
track_ids:
|
||||||
|
type: array
|
||||||
|
maxItems: 2000
|
||||||
|
uniqueItems: true
|
||||||
|
items: { type: string, format: uuid }
|
||||||
|
PlaylistUpdate:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/PlaylistInput'
|
||||||
|
- type: object
|
||||||
|
required: [revision]
|
||||||
|
properties:
|
||||||
|
revision: { type: integer, minimum: 1 }
|
||||||
|
PlaylistSummary:
|
||||||
|
type: object
|
||||||
|
required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at]
|
||||||
|
properties:
|
||||||
|
id: { type: string, format: uuid }
|
||||||
|
name: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
revision: { type: integer }
|
||||||
|
track_count: { type: integer }
|
||||||
|
duration_ms: { type: integer, format: int64 }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
Playlist:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/PlaylistSummary'
|
||||||
|
- type: object
|
||||||
|
required: [tracks]
|
||||||
|
properties:
|
||||||
|
tracks:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/Track' }
|
||||||
|
PlaylistList:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/PlaylistSummary' }
|
||||||
|
|
||||||
Onboarding:
|
Onboarding:
|
||||||
type: object
|
type: object
|
||||||
required: [required, artists, genres]
|
required: [required, artists, genres]
|
||||||
properties:
|
properties:
|
||||||
required:
|
required:
|
||||||
type: boolean
|
type: boolean
|
||||||
|
choice:
|
||||||
|
$ref: "#/components/schemas/OnboardingChoice"
|
||||||
artists:
|
artists:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
@@ -1515,6 +1857,19 @@ components:
|
|||||||
type: object
|
type: object
|
||||||
required: [artist_ids, genres]
|
required: [artist_ids, genres]
|
||||||
properties:
|
properties:
|
||||||
|
gender:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [male, female, other]
|
||||||
|
age_group:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
enum: [under_13, "13_19", "20_29", "30_39", "40_49", "50_59", 60_plus]
|
||||||
|
description: Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
|
||||||
|
age_group_year:
|
||||||
|
type: integer
|
||||||
|
readOnly: true
|
||||||
|
description: Год последнего выбора возрастной группы.
|
||||||
artist_ids:
|
artist_ids:
|
||||||
type: array
|
type: array
|
||||||
maxItems: 50
|
maxItems: 50
|
||||||
@@ -1584,6 +1939,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 +2055,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 +2129,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 +2157,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 +2180,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 +2236,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 +2252,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 +2278,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]
|
||||||
@@ -1944,7 +2331,7 @@ components:
|
|||||||
enum: [admin, moderator, user]
|
enum: [admin, moderator, user]
|
||||||
ClientType:
|
ClientType:
|
||||||
type: string
|
type: string
|
||||||
enum: [web, android]
|
enum: [web, android, desktop]
|
||||||
LoginRequest:
|
LoginRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [username, password, client_type]
|
required: [username, password, client_type]
|
||||||
@@ -1964,7 +2351,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
refresh_token:
|
refresh_token:
|
||||||
type: string
|
type: string
|
||||||
description: Только Android; веб передаёт токен в cookie
|
description: Android и desktop; веб передаёт токен в cookie
|
||||||
AuthTokens:
|
AuthTokens:
|
||||||
type: object
|
type: object
|
||||||
required: [access_token, token_type, expires_in, user]
|
required: [access_token, token_type, expires_in, user]
|
||||||
@@ -1979,7 +2366,7 @@ components:
|
|||||||
description: Срок жизни access-токена, секунды
|
description: Срок жизни access-токена, секунды
|
||||||
refresh_token:
|
refresh_token:
|
||||||
type: string
|
type: string
|
||||||
description: "Только для `client_type: android`"
|
description: "Для `client_type: android` и `client_type: desktop`"
|
||||||
user:
|
user:
|
||||||
$ref: "#/components/schemas/Me"
|
$ref: "#/components/schemas/Me"
|
||||||
UiPrefs:
|
UiPrefs:
|
||||||
@@ -1994,6 +2381,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 +2407,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 +2419,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 +2462,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 +2474,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 +2514,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]
|
||||||
@@ -2120,6 +2529,9 @@ components:
|
|||||||
UpdateUserRequest:
|
UpdateUserRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Новый логин. Может менять только администратор, в том числе у себя
|
||||||
display_name:
|
display_name:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 64
|
maxLength: 64
|
||||||
@@ -2128,6 +2540,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]
|
||||||
@@ -2663,10 +3078,51 @@ components:
|
|||||||
description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости
|
description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/components/schemas/TrackFeatures"
|
- $ref: "#/components/schemas/TrackFeatures"
|
||||||
|
GeniusStats:
|
||||||
|
type: object
|
||||||
|
required: [configured, paused, running, total, pending, matched, lyrics, annotations, images, unmatched, errors, last_error, cooldown_until]
|
||||||
|
properties:
|
||||||
|
configured: { type: boolean }
|
||||||
|
paused: { type: boolean }
|
||||||
|
running: { type: boolean }
|
||||||
|
total: { type: integer }
|
||||||
|
pending: { type: integer }
|
||||||
|
matched: { type: integer }
|
||||||
|
lyrics: { type: integer }
|
||||||
|
annotations: { type: integer }
|
||||||
|
images: { type: integer }
|
||||||
|
unmatched: { type: integer }
|
||||||
|
errors: { type: integer }
|
||||||
|
last_error: { type: string }
|
||||||
|
cooldown_until: { type: string, format: date-time }
|
||||||
|
GeniusContent:
|
||||||
|
type: object
|
||||||
|
required: [status, lyrics, source_url, song_image_url, artist_image_url, description, annotations]
|
||||||
|
properties:
|
||||||
|
status: { type: string }
|
||||||
|
lyrics: { type: string }
|
||||||
|
source_url: { type: string }
|
||||||
|
song_image_url: { type: string }
|
||||||
|
artist_image_url: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
annotations:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/GeniusAnnotation"
|
||||||
|
GeniusAnnotation:
|
||||||
|
type: object
|
||||||
|
required: [fragment, text, url, verified, authors]
|
||||||
|
properties:
|
||||||
|
fragment: { type: string }
|
||||||
|
text: { type: string }
|
||||||
|
url: { type: string }
|
||||||
|
verified: { type: boolean }
|
||||||
|
authors: { type: string }
|
||||||
Artist:
|
Artist:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name, album_count, track_count]
|
required: [id, name, album_count, track_count]
|
||||||
properties:
|
properties:
|
||||||
|
image_url: { type: string }
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
format: uuid
|
format: uuid
|
||||||
@@ -3081,7 +3537,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 +3550,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]
|
||||||
@@ -3135,6 +3595,59 @@ components:
|
|||||||
like_rate:
|
like_rate:
|
||||||
type: number
|
type: number
|
||||||
nullable: true
|
nullable: true
|
||||||
|
AppClient:
|
||||||
|
type: string
|
||||||
|
enum: [android, windows, linux, macos]
|
||||||
|
AppClientBuild:
|
||||||
|
type: object
|
||||||
|
required: [version, filename, size, download_url]
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
sha256:
|
||||||
|
type: string
|
||||||
|
download_url:
|
||||||
|
type: string
|
||||||
|
AppClientFile:
|
||||||
|
type: object
|
||||||
|
required: [client, filename, size, download_url]
|
||||||
|
properties:
|
||||||
|
client:
|
||||||
|
$ref: "#/components/schemas/AppClient"
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
sha256:
|
||||||
|
type: string
|
||||||
|
download_url:
|
||||||
|
type: string
|
||||||
|
older:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AppClientBuild"
|
||||||
|
AppClients:
|
||||||
|
type: object
|
||||||
|
required: [version, version_code, notes_md, clients]
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
version_code:
|
||||||
|
type: integer
|
||||||
|
notes_md:
|
||||||
|
type: string
|
||||||
|
clients:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AppClientFile"
|
||||||
AndroidRelease:
|
AndroidRelease:
|
||||||
type: object
|
type: object
|
||||||
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
|
||||||
|
let version = process.env.PIPU_VERSION ?? '0.0.0';
|
||||||
|
if (!process.env.PIPU_VERSION) {
|
||||||
|
try {
|
||||||
|
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
|
||||||
|
version = tag.replace(/^v/, '');
|
||||||
|
} catch {
|
||||||
|
// local untagged build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conf.version = version;
|
||||||
|
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
|
||||||
|
console.log(`desktop version ${version}`);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@pipu/desktop",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "tauri dev",
|
||||||
|
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
|
||||||
|
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
|
||||||
|
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
|
||||||
|
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"icons": "node ../../scripts/generate-icons.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[package]
|
||||||
|
name = "pipu-desktop"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "pipu_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pipu-desktop"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||||
|
axum = "0.8"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
url = "2"
|
||||||
|
souvlaki = "0.8.3"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
|
"identifier": "main",
|
||||||
|
"description": "Only the bundled main window may invoke desktop integration.",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": ["core:default", "core:event:default"]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 46 KiB |
@@ -0,0 +1,157 @@
|
|||||||
|
mod media;
|
||||||
|
mod proxy;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
use media::Media;
|
||||||
|
use proxy::Proxy;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{
|
||||||
|
image::Image,
|
||||||
|
include_image,
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
AppHandle, Manager, State,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
proxy: Proxy,
|
||||||
|
media: Media,
|
||||||
|
server: Mutex<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Bootstrap {
|
||||||
|
proxy_base: String,
|
||||||
|
server: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_main(app: &AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
|
||||||
|
Ok(Bootstrap {
|
||||||
|
proxy_base: state.proxy.base.clone(),
|
||||||
|
server: store::load_server(&app)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
|
||||||
|
let server = proxy::validate_server(&url)?;
|
||||||
|
state.proxy.check(&server).await?;
|
||||||
|
store::save_server(&app, Some(&server))?;
|
||||||
|
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
|
||||||
|
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
|
||||||
|
store::save_server(&app, None)?;
|
||||||
|
store::save_user(&app, None)?;
|
||||||
|
store::save_refresh(None)?;
|
||||||
|
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
|
||||||
|
*state.server.lock().map_err(|e| e.to_string())? = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_get() -> Result<Option<String>, String> {
|
||||||
|
store::load_refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_set(token: String) -> Result<(), String> {
|
||||||
|
store::save_refresh(Some(&token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_clear() -> Result<(), String> {
|
||||||
|
store::save_refresh(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
store::load_user(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
|
||||||
|
store::save_user(&app, Some(&json))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_clear(app: AppHandle) -> Result<(), String> {
|
||||||
|
store::save_user(&app, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn media_set(
|
||||||
|
state: State<AppState>,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
cover_url: Option<String>,
|
||||||
|
playing: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
|
.setup(|app| {
|
||||||
|
let saved = store::load_server(app.handle())?;
|
||||||
|
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||||
|
let media = Media::new();
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = media.attach(app.handle().clone(), &window);
|
||||||
|
}
|
||||||
|
app.manage(AppState {
|
||||||
|
proxy,
|
||||||
|
media,
|
||||||
|
server: Mutex::new(saved),
|
||||||
|
});
|
||||||
|
|
||||||
|
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&open, &quit])?;
|
||||||
|
let icon: Image = include_image!("icons/32x32.png");
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(icon)
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"open" => show_main(app),
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
show_main(tray.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("desktop app failed to start");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
pipu_desktop_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Emitter, WebviewWindow};
|
||||||
|
|
||||||
|
pub struct Media {
|
||||||
|
controls: Mutex<Option<MediaControls>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Media {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { controls: Mutex::new(None) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let hwnd = {
|
||||||
|
let _ = window;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut controls = MediaControls::new(PlatformConfig {
|
||||||
|
dbus_name: "app.pipu.music.desktop",
|
||||||
|
display_name: "Pipu Music",
|
||||||
|
hwnd,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let handle = app.clone();
|
||||||
|
controls
|
||||||
|
.attach(move |event| {
|
||||||
|
let name = match event {
|
||||||
|
MediaControlEvent::Play => "play",
|
||||||
|
MediaControlEvent::Pause => "pause",
|
||||||
|
MediaControlEvent::Toggle => "toggle",
|
||||||
|
MediaControlEvent::Next => "next",
|
||||||
|
MediaControlEvent::Previous => "previous",
|
||||||
|
MediaControlEvent::Stop => "stop",
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let _ = handle.emit("media", name);
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
|
||||||
|
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
|
||||||
|
let Some(controls) = slot.as_mut() else { return Ok(()); };
|
||||||
|
controls
|
||||||
|
.set_metadata(MediaMetadata {
|
||||||
|
title: Some(title),
|
||||||
|
artist: Some(artist),
|
||||||
|
album: Some(album),
|
||||||
|
cover_url,
|
||||||
|
duration: None,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
|
||||||
|
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! Loopback-only streaming transport. The random per-process path is a capability;
|
||||||
|
//! it never leaves the app except for OS media artwork requests. No open proxy.
|
||||||
|
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{sync::{Arc, RwLock}, time::Duration};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Proxy {
|
||||||
|
pub server: Arc<RwLock<Option<String>>>,
|
||||||
|
pub base: String,
|
||||||
|
secret: String,
|
||||||
|
authority: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_server(raw: &str) -> Result<String, String> {
|
||||||
|
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|
||||||
|
|| !url.username().is_empty() || url.password().is_some()
|
||||||
|
|| url.query().is_some() || url.fragment().is_some()
|
||||||
|
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
|
||||||
|
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Proxy {
|
||||||
|
pub async fn start(server: Option<String>) -> Result<Self, String> {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
|
||||||
|
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
|
||||||
|
let secret = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
let proxy = Self {
|
||||||
|
server: Arc::new(RwLock::new(server)),
|
||||||
|
base: format!("http://{authority}/{secret}"), secret, authority,
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.read_timeout(Duration::from_secs(60))
|
||||||
|
.build().map_err(|e| e.to_string())?,
|
||||||
|
};
|
||||||
|
let router = Router::new().fallback(forward).with_state(proxy.clone());
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
|
||||||
|
});
|
||||||
|
Ok(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check(&self, server: &str) -> Result<(), String> {
|
||||||
|
let response = self.client.get(format!("{server}/api/v1/server-info"))
|
||||||
|
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
|
||||||
|
if !response.status().is_success() { return Err("Server unavailable".into()); }
|
||||||
|
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
|
||||||
|
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_origin(origin: &str) -> bool {
|
||||||
|
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|
||||||
|
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
|
||||||
|
// the configured server; never route arbitrary URLs supplied in server content.
|
||||||
|
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
|
||||||
|
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
|
||||||
|
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
|
||||||
|
.map(|p| format!("{base}{p}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
|
||||||
|
Value::Object(fields) => for (key, v) in fields {
|
||||||
|
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
|
||||||
|
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
|
||||||
|
} else { rewrite_json(v, server, base); }
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
|
||||||
|
headers.insert("vary", HeaderValue::from_static("Origin"));
|
||||||
|
}
|
||||||
|
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||||
|
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||||
|
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||||
|
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||||
|
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|
||||||
|
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
|
||||||
|
{ return StatusCode::FORBIDDEN.into_response(); }
|
||||||
|
let prefix = format!("/{}/api/", proxy.secret);
|
||||||
|
let path = request.uri().path().to_owned();
|
||||||
|
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||||
|
let tail = tail.to_owned();
|
||||||
|
// Prevent URL normalization escaping the configured API path, including encoded segments.
|
||||||
|
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|
||||||
|
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
|
||||||
|
{ return StatusCode::BAD_REQUEST.into_response(); }
|
||||||
|
if origin.is_none() && !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
let mut response = if request.method() == Method::OPTIONS {
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
} else {
|
||||||
|
match transfer(&proxy, request, tail).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cors(response.headers_mut(), origin.as_deref());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||||
|
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||||
|
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||||
|
let url = format!("{server}/api/{tail}{query}");
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||||
|
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||||
|
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||||
|
}
|
||||||
|
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||||
|
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||||
|
}
|
||||||
|
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||||
|
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||||
|
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||||
|
let body = if json && parts.method != Method::HEAD {
|
||||||
|
// Bound only JSON, never buffer a song or an upload in memory.
|
||||||
|
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
rewrite_json(&mut value, &server, &proxy.base);
|
||||||
|
headers.remove("content-length");
|
||||||
|
headers.remove("etag");
|
||||||
|
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||||
|
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||||
|
let mut response = Response::new(body);
|
||||||
|
*response.status_mut() = status;
|
||||||
|
*response.headers_mut() = headers;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn validates_server_and_scopes_media_urls() {
|
||||||
|
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||||
|
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||||
|
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||||
|
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||||
|
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||||
|
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||||
|
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||||
|
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
const SERVICE: &str = "app.pipu.music.desktop";
|
||||||
|
|
||||||
|
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => {
|
||||||
|
let s = s.trim().to_string();
|
||||||
|
Ok((!s.is_empty()).then_some(s))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match server {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||||
|
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||||
|
match refresh_entry()?.get_password() {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||||
|
let entry = refresh_entry()?;
|
||||||
|
match token {
|
||||||
|
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||||
|
None => match entry.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match json {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Pipu Music",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"identifier": "app.pipu.music.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm --filter @pipu/web dev",
|
||||||
|
"devUrl": "http://127.0.0.1:5173",
|
||||||
|
"beforeBuildCommand": "pnpm --filter @pipu/web build",
|
||||||
|
"frontendDist": "../../web/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis", "appimage", "deb", "rpm"],
|
||||||
|
"category": "Music",
|
||||||
|
"shortDescription": "Self-hosted music player",
|
||||||
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
||||||
|
"linux": {
|
||||||
|
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
||||||
|
"appimage": { "bundleMediaFramework": false }
|
||||||
|
},
|
||||||
|
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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: "Проверить обновления"
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Android
|
||||||
|
|
||||||
|
- `src/player/controller.ts` — фасад и зеркало native-очереди; `service.ts` — фоновые события. Владельцем воспроизводимой очереди остаётся TrackPlayer.
|
||||||
|
- Общая политика волны — `packages/core/src`; офлайн-адаптация — `src/player/offlineWave.ts`, `src/offline`.
|
||||||
|
- Сессия/настройки — `src/lib`; маршруты — `src/app`. Сохраняй работу при выгруженном UI и изоляцию офлайн-данных по пользователю/серверу.
|
||||||
|
- `pnpm --filter @pipu/mobile typecheck`, `pnpm --filter @pipu/mobile test`.
|
||||||
|
- Изменения native playback требуют проверки на устройстве: background, смена трека, offline→online. Unit-тесты не заменяют эту проверку.
|
||||||
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
name: 'Pipu Music',
|
name: 'Pipu Music',
|
||||||
slug: 'pipu-music',
|
slug: 'pipu-music',
|
||||||
scheme: 'pipu',
|
scheme: 'pipu',
|
||||||
|
icon: './assets/icon.png',
|
||||||
version,
|
version,
|
||||||
orientation: 'portrait',
|
orientation: 'portrait',
|
||||||
userInterfaceStyle: 'automatic',
|
userInterfaceStyle: 'automatic',
|
||||||
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
|||||||
newArchEnabled: false,
|
newArchEnabled: false,
|
||||||
backgroundColor: '#05070D',
|
backgroundColor: '#05070D',
|
||||||
android: {
|
android: {
|
||||||
|
adaptiveIcon: {
|
||||||
|
foregroundImage: './assets/adaptive-icon.png',
|
||||||
|
monochromeImage: './assets/monochrome-icon.png',
|
||||||
|
backgroundColor: '#05070D',
|
||||||
|
},
|
||||||
package: 'app.pipu.music',
|
package: 'app.pipu.music',
|
||||||
versionCode,
|
versionCode,
|
||||||
edgeToEdgeEnabled: true,
|
edgeToEdgeEnabled: true,
|
||||||
@@ -30,7 +36,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 },
|
||||||
|
|||||||
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -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;
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { unwrap, type Track } from '@pipu/core';
|
import { unwrap, type Track } from '@pipu/core';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
|
|||||||
const session = useSession();
|
const session = useSession();
|
||||||
const current = usePlayerState().track;
|
const current = usePlayerState().track;
|
||||||
const [tab, setTab] = useState<Tab>('likes');
|
const [tab, setTab] = useState<Tab>('likes');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const list = useInfiniteQuery({
|
const list = useInfiniteQuery({
|
||||||
queryKey: ['m-collection', tab],
|
queryKey: ['m-collection', tab],
|
||||||
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
|
|||||||
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||||
|
|
||||||
const tabs = (
|
const tabs = (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
|
||||||
<Segmented<Tab>
|
<Segmented<Tab>
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
|
|||||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { player } from '../../player/controller';
|
|||||||
import { usePlayerState } from '../../player/hooks';
|
import { usePlayerState } from '../../player/hooks';
|
||||||
import { useColors } from '../../theme';
|
import { useColors } from '../../theme';
|
||||||
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
||||||
|
import { OnboardingGate } from '../../ui/Onboarding';
|
||||||
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
||||||
|
|
||||||
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
||||||
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
|
|||||||
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
||||||
*/
|
*/
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
|
return <OnboardingGate><WaveHome /></OnboardingGate>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WaveHome() {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tr = t as unknown as (k: string) => string;
|
const tr = t as unknown as (k: string) => string;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { player } from '../../player/controller';
|
|||||||
import { radius, useColors } from '../../theme';
|
import { radius, useColors } from '../../theme';
|
||||||
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||||
import { useDownloads } from '../../ui/downloads';
|
import { useDownloads } from '../../ui/downloads';
|
||||||
|
import { RecommendationSettings } from '../../ui/Onboarding';
|
||||||
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
|
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
|
||||||
import { useUpdateState } from '../../ui/UpdateDialog';
|
import { useUpdateState } from '../../ui/UpdateDialog';
|
||||||
|
|
||||||
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
|
|||||||
@{user.username} · {t(`roles.${user.role}`)}
|
@{user.username} · {t(`roles.${user.role}`)}
|
||||||
</Muted>
|
</Muted>
|
||||||
|
|
||||||
|
<RecommendationSettings />
|
||||||
|
|
||||||
<Section>
|
<Section>
|
||||||
<Row label={t('language.label')}>
|
<Row label={t('language.label')}>
|
||||||
<Segmented<Locale>
|
<Segmented<Locale>
|
||||||
@@ -116,6 +119,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,30 @@ function Gate() {
|
|||||||
|
|
||||||
function Screens() {
|
function Screens() {
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
|
const { server, session } = useSessionCtx();
|
||||||
|
const auth = useAuthState();
|
||||||
|
const connected = !!server && !!session;
|
||||||
|
const user = connected && auth.status === 'signed_in' ? auth.user : null;
|
||||||
|
const needsSetup = !!user && (user.must_change_credentials || user.must_change_password);
|
||||||
return (
|
return (
|
||||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
|
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: colors.bg } }}>
|
||||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
{/* Guards exclude screens before render: an effect redirect runs after useSession/useUser throw. */}
|
||||||
|
<Stack.Protected guard={!connected}>
|
||||||
|
<Stack.Screen name="connect" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={connected && !user}>
|
||||||
|
<Stack.Screen name="login" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={needsSetup}>
|
||||||
|
<Stack.Screen name="setup" />
|
||||||
|
</Stack.Protected>
|
||||||
|
<Stack.Protected guard={!!user && !needsSetup}>
|
||||||
|
<Stack.Screen name="(tabs)" />
|
||||||
|
<Stack.Screen name="album/[id]" />
|
||||||
|
<Stack.Screen name="artist/[id]" />
|
||||||
|
<Stack.Screen name="playlists" />
|
||||||
|
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||||
|
</Stack.Protected>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||||
|
import { trackShareUrl } from '@pipu/core';
|
||||||
|
|
||||||
import { useUser } from '../lib/session';
|
import { useSessionCtx, useUser } from '../lib/session';
|
||||||
import { player } from '../player/controller';
|
import { player } from '../player/controller';
|
||||||
import { usePlayerState } from '../player/hooks';
|
import { usePlayerState } from '../player/hooks';
|
||||||
import { useColors } from '../theme';
|
import { useColors } from '../theme';
|
||||||
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const prefs = useUser().ui_prefs;
|
const prefs = useUser().ui_prefs;
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
const [barWidth, setBarWidth] = useState(1);
|
const [barWidth, setBarWidth] = useState(1);
|
||||||
const track = st.track;
|
const track = st.track;
|
||||||
|
|
||||||
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
|
|||||||
const duration = st.durationMs || track.duration_ms;
|
const duration = st.durationMs || track.duration_ms;
|
||||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const share = async () => {
|
||||||
|
if (!server || sharing) return;
|
||||||
|
setSharing(true);
|
||||||
|
try {
|
||||||
|
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t('player.share'), t('player.share_error'));
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
|
|||||||
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
||||||
<DownloadButton track={track} />
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||||
|
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
|
||||||
|
<Icon name="share" size={24} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<DownloadButton track={track} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ alignItems: 'center' }}>
|
<View style={{ alignItems: 'center' }}>
|
||||||
@@ -72,6 +91,12 @@ export default function PlayerScreen() {
|
|||||||
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
||||||
{track.artists.map((a) => a.name).join(', ')}
|
{track.artists.map((a) => a.name).join(', ')}
|
||||||
</Text>
|
</Text>
|
||||||
|
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
|
||||||
|
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
|
||||||
|
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
|
||||||
|
<Icon name="playlist-add" size={24} color={colors.text2} />
|
||||||
|
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
|
||||||
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ gap: 6 }}>
|
<View style={{ gap: 6 }}>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||||
|
import { useSession, useUser } from '../lib/session';
|
||||||
|
import { player } from '../player/controller';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
|
type Playlist = components['schemas']['Playlist'];
|
||||||
|
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
|
||||||
|
|
||||||
|
export default function PlaylistsScreen() {
|
||||||
|
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
|
||||||
|
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function List({ track }: { track?: string }) {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
|
const add = useMutation({ mutationFn: async (id: string) => {
|
||||||
|
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||||
|
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
|
||||||
|
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||||
|
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||||
|
return <Screen><FlatList
|
||||||
|
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
|
||||||
|
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
|
||||||
|
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||||
|
ListHeaderComponent={<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
|
||||||
|
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
|
||||||
|
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
|
||||||
|
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
|
||||||
|
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
|
</View>}
|
||||||
|
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
|
||||||
|
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
|
||||||
|
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
|
||||||
|
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
|
||||||
|
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
|
||||||
|
}} />}</Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
|
||||||
|
const session = useSession();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [original] = useState(playlist);
|
||||||
|
const [name, setName] = useState(playlist?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||||
|
const save = useMutation({ mutationFn: async () => {
|
||||||
|
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
|
||||||
|
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||||
|
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||||
|
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
|
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||||
|
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
|
||||||
|
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
|
||||||
|
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
|
||||||
|
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||||
|
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
|
||||||
|
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
|
||||||
|
</ScrollView></Screen></Modal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ id }: { id: string }) {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [edit, setEdit] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const [playError, setPlayError] = useState<unknown>();
|
||||||
|
const key = ['m-playlists', user.id, id];
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
|
const p = query.data;
|
||||||
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||||
|
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
|
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||||
|
const play = (shuffle: boolean) => {
|
||||||
|
const tracks = p?.tracks.filter(t => t.available) ?? [];
|
||||||
|
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
|
||||||
|
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
|
||||||
|
};
|
||||||
|
const move = (index: number, delta: number) => {
|
||||||
|
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||||
|
};
|
||||||
|
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||||
|
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||||
|
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
|
||||||
|
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
|
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||||
|
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||||
|
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||||
|
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||||
|
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||||
|
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||||
|
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
|
||||||
|
])} />
|
||||||
|
</>}
|
||||||
|
</View>}
|
||||||
|
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
|
||||||
|
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
|
||||||
|
{manage && <View style={{padding:12,gap:6}}>
|
||||||
|
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
|
||||||
|
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
|
||||||
|
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
|
||||||
|
</View>}
|
||||||
|
</View>}
|
||||||
|
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -272,7 +272,7 @@ class Downloads {
|
|||||||
if (track.album.cover_url) {
|
if (track.album.cover_url) {
|
||||||
const cover = this.cover(track.album.id);
|
const cover = this.cover(track.album.id);
|
||||||
const info = await FileSystem.getInfoAsync(cover);
|
const info = await FileSystem.getInfoAsync(cover);
|
||||||
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined);
|
if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
|
||||||
}
|
}
|
||||||
const info = await FileSystem.getInfoAsync(this.file(id));
|
const info = await FileSystem.getInfoAsync(this.file(id));
|
||||||
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
|
||||||
@@ -286,4 +286,9 @@ class Downloads {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
|
||||||
|
export function sizedCover(url: string, size: number): string {
|
||||||
|
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
|
||||||
|
}
|
||||||
|
|
||||||
export const downloads = new Downloads();
|
export const downloads = new Downloads();
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import NetInfo from '@react-native-community/netinfo';
|
import NetInfo from '@react-native-community/netinfo';
|
||||||
import {
|
import {
|
||||||
EventBuffer,
|
EventBuffer,
|
||||||
|
WaveCoordinator,
|
||||||
|
EARLY_SKIP_MS,
|
||||||
|
type WaveInfo,
|
||||||
|
type WaveOutcome,
|
||||||
|
type WaveFeedback,
|
||||||
PlayAttempt,
|
PlayAttempt,
|
||||||
|
normalizeGain,
|
||||||
unwrap,
|
unwrap,
|
||||||
type AuthSession,
|
type AuthSession,
|
||||||
type PlayContext,
|
type PlayContext,
|
||||||
@@ -16,15 +22,14 @@ import TrackPlayer, {
|
|||||||
} from 'react-native-track-player';
|
} from 'react-native-track-player';
|
||||||
|
|
||||||
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||||
import { downloads } from '../offline/downloads';
|
import { downloads, sizedCover } from '../offline/downloads';
|
||||||
import { PersistentEvents } from '../offline/events';
|
import { PersistentEvents } from '../offline/events';
|
||||||
|
import { offlineWave } from './offlineWave';
|
||||||
|
import { eventId } from './eventId';
|
||||||
|
|
||||||
export interface WaveInfo {
|
export { offlineWave };
|
||||||
sessionId: string;
|
|
||||||
activity: string | null;
|
export type { WaveInfo } from '@pipu/core';
|
||||||
mood: string | null;
|
|
||||||
fallback: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
ready: boolean;
|
ready: boolean;
|
||||||
@@ -39,21 +44,8 @@ export interface PlayerState {
|
|||||||
wave: WaveInfo | null;
|
wave: WaveInfo | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
|
|
||||||
|
|
||||||
const WAVE_REFILL_AT = 2;
|
|
||||||
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 относительные — для приложения нужен полный адрес сервера. */
|
||||||
@@ -88,8 +80,13 @@ class Controller {
|
|||||||
private attempt: PlayAttempt | null = null;
|
private attempt: PlayAttempt | null = null;
|
||||||
private lastPositionMs = 0;
|
private lastPositionMs = 0;
|
||||||
private lastSave = 0;
|
private lastSave = 0;
|
||||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
private readonly wavePolicy = new WaveCoordinator({
|
||||||
private waveLoading = false;
|
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
|
||||||
|
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
|
||||||
|
params: { path: { id } }, body: { feedback },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||||
private setupDone: Promise<void> | null = null;
|
private setupDone: Promise<void> | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
@@ -118,7 +115,7 @@ class Controller {
|
|||||||
this.server = server;
|
this.server = server;
|
||||||
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||||
const sink = this.persistent;
|
const sink = this.persistent;
|
||||||
this.events = sink ? new EventBuffer({ send: sink.send }) : null;
|
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
|
||||||
void sink?.drain();
|
void sink?.drain();
|
||||||
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||||
if (st.isConnected) void this.persistent?.drain();
|
if (st.isConnected) void this.persistent?.drain();
|
||||||
@@ -159,6 +156,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 +168,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,18 +211,32 @@ 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();
|
||||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||||
return tracks.map((t) => ({
|
return tracks.map((t) => {
|
||||||
id: t.id,
|
const cover = absolute(this.server, t.album.cover_url);
|
||||||
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
return {
|
||||||
title: t.title,
|
id: t.id,
|
||||||
artist: t.artists.map((a) => a.name).join(', '),
|
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||||
album: t.album.title,
|
title: t.title,
|
||||||
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
|
artist: t.artists.map((a) => a.name).join(', '),
|
||||||
duration: t.duration_ms / 1000,
|
album: t.album.title,
|
||||||
}));
|
artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
|
||||||
|
duration: t.duration_ms / 1000,
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async playList(tracks: Track[], index: number, context: PlayContext) {
|
async playList(tracks: Track[], index: number, context: PlayContext) {
|
||||||
@@ -293,18 +309,22 @@ class Controller {
|
|||||||
if (!this.session) return;
|
if (!this.session) return;
|
||||||
await this.setup();
|
await this.setup();
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
|
const version = this.wavePolicy.version;
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
// Нет связи с сервером — офлайн-волна из скачанного.
|
// Нет связи с сервером — офлайн-волна из скачанного.
|
||||||
const local = offlineWave(downloads.downloaded());
|
const local = offlineWave(downloads.downloaded());
|
||||||
if (!local.length) throw e;
|
if (!local.length) throw e;
|
||||||
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
||||||
}
|
}
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
const tracks = res.tracks.filter(playable);
|
const tracks = res.tracks.filter(playable);
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
this.finishAttempt(this.lastPositionMs / 1000);
|
this.finishAttempt(this.lastPositionMs / 1000);
|
||||||
|
this.wavePolicy.activate(res.session_id);
|
||||||
this.update({
|
this.update({
|
||||||
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
|
||||||
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
|
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
|
||||||
@@ -317,13 +337,16 @@ class Controller {
|
|||||||
async setWaveContext(activity: string | null, mood: string | null) {
|
async setWaveContext(activity: string | null, mood: string | null) {
|
||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
if (!wave || !this.session) return this.startWave(activity, mood);
|
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||||
|
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||||
const res = unwrap(
|
const res = unwrap(
|
||||||
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||||
params: { path: { id: wave.sessionId } },
|
params: { path: { id: wave.sessionId } },
|
||||||
body: { activity, mood },
|
body: { activity, mood },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await this.replaceUpcoming(res.tracks);
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
|
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
|
||||||
|
if (!this.wavePolicy.isCurrent(version)) return;
|
||||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||||
if (res.tracks.some(playable)) {
|
if (res.tracks.some(playable)) {
|
||||||
@@ -336,8 +359,8 @@ class Controller {
|
|||||||
|
|
||||||
private leaveWave() {
|
private leaveWave() {
|
||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
if (!wave) return;
|
if (!wave) return;
|
||||||
this.waveFeedback = [];
|
|
||||||
this.update({ wave: null });
|
this.update({ wave: null });
|
||||||
void this.session?.client
|
void this.session?.client
|
||||||
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||||
@@ -345,46 +368,36 @@ class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private noteWave(trackId: string, outcome: WaveOutcome) {
|
private noteWave(trackId: string, outcome: WaveOutcome) {
|
||||||
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
|
this.wavePolicy.note(trackId, outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
|
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
|
||||||
private async replaceUpcoming(tracks: Track[]) {
|
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
|
||||||
const keep = this.state.queue.slice(0, this.state.index + 1);
|
const keep = this.state.queue.slice(0, this.state.index + 1);
|
||||||
await TrackPlayer.removeUpcomingTracks();
|
|
||||||
const add = tracks.filter(playable);
|
const add = tracks.filter(playable);
|
||||||
await TrackPlayer.add(await this.toRntp(add));
|
const native = await this.toRntp(add);
|
||||||
this.update({ queue: [...keep, ...add] });
|
if (!current()) return;
|
||||||
|
await TrackPlayer.removeUpcomingTracks();
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (current()) this.update({ queue: [...keep, ...add] });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refillWave(refresh = false) {
|
private async refillWave(refresh = false) {
|
||||||
const wave = this.state.wave;
|
if (!this.session) return;
|
||||||
if (!wave || !this.session || this.waveLoading) return;
|
|
||||||
const remaining = this.state.queue.length - this.state.index - 1;
|
const remaining = this.state.queue.length - this.state.index - 1;
|
||||||
if (!refresh && remaining > WAVE_REFILL_AT) return;
|
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
|
||||||
this.waveLoading = true;
|
if (refresh) await this.replaceUpcoming(res.tracks, current);
|
||||||
const feedback = this.waveFeedback;
|
|
||||||
this.waveFeedback = [];
|
|
||||||
try {
|
|
||||||
const res = unwrap(
|
|
||||||
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
|
|
||||||
params: { path: { id: wave.sessionId } },
|
|
||||||
body: { feedback },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (this.state.wave?.sessionId !== wave.sessionId) return;
|
|
||||||
if (refresh) await this.replaceUpcoming(res.tracks);
|
|
||||||
else {
|
else {
|
||||||
const add = res.tracks.filter(playable);
|
const add = res.tracks.filter(playable);
|
||||||
await TrackPlayer.add(await this.toRntp(add));
|
const native = await this.toRntp(add);
|
||||||
|
if (!current()) return;
|
||||||
|
await TrackPlayer.add(native);
|
||||||
|
if (!current()) return;
|
||||||
this.update({ queue: [...this.state.queue, ...add] });
|
this.update({ queue: [...this.state.queue, ...add] });
|
||||||
}
|
}
|
||||||
this.update({ wave: { ...wave, fallback: res.fallback } });
|
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
|
||||||
} catch {
|
});
|
||||||
this.waveFeedback = [...feedback, ...this.waveFeedback];
|
|
||||||
} finally {
|
|
||||||
this.waveLoading = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- состояние ---
|
// --- состояние ---
|
||||||
@@ -428,6 +441,7 @@ class Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async reset() {
|
async reset() {
|
||||||
|
this.wavePolicy.activate(null);
|
||||||
this.attempt = null;
|
this.attempt = null;
|
||||||
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||||
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { uuidv7 } from '@pipu/core';
|
||||||
|
|
||||||
|
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
|
||||||
|
export function eventId(now: number): string {
|
||||||
|
return uuidv7(now, () => {
|
||||||
|
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
|
||||||
|
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,85 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useState, type ReactNode } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
||||||
|
|
||||||
|
import { useSession, useSessionCtx, useUser } from '../lib/session';
|
||||||
|
import { useColors } from '../theme';
|
||||||
|
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
|
||||||
|
|
||||||
|
type Options = components['schemas']['Onboarding'];
|
||||||
|
type Choice = components['schemas']['OnboardingChoice'];
|
||||||
|
|
||||||
|
function useOnboarding() {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
return useQuery({ queryKey: ['onboarding', server, user.id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||||
|
staleTime: 0, refetchInterval: 30_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OnboardingGate({ children }: { children: ReactNode }) {
|
||||||
|
const query = useOnboarding();
|
||||||
|
const client = useQueryClient();
|
||||||
|
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
|
||||||
|
if (!query.data?.required) return <>{children}</>;
|
||||||
|
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
|
||||||
|
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
|
||||||
|
</ScrollView></Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecommendationSettings() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const query = useOnboarding();
|
||||||
|
const client = useQueryClient();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const errorText = useErrorText();
|
||||||
|
return <View>
|
||||||
|
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
|
||||||
|
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
|
||||||
|
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
|
||||||
|
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, age_group: null });
|
||||||
|
const save = useMutation({ mutationFn: async (skip: boolean) => unwrap(await session.client.PUT('/api/v1/me/onboarding', {
|
||||||
|
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : choice,
|
||||||
|
})), onSuccess: onDone });
|
||||||
|
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
|
||||||
|
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
|
||||||
|
}));
|
||||||
|
return <View style={{ gap: 20 }}>
|
||||||
|
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
|
||||||
|
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
|
||||||
|
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
|
||||||
|
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
|
||||||
|
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
|
||||||
|
<Muted>{t('onboarding.age_hint')}</Muted>
|
||||||
|
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
|
||||||
|
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
|
||||||
|
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||||
|
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
|
||||||
|
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
|
||||||
|
const colors = useColors();
|
||||||
|
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
|
||||||
|
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
|
||||||
|
const on = selected.includes(item.value);
|
||||||
|
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
|
||||||
|
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
|
||||||
|
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
|
||||||
|
</Pressable>;
|
||||||
|
})}</View>
|
||||||
|
</View>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { ApiError, type Track } from '@pipu/core';
|
import { ApiError, type Track } from '@pipu/core';
|
||||||
import type { ComponentProps, ReactNode } from 'react';
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
import { useState, useSyncExternalStore } from 'react';
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
@@ -19,7 +20,7 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { useSessionCtx } from '../lib/session';
|
import { useSessionCtx } from '../lib/session';
|
||||||
import { downloads } from '../offline/downloads';
|
import { downloads, sizedCover } from '../offline/downloads';
|
||||||
import { absolute } from '../player/controller';
|
import { absolute } from '../player/controller';
|
||||||
import { radius, useColors, type Colors } from '../theme';
|
import { radius, useColors, type Colors } from '../theme';
|
||||||
|
|
||||||
@@ -160,7 +161,7 @@ export function Cover({
|
|||||||
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
|
||||||
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
||||||
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
|
||||||
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined);
|
const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
|
||||||
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
const bg = album.cover_colors?.[0] ?? colors.surface3;
|
||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
@@ -199,6 +200,7 @@ export function TrackRow({
|
|||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const liked = track.reaction === 'like';
|
const liked = track.reaction === 'like';
|
||||||
|
const router = useRouter();
|
||||||
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -233,6 +235,10 @@ export function TrackRow({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
{trailing}
|
{trailing}
|
||||||
|
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
|
||||||
|
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
|
||||||
|
<Icon name="playlist-add" size={22} />
|
||||||
|
</Pressable>
|
||||||
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
||||||
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { AuthSession, Track } from '@pipu/core';
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@react-native-community/netinfo', () => ({ default: {
|
||||||
|
fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {},
|
||||||
|
} }));
|
||||||
|
vi.mock('../src/lib/prefs', () => ({
|
||||||
|
qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) },
|
||||||
|
playerCache: { clearIfRequested: async () => {} },
|
||||||
|
}));
|
||||||
|
vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} }));
|
||||||
|
vi.mock('../src/offline/downloads', () => ({
|
||||||
|
downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] },
|
||||||
|
sizedCover: (s: string) => s,
|
||||||
|
}));
|
||||||
|
vi.mock('react-native-track-player', () => ({
|
||||||
|
default: {
|
||||||
|
isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {},
|
||||||
|
addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined),
|
||||||
|
add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined),
|
||||||
|
skip: vi.fn().mockResolvedValue(undefined),
|
||||||
|
},
|
||||||
|
AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
function track(id: string): Track {
|
||||||
|
return { id, title: id, duration_ms: 1000, artists: [],
|
||||||
|
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||||
|
genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null };
|
||||||
|
}
|
||||||
|
function deferred<T>() {
|
||||||
|
let resolve!: (v: T) => void;
|
||||||
|
const promise = new Promise<T>((yes) => { resolve = yes; });
|
||||||
|
return { promise, resolve };
|
||||||
|
}
|
||||||
|
const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() });
|
||||||
|
|
||||||
|
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
it('ignores a wave creation response after the player is reset', async () => {
|
||||||
|
const { player } = await import('../src/player/controller');
|
||||||
|
const native = (await import('react-native-track-player')).default;
|
||||||
|
const pending = deferred<ReturnType<typeof reply>>();
|
||||||
|
const post = vi.fn().mockReturnValue(pending.promise);
|
||||||
|
player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null);
|
||||||
|
const start = player.startWave(null, null);
|
||||||
|
await vi.waitFor(() => expect(post).toHaveBeenCalled());
|
||||||
|
await player.reset();
|
||||||
|
pending.resolve(reply('old'));
|
||||||
|
await start;
|
||||||
|
expect(player.getState().wave).toBeNull();
|
||||||
|
expect(player.getState().queue).toEqual([]);
|
||||||
|
expect(native.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not let a late context response replace a manually selected playlist', async () => {
|
||||||
|
const { player } = await import('../src/player/controller');
|
||||||
|
const pending = deferred<ReturnType<typeof reply>>();
|
||||||
|
const session = { client: {
|
||||||
|
POST: vi.fn().mockResolvedValue(reply('wave')),
|
||||||
|
PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}),
|
||||||
|
} };
|
||||||
|
player.attach(session as unknown as AuthSession, 'http://localhost', null);
|
||||||
|
await player.startWave(null, null);
|
||||||
|
const change = player.setWaveContext('focus', null);
|
||||||
|
await player.playList([track('manual')], 0, { type: 'album', id: 'album' });
|
||||||
|
pending.resolve(reply('stale'));
|
||||||
|
await change;
|
||||||
|
expect(player.getState().wave).toBeNull();
|
||||||
|
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
|
||||||
|
expect(player.getState().context?.type).toBe('album');
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { EventBuffer } from '@pipu/core';
|
||||||
|
import { eventId } from '../src/player/eventId';
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it('queues restored-track events on Hermes without Web Crypto', () => {
|
||||||
|
vi.stubGlobal('crypto', undefined);
|
||||||
|
const nativeUuid = vi.fn()
|
||||||
|
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
|
||||||
|
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
|
||||||
|
vi.stubGlobal('expo', { uuidv4: nativeUuid });
|
||||||
|
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
|
||||||
|
buffer.push({ type: 'play_start', track_id: 'restored-track' });
|
||||||
|
buffer.push({ type: 'skip', track_id: 'restored-track' });
|
||||||
|
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
|
||||||
|
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
|
||||||
|
expect(nativeUuid).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Web и desktop UI
|
||||||
|
|
||||||
|
- Маршруты: `src/app/router.tsx`; сессия: `src/app/session.tsx`; страницы собирают возможности, `src/features` владеет сценариями.
|
||||||
|
- Воспроизведение: `src/player/player.ts` — фасад; адаптеры рядом. Общие правила очереди, событий и волны — `packages/core/src`.
|
||||||
|
- Загрузки: `src/features/uploads`; страница очереди — `src/pages/admin/UploadQueuePage.tsx`. Сохраняй query keys/invalidation, права и поведение диалогов.
|
||||||
|
- `pnpm --filter @pipu/web typecheck`; `pnpm --filter @pipu/web test`. Для локальной проверки можно передать путь теста после `test`.
|
||||||
|
- UI/навигация/диалоги: `pnpm --filter @pipu/web test:e2e`. Проверки используют fake API; реальная серверная авторизация покрывается Go отдельно.
|
||||||
|
- Restore не запускает музыку и не создаёт событие прослушивания. Ошибка preload не пропускает трек. Dispose должен освобождать обработчики.
|
||||||
@@ -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,39 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
for (const width of [320, 1280]) {
|
||||||
|
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||||
|
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||||
|
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||||
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||||
|
const playlistsHeader = page.locator('header').filter({
|
||||||
|
has: page.getByRole('heading', { name: 'Плейлисты', exact: true }),
|
||||||
|
});
|
||||||
|
await playlistsHeader.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
|
||||||
|
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
|
||||||
|
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
|
||||||
|
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
||||||
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||||
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||||
|
await page.getByRole('listitem').first().click();
|
||||||
|
const player = page.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||||
|
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
||||||
|
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const main = document.querySelector('main')!;
|
||||||
|
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||||
|
})).toBeLessThanOrEqual(1);
|
||||||
|
await page.screenshot({ path: info.outputPath('playlist.png') });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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();
|
||||||
|
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Мужской', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: '13–19', exact: true }).click();
|
||||||
|
const form = page.getByRole('heading', { name: 'Что вы любите слушать?' }).locator('../..');
|
||||||
|
await form.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: 'Мужской', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await expect(page.getByRole('button', { name: '13–19', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
});
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -2,9 +2,11 @@
|
|||||||
<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" />
|
||||||
|
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
|
||||||
<title>pipu</title>
|
<title>pipu</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1,17 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#9CC6FF"/><stop offset=".55" stop-color="#2F80FF"/><stop offset="1" stop-color="#3A1FA8"/></radialGradient></defs><circle cx="16" cy="16" r="14" fill="url(#g)"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="orb" cx="28%" cy="18%" r="95%">
|
||||||
|
<stop stop-color="#9CC6FF"/>
|
||||||
|
<stop offset=".42" stop-color="#2F80FF"/>
|
||||||
|
<stop offset=".78" stop-color="#6354E8"/>
|
||||||
|
<stop offset="1" stop-color="#3A1FA8"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="note" x1="0" y1="0" x2=".3" y2="1">
|
||||||
|
<stop stop-color="#FFFFFF"/>
|
||||||
|
<stop offset="1" stop-color="#DFEBFF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="256" cy="256" r="240" fill="url(#orb)"/>
|
||||||
|
<path d="M208 348V164H270C356 164 356 280 270 280H208" fill="none" stroke="url(#note)" stroke-width="44" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<ellipse cx="181" cy="348" rx="49" ry="36" fill="url(#note)" transform="rotate(-18 181 348)"/>
|
||||||
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 312 B After Width: | Height: | Size: 840 B |
@@ -4,6 +4,7 @@ import { QueryClient } from '@tanstack/react-query';
|
|||||||
import { createMemoryHistory } from '@tanstack/react-router';
|
import { createMemoryHistory } from '@tanstack/react-router';
|
||||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import axe from 'axe-core';
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from 'react-i18next';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
@@ -117,7 +118,8 @@ describe('navigation by role', () => {
|
|||||||
|
|
||||||
it('shows users but not the audit log to moderators', async () => {
|
it('shows users but not the audit log to moderators', async () => {
|
||||||
const mod = makeUser('mod', 'moderator');
|
const mod = makeUser('mod', 'moderator');
|
||||||
await renderApp([mod], '/', { signedInAs: mod });
|
const { user } = await renderApp([mod], '/', { signedInAs: mod });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Админка' }));
|
||||||
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
|
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -152,6 +154,39 @@ describe('users admin', () => {
|
|||||||
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
|
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(['root', 'anna'])('lets admins change the username of %s', async (target) => {
|
||||||
|
const root = makeUser('root', 'admin');
|
||||||
|
const anna = makeUser('anna', 'user');
|
||||||
|
const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||||
|
await user.click(await screen.findByRole('button', { name: `Действия с ${target}` }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||||
|
const input = within(dialog).getByLabelText('Логин');
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, `new.${target}`);
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument();
|
||||||
|
expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`);
|
||||||
|
if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the username dialog open when the new username is taken', async () => {
|
||||||
|
const root = makeUser('root', 'admin');
|
||||||
|
const anna = makeUser('anna', 'user');
|
||||||
|
const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Действия с anna' }));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||||
|
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||||
|
const input = within(dialog).getByLabelText('Логин');
|
||||||
|
await user.clear(input);
|
||||||
|
await user.type(input, 'ROOT');
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
|
||||||
|
expect(api.users.get(anna.id)?.username).toBe('anna');
|
||||||
|
expect(dialog).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('offers moderators only what they may do', async () => {
|
it('offers moderators only what they may do', async () => {
|
||||||
const mod = makeUser('mod', 'moderator');
|
const mod = makeUser('mod', 'moderator');
|
||||||
const anna = makeUser('anna', 'user');
|
const anna = makeUser('anna', 'user');
|
||||||
@@ -164,7 +199,7 @@ describe('users admin', () => {
|
|||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
|
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
|
||||||
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
|
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
|
||||||
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
|
expect(items).toEqual(['Сбросить пароль', 'Сбросить рекомендации', 'Заблокировать']);
|
||||||
await user.keyboard('{Escape}');
|
await user.keyboard('{Escape}');
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
||||||
@@ -246,6 +281,30 @@ describe('search box', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('player', () => {
|
describe('player', () => {
|
||||||
|
it('opens a shared track after login and copies its page URL from the player', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const path = '/track/00000000-0000-4000-8000-000000000101';
|
||||||
|
const { user, router } = await renderApp([alice], path);
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
await user.type(screen.getByLabelText('Логин'), alice.username);
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), alice.password);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||||
|
expect(router.state.location.pathname).toBe(path);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Слушать' }));
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||||
|
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||||
|
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a missing shared track without starting playback', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
await renderApp([alice], '/track/missing', { signedInAs: alice });
|
||||||
|
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
||||||
const alice = makeUser('alice', 'user');
|
const alice = makeUser('alice', 'user');
|
||||||
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
@@ -313,6 +372,31 @@ describe('my wave', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('suggest tracks', () => {
|
||||||
|
it('warns that lossless formats are the ones to send', async () => {
|
||||||
|
const admin = makeUser('admin', 'admin');
|
||||||
|
await renderApp([admin], '/uploads/new', { signedInAs: admin });
|
||||||
|
expect(await screen.findByText(/Нужны FLAC, WAV или AIFF/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('accessibility', () => {
|
||||||
|
it('home and login have no serious axe violations', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
await renderApp([alice], '/', { signedInAs: alice });
|
||||||
|
await screen.findByRole('button', { name: 'Бегаю' });
|
||||||
|
const home = await axe.run(document.body, { resultTypes: ['violations'] });
|
||||||
|
expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
await renderApp([], '/login');
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
const login = await axe.run(document.body, { resultTypes: ['violations'] });
|
||||||
|
expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('safeRedirect', () => {
|
describe('safeRedirect', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['/settings', '/settings'],
|
['/settings', '/settings'],
|
||||||
@@ -323,3 +407,102 @@ describe('safeRedirect', () => {
|
|||||||
expect(safeRedirect(input)).toBe(expected);
|
expect(safeRedirect(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('playlists', () => {
|
||||||
|
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
|
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||||
|
await user.click(screen.getAllByRole('listitem')[0]!);
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Из плеера');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(api.playlists.size).toBe(1));
|
||||||
|
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
|
||||||
|
// Simulate automatic advancement while the picker is open.
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
|
||||||
|
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||||
|
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
|
||||||
|
});
|
||||||
|
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'В дорогу');
|
||||||
|
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'В дорогу' });
|
||||||
|
const id = [...api.playlists.keys()][0]!;
|
||||||
|
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
|
||||||
|
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
|
||||||
|
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
|
||||||
|
await router.navigate({ to: '/playlist/$id', params: { id } });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||||
|
await user.clear(screen.getByLabelText('Название'));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Вечер');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Вечер' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
|
||||||
|
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Плейлисты' });
|
||||||
|
expect(api.playlists.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Первый');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Первый' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||||
|
const item = [...api.playlists.values()][0]!;
|
||||||
|
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
|
||||||
|
await user.type(screen.getByLabelText('Название'), ' изменён');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
describe('recommendation preferences', () => {
|
||||||
|
it('saves demographics, edits the age group and asks again after an admin reset', async () => {
|
||||||
|
const admin = makeUser('owner', 'admin');
|
||||||
|
const { user, api, router, session } = await renderApp([admin], '/settings', { signedInAs: admin });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Настроить рекомендации' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Мужской' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: '13–19' }));
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
|
||||||
|
await screen.findByRole('button', { name: 'Настроить рекомендации' });
|
||||||
|
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toMatchObject({ gender: 'male', age_group: '13_19' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Настроить рекомендации' }));
|
||||||
|
expect(await screen.findByRole('button', { name: '13–19' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
await user.click(screen.getByRole('button', { name: '20–29' }));
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
|
||||||
|
await screen.findByRole('button', { name: 'Настроить рекомендации' });
|
||||||
|
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: admin.id } } });
|
||||||
|
await router.navigate({ to: '/' });
|
||||||
|
await screen.findByRole('heading', { name: 'Что вы любите слушать?' });
|
||||||
|
expect(screen.getByRole('button', { name: 'Мужской' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(screen.getByRole('button', { name: '20–29' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Пропустить' }));
|
||||||
|
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
|
||||||
|
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -7,20 +7,26 @@ 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 type { DesktopHandle } from '../desktop';
|
||||||
import { AlbumPage } from '../pages/AlbumPage';
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
|
import { ConnectPage } from '../pages/ConnectPage';
|
||||||
|
import { TrackPage } from '../pages/TrackPage';
|
||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
|
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||||
import { HomePage } from '../pages/HomePage';
|
import { HomePage } from '../pages/HomePage';
|
||||||
|
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||||
import { 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';
|
||||||
@@ -30,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
|
|||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
session: AuthSession;
|
session: AuthSession;
|
||||||
|
desktop: DesktopHandle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||||
@@ -48,9 +55,12 @@ export function safeRedirect(target: unknown): string {
|
|||||||
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
|
||||||
const user = signedInUser(session);
|
const user = signedInUser(session);
|
||||||
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
if (!user) {
|
||||||
|
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
|
||||||
|
throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,12 +69,34 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|||||||
errorComponent: RouteError,
|
errorComponent: RouteError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
|
||||||
|
const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
|
||||||
|
const androidAppRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/android',
|
||||||
|
beforeLoad: () => {
|
||||||
|
throw redirect({ to: '/download' });
|
||||||
|
},
|
||||||
|
component: AppDownloadPage,
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/connect',
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (!context.desktop) throw redirect({ to: '/login' });
|
||||||
|
if (context.desktop.server) throw redirect({ to: '/login' });
|
||||||
|
},
|
||||||
|
component: ConnectPage,
|
||||||
|
});
|
||||||
|
|
||||||
const loginRoute = createRoute({
|
const loginRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/login',
|
path: '/login',
|
||||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||||
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||||
beforeLoad: ({ context, search }) => {
|
beforeLoad: ({ context, search }) => {
|
||||||
|
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
||||||
const user = signedInUser(context.session);
|
const user = signedInUser(context.session);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
@@ -78,7 +110,7 @@ const setupAdminRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/admin',
|
path: '/setup/admin',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="admin" />,
|
component: () => <SetupPage variant="admin" />,
|
||||||
@@ -88,7 +120,7 @@ const setupPasswordRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/password',
|
path: '/setup/password',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="password" />,
|
component: () => <SetupPage variant="password" />,
|
||||||
@@ -99,7 +131,7 @@ const appRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
id: 'app',
|
id: 'app',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
if (home !== '/') throw redirect({ to: home });
|
if (home !== '/') throw redirect({ to: home });
|
||||||
return { user };
|
return { user };
|
||||||
@@ -112,6 +144,9 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||||
|
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||||
|
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||||
|
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||||
|
|
||||||
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||||
|
|
||||||
@@ -221,7 +256,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,11 +270,17 @@ const adminSettingsRoute = createRoute({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
|
downloadAppRoute,
|
||||||
|
androidAppRoute,
|
||||||
|
connectRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupAdminRoute,
|
setupAdminRoute,
|
||||||
setupPasswordRoute,
|
setupPasswordRoute,
|
||||||
appRoute.addChildren([
|
appRoute.addChildren([
|
||||||
homeRoute,
|
homeRoute,
|
||||||
|
trackRoute,
|
||||||
|
playlistsRoute,
|
||||||
|
playlistRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
searchRoute,
|
searchRoute,
|
||||||
historyRoute,
|
historyRoute,
|
||||||
@@ -257,8 +300,12 @@ const routeTree = rootRoute.addChildren([
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
export function createAppRouter(
|
||||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
session: AuthSession,
|
||||||
|
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
||||||
|
) {
|
||||||
|
const { desktop = null, ...rest } = opts;
|
||||||
|
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
||||||
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||||
session.subscribe(() => void router.invalidate());
|
session.subscribe(() => void router.invalidate());
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createDesktop, isDesktop } from './desktop';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('desktop bridge', () => {
|
||||||
|
it('is inactive in the browser', () => {
|
||||||
|
expect(isDesktop()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bootstraps through the Tauri invoke surface', async () => {
|
||||||
|
const invoke = vi.fn(async (cmd: string) => {
|
||||||
|
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||||
|
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||||
|
const desktop = await createDesktop();
|
||||||
|
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||||
|
expect(desktop?.server).toBe('https://music.example');
|
||||||
|
await desktop?.setServer('https://music.example/pipu');
|
||||||
|
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||||
|
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||||
|
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Tauri = {
|
||||||
|
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||||
|
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
function api(): Tauri | undefined {
|
||||||
|
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDesktop(): boolean {
|
||||||
|
return api() !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const tauri = api();
|
||||||
|
if (!tauri) throw new Error('Tauri API unavailable');
|
||||||
|
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesktopHandle = {
|
||||||
|
proxyBase: string;
|
||||||
|
server: string | null;
|
||||||
|
setServer: (url: string) => Promise<string>;
|
||||||
|
clearServer: () => Promise<void>;
|
||||||
|
refreshStore: RefreshStore;
|
||||||
|
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||||
|
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||||
|
|
||||||
|
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||||
|
return createElement(DesktopContext.Provider, { value }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDesktop(): DesktopHandle | null {
|
||||||
|
return useContext(DesktopContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let active: DesktopHandle | null = null;
|
||||||
|
|
||||||
|
export function desktopHandle(): DesktopHandle | null {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||||
|
if (!isDesktop()) return null;
|
||||||
|
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||||
|
const handle: DesktopHandle = {
|
||||||
|
proxyBase: boot.proxyBase,
|
||||||
|
server: boot.server,
|
||||||
|
setServer: async (url) => {
|
||||||
|
const server = await invoke<string>('set_server', { url });
|
||||||
|
handle.server = server;
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
clearServer: async () => {
|
||||||
|
await invoke('clear_server');
|
||||||
|
handle.server = null;
|
||||||
|
},
|
||||||
|
refreshStore: {
|
||||||
|
get: () => invoke<string | null>('token_get'),
|
||||||
|
set: (token) => invoke('token_set', { token }),
|
||||||
|
clear: async () => {
|
||||||
|
await invoke('token_clear');
|
||||||
|
await invoke('user_clear');
|
||||||
|
},
|
||||||
|
getUser: async () => {
|
||||||
|
const json = await invoke<string | null>('user_get');
|
||||||
|
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||||
|
},
|
||||||
|
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||||
|
},
|
||||||
|
listenMedia: async (handler) => {
|
||||||
|
const tauri = api();
|
||||||
|
if (!tauri) return () => undefined;
|
||||||
|
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||||
|
},
|
||||||
|
setMedia: (info) => {
|
||||||
|
void invoke('media_set', {
|
||||||
|
title: info.title,
|
||||||
|
artist: info.artist,
|
||||||
|
album: info.album,
|
||||||
|
coverUrl: info.coverUrl ?? null,
|
||||||
|
playing: info.playing,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
active = handle;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
@@ -8,12 +8,18 @@
|
|||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover img {
|
.coverImg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coverImgOn {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.round {
|
.round {
|
||||||
@@ -59,10 +65,52 @@
|
|||||||
width: 128px;
|
width: 128px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cardCoverWrap {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.cardCover {
|
.cardCover {
|
||||||
aspect-ratio: 1;
|
aspect-ratio: 1;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 4px;
|
transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cardCover,
|
||||||
|
.card:focus-within .cardCover {
|
||||||
|
transform: scale(1.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPlay {
|
||||||
|
position: absolute;
|
||||||
|
right: 10px;
|
||||||
|
bottom: 10px;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
color: #05070d;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
transition:
|
||||||
|
opacity 0.18s,
|
||||||
|
transform 0.18s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover .cardPlay,
|
||||||
|
.card:focus-within .cardPlay,
|
||||||
|
.cardPlay:focus-visible {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cardPlay:active {
|
||||||
|
transform: scale(0.94);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cardTitle {
|
.cardTitle {
|
||||||
@@ -104,6 +152,12 @@
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
padding: 6px 12px;
|
padding: 6px 12px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
|
animation: pipuIn 0.2s ease-out both;
|
||||||
|
animation-delay: var(--stagger, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trackStatic {
|
||||||
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track:has(.trackAlbum) {
|
.track:has(.trackAlbum) {
|
||||||
@@ -270,3 +324,13 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.cardPlay { opacity: 1; transform: none; }
|
||||||
|
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.track, .track:has(.trackAlbum) { gap: 8px; padding: 6px 4px; }
|
||||||
|
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||||
|
.end { gap: 4px; }
|
||||||
|
.like { opacity: 1; width: 44px; height: 44px; }
|
||||||
|
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
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, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
|
||||||
|
import { AddToPlaylist } from './playlists';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import type { PlayContext } from '../player/player';
|
import type { PlayContext } from '../player/player';
|
||||||
@@ -26,6 +30,7 @@ function fallbackColors(id: string): string[] {
|
|||||||
|
|
||||||
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
||||||
export function coverSrc(url: string, cssPx: number): string {
|
export function coverSrc(url: string, cssPx: number): string {
|
||||||
|
if (!url.includes('/api/v1/covers/')) return url;
|
||||||
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
|
||||||
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
||||||
return `${url}&size=${size}`;
|
return `${url}&size=${size}`;
|
||||||
@@ -56,7 +61,7 @@ export function Cover({
|
|||||||
aria-hidden
|
aria-hidden
|
||||||
>
|
>
|
||||||
{album.cover_url ? (
|
{album.cover_url ? (
|
||||||
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
|
<CoverImg src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} />
|
||||||
) : (
|
) : (
|
||||||
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
|
||||||
)}
|
)}
|
||||||
@@ -80,10 +85,39 @@ export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CoverImg({ src }: { src: string }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
className={cx(s.coverImg, ready && s.coverImgOn)}
|
||||||
|
onLoad={() => setReady(true)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
export function AlbumCard({ album }: { album: AlbumSummary }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const player = usePlayer();
|
||||||
|
const play = (e: MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
void (async () => {
|
||||||
|
const full = unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id: album.id } } }));
|
||||||
|
if (full.tracks.some((tr) => tr.available)) player.playList(full.tracks, 0, { type: 'album', id: album.id, label: album.title });
|
||||||
|
})();
|
||||||
|
};
|
||||||
return (
|
return (
|
||||||
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
|
||||||
<Cover album={album} size="100%" className={s.cardCover} />
|
<span className={s.cardCoverWrap}>
|
||||||
|
<Cover album={album} size="100%" className={s.cardCover} />
|
||||||
|
<button type="button" className={s.cardPlay} aria-label={t('catalog.play')} onClick={play}>
|
||||||
|
<Icon src={playIcon} size={22} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<span className={s.cardTitle}>{album.title}</span>
|
<span className={s.cardTitle}>{album.title}</span>
|
||||||
<span className={s.cardMeta}>
|
<span className={s.cardMeta}>
|
||||||
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||||
@@ -114,6 +148,7 @@ export function TrackList({
|
|||||||
rowMeta,
|
rowMeta,
|
||||||
lead,
|
lead,
|
||||||
disliked,
|
disliked,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
showAlbum?: boolean;
|
showAlbum?: boolean;
|
||||||
@@ -125,86 +160,122 @@ export function TrackList({
|
|||||||
lead?: (t: Track, i: number) => string;
|
lead?: (t: Track, i: number) => string;
|
||||||
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||||
disliked?: boolean;
|
disliked?: boolean;
|
||||||
|
actions?: (track: Track, index: number) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const { track: current, playing } = usePlayerState();
|
const { track: current, playing } = usePlayerState();
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||||
|
const multiDisc = Boolean(numbered && discs.size > 1);
|
||||||
|
const virtual = tracks.length > 30 && !multiDisc;
|
||||||
|
const virtualizer = useVirtualizer({
|
||||||
|
count: virtual ? tracks.length : 0,
|
||||||
|
getScrollElement: () => listRef.current?.closest('main') ?? null,
|
||||||
|
estimateSize: () => 68,
|
||||||
|
overscan: 8,
|
||||||
|
});
|
||||||
|
|
||||||
|
const row = (tr: Track, i: number) => {
|
||||||
|
const isCurrent = current?.id === tr.id;
|
||||||
|
const play = () => tr.available && player.playList(tracks, i, context);
|
||||||
|
const liked = tr.reaction === 'like';
|
||||||
|
const meta = rowMeta?.(tr, i);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="listitem"
|
||||||
|
tabIndex={tr.available ? 0 : -1}
|
||||||
|
aria-current={isCurrent || undefined}
|
||||||
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current, virtual && s.trackStatic)}
|
||||||
|
style={{ '--stagger': `${Math.min(i, 12) * 40}ms` } as CSSProperties}
|
||||||
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
||||||
|
<span className={cx(s.num, disliked && s.dimmed)}>
|
||||||
|
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
||||||
|
{tr.available && (
|
||||||
|
<span className={s.playHint} aria-hidden>
|
||||||
|
<Icon src={playIcon} size={20} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||||
|
<span className={s.trackTitle}>{tr.title}</span>
|
||||||
|
<span className={s.trackArtists}>
|
||||||
|
<ArtistLinks artists={tr.artists} />
|
||||||
|
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{showAlbum && (
|
||||||
|
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||||
|
{tr.album.title}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||||
|
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||||
|
{disliked ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.like, s.liked)}
|
||||||
|
aria-label={t('player.undislike')}
|
||||||
|
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Icon src={undoIcon} size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cx(s.like, liked && s.liked)}
|
||||||
|
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||||
|
aria-pressed={liked}
|
||||||
|
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||||
|
>
|
||||||
|
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
let lastDisc: number | null = null;
|
let lastDisc: number | null = null;
|
||||||
return (
|
return (
|
||||||
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
|
<div
|
||||||
{tracks.map((tr, i) => {
|
ref={listRef}
|
||||||
const disc = tr.disc_no ?? 1;
|
className={s.tracks}
|
||||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
role="list"
|
||||||
lastDisc = disc;
|
aria-label={t('search.tracks')}
|
||||||
const isCurrent = current?.id === tr.id;
|
style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
|
||||||
const play = () => tr.available && player.playList(tracks, i, context);
|
>
|
||||||
const liked = tr.reaction === 'like';
|
{virtual
|
||||||
const meta = rowMeta?.(tr, i);
|
? virtualizer.getVirtualItems().map((vi) => (
|
||||||
return (
|
|
||||||
<Fragment key={`${tr.id}-${i}`}>
|
|
||||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
|
||||||
<div
|
<div
|
||||||
role="listitem"
|
key={vi.key}
|
||||||
tabIndex={tr.available ? 0 : -1}
|
data-index={vi.index}
|
||||||
aria-current={isCurrent || undefined}
|
ref={virtualizer.measureElement}
|
||||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
{row(tracks[vi.index]!, vi.index)}
|
||||||
<span className={cx(s.num, disliked && s.dimmed)}>
|
|
||||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
|
||||||
{tr.available && (
|
|
||||||
<span className={s.playHint} aria-hidden>
|
|
||||||
<Icon src={playIcon} size={20} />
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
|
||||||
<span className={s.trackTitle}>{tr.title}</span>
|
|
||||||
<span className={s.trackArtists}>
|
|
||||||
<ArtistLinks artists={tr.artists} />
|
|
||||||
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
{showAlbum && (
|
|
||||||
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
|
||||||
{tr.album.title}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<span className={s.end}>
|
|
||||||
{disliked ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cx(s.like, s.liked)}
|
|
||||||
aria-label={t('player.undislike')}
|
|
||||||
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
|
||||||
>
|
|
||||||
<Icon src={undoIcon} size={18} />
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cx(s.like, liked && s.liked)}
|
|
||||||
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
|
||||||
aria-pressed={liked}
|
|
||||||
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
|
||||||
>
|
|
||||||
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</Fragment>
|
))
|
||||||
);
|
: tracks.map((tr, i) => {
|
||||||
})}
|
const disc = tr.disc_no ?? 1;
|
||||||
|
const header = multiDisc && disc !== lastDisc;
|
||||||
|
lastDisc = disc;
|
||||||
|
return (
|
||||||
|
<Fragment key={`${tr.id}-${i}`}>
|
||||||
|
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||||
|
{row(tr, i)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { memo, useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||||
|
import { playlistAddIcon } from '../shared/ui/icons';
|
||||||
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
|
export type Playlist = components['schemas']['Playlist'];
|
||||||
|
export type PlaylistInput = components['schemas']['PlaylistInput'];
|
||||||
|
|
||||||
|
export function usePlaylists(enabled = true) {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({ queryKey: ['playlists'], enabled,
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaylist(id: string) {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({ queryKey: ['playlists', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||||
|
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
||||||
|
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [original] = useState(playlist);
|
||||||
|
const [name, setName] = useState(playlist?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||||
|
const save = useMutation({ mutationFn: async () => {
|
||||||
|
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
|
||||||
|
return original
|
||||||
|
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||||
|
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||||
|
}, onSuccess: p => {
|
||||||
|
cache.setQueryData(['playlists', p.id], p);
|
||||||
|
void cache.invalidateQueries({ queryKey: ['playlists'] });
|
||||||
|
onSaved(p);
|
||||||
|
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||||
|
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
|
||||||
|
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
|
||||||
|
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
|
||||||
|
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
|
||||||
|
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
|
||||||
|
</form>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||||
|
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const list = usePlaylists();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const add = useMutation({ mutationFn: async (id: string) => {
|
||||||
|
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||||
|
const body = playlistInput(p);
|
||||||
|
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
|
||||||
|
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||||
|
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
|
||||||
|
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
|
||||||
|
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
|
||||||
|
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
|
||||||
|
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||||
|
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
|
||||||
|
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>;
|
||||||
|
});
|
||||||
|
|
||||||
|
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
|
||||||
|
const close = useCallback(() => setSelectedIds(null), []);
|
||||||
|
return <>
|
||||||
|
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
|
||||||
|
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
|
||||||
|
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { useUploadsApi } from './queries';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const api = useUploadsApi();
|
||||||
|
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: () => api.updateBatch(batchId, {
|
||||||
|
...(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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { AuthSession } from '@pipu/core';
|
||||||
|
import { createI18n } from '@pipu/i18n';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { cleanup, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { I18nextProvider } from 'react-i18next';
|
||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { SessionProvider } from '../../app/session';
|
||||||
|
import { mobileFixtures } from '../../test/mobileFixtures';
|
||||||
|
import { EditDialog } from './EditDialog';
|
||||||
|
import type { UploadFile } from './common';
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
async function fixture(fail = false) {
|
||||||
|
const file = (mobileFixtures['/api/v1/admin/uploads'] as { items: UploadFile[] }).items[0]!;
|
||||||
|
const requests: unknown[] = [];
|
||||||
|
const fetchImpl: typeof fetch = async (input) => {
|
||||||
|
const req = input as Request;
|
||||||
|
if (req.method === 'PATCH') {
|
||||||
|
requests.push(await req.json());
|
||||||
|
return new Response(JSON.stringify(fail ? { status: 409, code: 'uploads.wrong_status' } : file), { status: fail ? 409 : 200 });
|
||||||
|
}
|
||||||
|
return new Response(JSON.stringify(mobileFixtures['/api/v1/admin/sources']));
|
||||||
|
};
|
||||||
|
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: fetchImpl, clientType: 'web' });
|
||||||
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||||
|
const onSaved = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const i18n = await createI18n('ru');
|
||||||
|
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={queryClient}>
|
||||||
|
<EditDialog file={file} onSaved={onSaved} onClose={onClose} />
|
||||||
|
</QueryClientProvider></SessionProvider></I18nextProvider>);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
await user.type(screen.getByLabelText('Исполнитель', { exact: true }), 'Кино');
|
||||||
|
await user.type(screen.getByLabelText('Название', { exact: true }), 'Перемен');
|
||||||
|
return { requests, onSaved, onClose, user };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('saves edited metadata with the target source and closes only after success', async () => {
|
||||||
|
const f = await fixture();
|
||||||
|
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(f.onSaved).toHaveBeenCalledOnce());
|
||||||
|
expect(f.onClose).toHaveBeenCalledOnce();
|
||||||
|
expect(f.requests).toEqual([expect.objectContaining({ metadata: expect.objectContaining({ artist: 'Кино', title: 'Перемен' }), target_source_id: expect.any(String) })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the editor and input when a concurrent review rejects the save', async () => {
|
||||||
|
const f = await fixture(true);
|
||||||
|
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await waitFor(() => expect(screen.getByRole('alert')).toBeVisible());
|
||||||
|
expect(screen.getByRole('dialog')).toBeVisible();
|
||||||
|
expect(screen.getByLabelText('Название', { exact: true })).toHaveValue('Перемен');
|
||||||
|
expect(f.onSaved).not.toHaveBeenCalled();
|
||||||
|
expect(f.onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useErrorText, useFieldError } from '../../shared/lib/errors';
|
||||||
|
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
|
||||||
|
import { useUploadsApi, useUploadSources } from './queries';
|
||||||
|
import type { UploadFile } from './common';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
|
||||||
|
type Meta = components['schemas']['UploadMeta'];
|
||||||
|
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
|
||||||
|
|
||||||
|
export function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const api = useUploadsApi();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const fieldError = useFieldError();
|
||||||
|
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
|
||||||
|
const [source, setSource] = useState(file.target_source_id ?? '');
|
||||||
|
const sources = useUploadSources();
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api.updateFile(file.id, { metadata: m, ...(source ? { target_source_id: source } : {}) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
onSaved();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
|
||||||
|
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
|
||||||
|
);
|
||||||
|
const num = (k: 'year' | 'track' | 'disc') => (
|
||||||
|
<Field
|
||||||
|
label={t(`uploads.meta_${k}`)}
|
||||||
|
value={m[k] === null ? '' : String(m[k])}
|
||||||
|
inputMode="numeric"
|
||||||
|
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('uploads.edit_title')}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
|
||||||
|
{t('uploads.save')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={s.sessions}>
|
||||||
|
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
|
||||||
|
<div className={u.grid}>
|
||||||
|
{text('artist')}
|
||||||
|
{text('albumartist')}
|
||||||
|
{text('album')}
|
||||||
|
{text('title')}
|
||||||
|
{text('genre')}
|
||||||
|
{num('year')}
|
||||||
|
{num('track')}
|
||||||
|
{num('disc')}
|
||||||
|
</div>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.target_source')}</span>
|
||||||
|
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
|
||||||
|
<option value="">{t('uploads.target_default')}</option>
|
||||||
|
{accepting.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className={s.note}>{t('uploads.tags_note')}</p>
|
||||||
|
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
|
||||||
|
import { editIcon, refreshIcon } from '../../shared/ui/icons';
|
||||||
|
import { needsFormatAttention } from './files';
|
||||||
|
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, cx, Icon } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
export function BatchCard({
|
||||||
|
files,
|
||||||
|
selectable,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onEdit,
|
||||||
|
onEditBatch,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
files: UploadFile[];
|
||||||
|
selectable: boolean;
|
||||||
|
selected: Set<string>;
|
||||||
|
onToggle: (ids: string[], on: boolean) => void;
|
||||||
|
onEdit: (f: UploadFile) => void;
|
||||||
|
onEditBatch: (batchId: string) => void;
|
||||||
|
onRetry: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const first = files[0]!;
|
||||||
|
const ids = files.map((f) => f.id);
|
||||||
|
const all = ids.every((id) => selected.has(id));
|
||||||
|
const size = files.reduce((n, f) => n + f.size_bytes, 0);
|
||||||
|
return (
|
||||||
|
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
|
||||||
|
<div className={u.batchHead}>
|
||||||
|
{selectable && (
|
||||||
|
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
|
||||||
|
)}
|
||||||
|
<b>{first.author.username}</b>
|
||||||
|
<span>{formatDateTime(first.created_at, 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>}
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
{selectable && (
|
||||||
|
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
|
||||||
|
{t('uploads.edit_batch')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{files.map((f) => (
|
||||||
|
<QueueRow
|
||||||
|
key={f.id}
|
||||||
|
file={f}
|
||||||
|
selectable={selectable}
|
||||||
|
checked={selected.has(f.id)}
|
||||||
|
onCheck={(on) => onToggle([f.id], on)}
|
||||||
|
onEdit={() => onEdit(f)}
|
||||||
|
onRetry={() => onRetry(f.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function QueueRow({
|
||||||
|
file: f,
|
||||||
|
selectable,
|
||||||
|
checked,
|
||||||
|
onCheck,
|
||||||
|
onEdit,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
file: UploadFile;
|
||||||
|
selectable: boolean;
|
||||||
|
checked: boolean;
|
||||||
|
onCheck: (on: boolean) => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
}) {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const reason = useReasonText()(f.status_reason);
|
||||||
|
const m = f.metadata;
|
||||||
|
const quality = [
|
||||||
|
f.format?.toUpperCase(),
|
||||||
|
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
|
||||||
|
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
|
||||||
|
f.bit_depth ? `${f.bit_depth} bit` : null,
|
||||||
|
f.duration_ms ? formatDuration(f.duration_ms) : null,
|
||||||
|
formatBytes(f.size_bytes, i18n.language),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ');
|
||||||
|
const editable = f.status === 'pending_review' || f.status === 'import_failed';
|
||||||
|
return (
|
||||||
|
<div className={u.queueRow}>
|
||||||
|
{selectable ? (
|
||||||
|
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<div className={u.meta}>
|
||||||
|
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
|
||||||
|
{m && (
|
||||||
|
<span className={u.sub}>
|
||||||
|
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
{f.metadata_edited && ` · ${t('uploads.edited')}`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={u.sub}>{quality}</span>
|
||||||
|
<span className={u.sub} title={f.client_rel_path ?? undefined}>
|
||||||
|
{f.client_rel_path ?? f.original_name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={u.meta}>
|
||||||
|
<div className={u.flags}>
|
||||||
|
<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.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
|
||||||
|
{f.matched_album_id ? (
|
||||||
|
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
|
||||||
|
) : (
|
||||||
|
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
|
||||||
|
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
|
||||||
|
{f.duplicate_level && f.status === 'duplicate' && (
|
||||||
|
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<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 && (
|
||||||
|
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
|
||||||
|
<Icon src={editIcon} size={18} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{f.status === 'import_failed' && (
|
||||||
|
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
|
||||||
|
{t('uploads.retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Dialog } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
export function RejectDialog({
|
||||||
|
count,
|
||||||
|
busy,
|
||||||
|
onClose,
|
||||||
|
onReject,
|
||||||
|
}: {
|
||||||
|
count: number;
|
||||||
|
busy: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onReject: (reason: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
title={t('uploads.reject_title', { count })}
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
|
||||||
|
{t('uploads.reject')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<label className={s.sessions}>
|
||||||
|
<span>{t('uploads.reject_reason')}</span>
|
||||||
|
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
</label>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { ApiError } from '@pipu/core';
|
||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { checkIcon, closeIcon, inboxIcon } from '../../shared/ui/icons';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { QuotaMeter, type UploadFile, type UploadStatus } from './common';
|
||||||
|
import { BatchCard } from './QueueRows';
|
||||||
|
import { BatchEditDialog } from './BatchEditDialog';
|
||||||
|
import { EditDialog } from './EditDialog';
|
||||||
|
import { RejectDialog } from './RejectDialog';
|
||||||
|
import { useUploadQueue, useUploadsApi } from './queries';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, EmptyState, FormError } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
const TABS = {
|
||||||
|
review: ['pending_review'],
|
||||||
|
failed: ['import_failed'],
|
||||||
|
progress: ['uploading', 'processing', 'approved', 'importing'],
|
||||||
|
imported: ['imported'],
|
||||||
|
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
|
||||||
|
} satisfies Record<string, UploadStatus[]>;
|
||||||
|
type Tab = keyof typeof TABS;
|
||||||
|
|
||||||
|
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
|
||||||
|
export function UploadQueue() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const api = useUploadsApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [tab, setTab] = useState<Tab>('review');
|
||||||
|
const [onlyPossible, setOnlyPossible] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [editing, setEditing] = useState<UploadFile | null>(null);
|
||||||
|
const [batchEdit, setBatchEdit] = useState<string | null>(null);
|
||||||
|
const [rejecting, setRejecting] = useState(false);
|
||||||
|
|
||||||
|
const list = useUploadQueue(TABS[tab], tab, onlyPossible);
|
||||||
|
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
|
||||||
|
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
|
||||||
|
|
||||||
|
const refresh = async () => {
|
||||||
|
setSelected(new Set());
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||||
|
};
|
||||||
|
const review = useMutation({
|
||||||
|
mutationFn: (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) => api.review(v),
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const retry = useMutation({
|
||||||
|
mutationFn: api.retry,
|
||||||
|
onSuccess: refresh,
|
||||||
|
});
|
||||||
|
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
|
||||||
|
|
||||||
|
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
|
||||||
|
const batches = useMemo(() => {
|
||||||
|
const m = new Map<string, UploadFile[]>();
|
||||||
|
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
|
||||||
|
return [...m.values()];
|
||||||
|
}, [items]);
|
||||||
|
const toggle = (ids: string[], on: boolean) =>
|
||||||
|
setSelected((cur) => {
|
||||||
|
const next = new Set(cur);
|
||||||
|
for (const id of ids) {
|
||||||
|
if (on) next.add(id);
|
||||||
|
else next.delete(id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
const selectable = tab === 'review' || tab === 'failed';
|
||||||
|
const ids = [...selected];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<div className={s.head}>
|
||||||
|
<div>
|
||||||
|
<h1>{t('uploads.queue_title')}</h1>
|
||||||
|
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
|
||||||
|
</div>
|
||||||
|
<QuotaMeter />
|
||||||
|
</div>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
|
||||||
|
{(Object.keys(TABS) as Tab[]).map((k) => (
|
||||||
|
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
|
||||||
|
{t(`uploads.tab_${k}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||||
|
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
|
||||||
|
{t('uploads.only_possible')}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
|
||||||
|
{failed.length > 0 && (
|
||||||
|
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
|
||||||
|
)}
|
||||||
|
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
|
||||||
|
|
||||||
|
{batches.map((files) => (
|
||||||
|
<BatchCard
|
||||||
|
key={files[0]!.batch_id}
|
||||||
|
files={files}
|
||||||
|
selectable={selectable}
|
||||||
|
selected={selected}
|
||||||
|
onToggle={toggle}
|
||||||
|
onEdit={setEditing}
|
||||||
|
onEditBatch={setBatchEdit}
|
||||||
|
onRetry={(id) => retry.mutate(id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{list.hasNextPage && (
|
||||||
|
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
|
||||||
|
{t('uploads.load_more')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{ids.length > 0 && (
|
||||||
|
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
|
||||||
|
<span>{t('uploads.selected', { count: ids.length })}</span>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="ghost" onClick={() => setSelected(new Set())}>
|
||||||
|
{t('common:form.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
|
||||||
|
{t('uploads.reject')}
|
||||||
|
</Button>
|
||||||
|
{tab === 'review' && (
|
||||||
|
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
|
||||||
|
{t('uploads.approve')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{batchEdit && (
|
||||||
|
<BatchEditDialog
|
||||||
|
batchId={batchEdit}
|
||||||
|
onClose={() => setBatchEdit(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
setBatchEdit(null);
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<EditDialog
|
||||||
|
file={editing}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
onSaved={() => {
|
||||||
|
review.reset();
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{rejecting && (
|
||||||
|
<RejectDialog
|
||||||
|
count={ids.length}
|
||||||
|
busy={review.isPending}
|
||||||
|
onClose={() => setRejecting(false)}
|
||||||
|
onReject={(reason) => {
|
||||||
|
setRejecting(false);
|
||||||
|
review.mutate({ decision: 'reject', ids, reason });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Problem из ответа массового решения → объект, понятный useErrorText. */
|
||||||
|
function problemError(p: components['schemas']['Problem']) {
|
||||||
|
return new ApiError(p.status, p.code, p.params ?? {});
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useErrorText } from '../../shared/lib/errors';
|
||||||
|
import { Button, Field, FormError } from '../../shared/ui/ui';
|
||||||
|
import { useUploadsApi, useUploadSources, useUploadSettings } from './queries';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
|
||||||
|
type Settings = components['schemas']['UploadSettings'];
|
||||||
|
const GB = 1024 ** 3;
|
||||||
|
|
||||||
|
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
|
||||||
|
export function UploadSettingsSection() {
|
||||||
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
|
const api = useUploadsApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const settings = useUploadSettings();
|
||||||
|
const sources = useUploadSources();
|
||||||
|
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings.data) setForm(toForm(settings.data));
|
||||||
|
}, [settings.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const f = form!;
|
||||||
|
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
|
||||||
|
return api.updateSettings({
|
||||||
|
queue_limit_bytes: gb(f.queue), max_file_bytes: gb(f.maxFile),
|
||||||
|
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
|
||||||
|
default_source_id: f.source || null, allow_quality_upgrade: f.upgrade,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
queryClient.setQueryData(['upload-settings'], data);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!form) return null;
|
||||||
|
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
|
||||||
|
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
|
||||||
|
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
|
||||||
|
return (
|
||||||
|
<section className={s.section}>
|
||||||
|
<h2>{t('uploads.settings_title')}</h2>
|
||||||
|
<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_per_user')}
|
||||||
|
value={form.perUser}
|
||||||
|
hint={t('uploads.set_per_user_hint')}
|
||||||
|
onChange={(v) => setForm({ ...form, perUser: v })}
|
||||||
|
inputMode="decimal"
|
||||||
|
/>
|
||||||
|
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
|
||||||
|
<label>
|
||||||
|
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
|
||||||
|
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
|
||||||
|
<option value="">{t('uploads.set_source_auto')}</option>
|
||||||
|
{sources.data
|
||||||
|
?.filter((x) => x.accepts_uploads)
|
||||||
|
.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
|
<p className={s.note}>{t('uploads.set_note')}</p>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<span style={{ flex: 1 }} />
|
||||||
|
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
|
||||||
|
{t('uploads.save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(st: Settings) {
|
||||||
|
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
|
||||||
|
return {
|
||||||
|
queue: gb(st.queue_limit_bytes),
|
||||||
|
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
|
||||||
|
maxFile: gb(st.max_file_bytes),
|
||||||
|
source: st.default_source_id ?? '',
|
||||||
|
upgrade: st.allow_quality_upgrade,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ApiClient, components, paths } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
|
||||||
|
type FilePatch = paths['/api/v1/admin/uploads/files/{id}']['patch']['requestBody']['content']['application/json'];
|
||||||
|
type BatchPatch = paths['/api/v1/admin/uploads/batches/{id}']['patch']['requestBody']['content']['application/json'];
|
||||||
|
type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBody']['content']['application/json'];
|
||||||
|
|
||||||
|
/** The feature owns transport mapping; views only provide form values. */
|
||||||
|
export function uploadsApi(client: ApiClient) {
|
||||||
|
return {
|
||||||
|
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
|
||||||
|
await client.GET('/api/v1/admin/uploads', {
|
||||||
|
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
review: async (v: { ids: string[]; decision: 'approve' | 'reject'; reason?: string }) => unwrap(
|
||||||
|
await client.POST('/api/v1/admin/uploads/review', { body: { file_ids: v.ids, decision: v.decision, reason: v.reason } }),
|
||||||
|
),
|
||||||
|
retry: async (id: string) => unwrap(await client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
|
||||||
|
updateFile: async (id: string, body: FilePatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/files/{id}', { params: { path: { id } }, body })),
|
||||||
|
updateBatch: async (id: string, body: BatchPatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/batches/{id}', { params: { path: { id } }, body })),
|
||||||
|
sources: async () => unwrap(await client.GET('/api/v1/admin/sources')).items,
|
||||||
|
settings: async () => unwrap(await client.GET('/api/v1/admin/settings/uploads')),
|
||||||
|
updateSettings: async (body: SettingsPatch) => unwrap(await client.PATCH('/api/v1/admin/settings/uploads', { body })),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { formatBytes } from '../../shared/lib/format';
|
||||||
|
import { cx } from '../../shared/ui/ui';
|
||||||
|
import s from '../../pages/page.module.css';
|
||||||
|
import u from '../../pages/uploads/uploads.module.css';
|
||||||
|
|
||||||
|
export type UploadStatus = components['schemas']['UploadStatus'];
|
||||||
|
export type UploadFile = components['schemas']['UploadFile'];
|
||||||
|
|
||||||
|
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
|
||||||
|
uploading: 'info',
|
||||||
|
processing: 'info',
|
||||||
|
pending_review: 'warn',
|
||||||
|
approved: 'info',
|
||||||
|
importing: 'info',
|
||||||
|
imported: 'ok',
|
||||||
|
import_failed: 'bad',
|
||||||
|
duplicate: 'muted',
|
||||||
|
invalid: 'bad',
|
||||||
|
rejected: 'bad',
|
||||||
|
withdrawn: 'muted',
|
||||||
|
expired: 'muted',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
|
||||||
|
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
|
||||||
|
|
||||||
|
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
|
||||||
|
const { t } = useTranslation('admin');
|
||||||
|
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadQuota() {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['upload-quota'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
|
||||||
|
export function QuotaMeter() {
|
||||||
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const quota = useUploadQuota();
|
||||||
|
const id = useId();
|
||||||
|
if (!quota.data) return null;
|
||||||
|
const q = quota.data;
|
||||||
|
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
||||||
|
const fmt = (n: number) => formatBytes(n, i18n.language);
|
||||||
|
return (
|
||||||
|
<div className={u.quota}>
|
||||||
|
<span className={u.quotaText} id={id}>
|
||||||
|
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
||||||
|
{q.per_user_limit_bytes !== null &&
|
||||||
|
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
||||||
|
</span>
|
||||||
|
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<span style={{ width: `${ratio * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
|
||||||
|
export function useReasonText(): (reason: string | null) => string | null {
|
||||||
|
const { t, i18n } = useTranslation(['admin', 'errors']);
|
||||||
|
const tr = t as unknown as (k: string) => string;
|
||||||
|
return (reason) => {
|
||||||
|
if (!reason) return null;
|
||||||
|
const code = reason.split(':')[0]!.trim();
|
||||||
|
const key = `errors:${code}`;
|
||||||
|
if (i18n.exists(key)) {
|
||||||
|
const rest = reason.slice(code.length + 1).trim();
|
||||||
|
return rest ? `${tr(key)}: ${rest}` : tr(key);
|
||||||
|
}
|
||||||
|
return reason;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
|
import { useSession } from '../../app/session';
|
||||||
|
import { uploadsApi } from './api';
|
||||||
|
import { MOVING, type UploadStatus } from './common';
|
||||||
|
|
||||||
|
export function useUploadsApi() { return uploadsApi(useSession().client); }
|
||||||
|
|
||||||
|
export function useUploadQueue(status: UploadStatus[], tab: string, onlyPossible: boolean) {
|
||||||
|
const api = useUploadsApi();
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ['admin-uploads', tab, onlyPossible],
|
||||||
|
initialPageParam: undefined as string | undefined,
|
||||||
|
queryFn: ({ pageParam }) => api.list(status, onlyPossible, pageParam),
|
||||||
|
getNextPageParam: (p) => p.next_cursor ?? undefined,
|
||||||
|
refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadSources() {
|
||||||
|
const api = useUploadsApi();
|
||||||
|
return useQuery({ queryKey: ['sources'], queryFn: api.sources });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadSettings() {
|
||||||
|
const api = useUploadsApi();
|
||||||
|
return useQuery({ queryKey: ['upload-settings'], queryFn: api.settings });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createAppRouter } from './app/router';
|
import { createAppRouter } from './app/router';
|
||||||
import { applyTheme } from './app/session';
|
import { applyTheme } from './app/session';
|
||||||
|
import { createDesktop, DesktopProvider } from './desktop';
|
||||||
|
|
||||||
const LOCALE_KEY = 'pipu.locale';
|
const LOCALE_KEY = 'pipu.locale';
|
||||||
|
|
||||||
@@ -48,6 +49,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) => {
|
||||||
@@ -59,15 +64,23 @@ i18n.on('languageChanged', (l) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
const desktop = await createDesktop();
|
||||||
|
const session = new AuthSession({
|
||||||
|
clientType: desktop ? 'desktop' : 'web',
|
||||||
|
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||||
|
baseUrl: desktop?.proxyBase,
|
||||||
|
refreshStore: desktop?.refreshStore,
|
||||||
|
});
|
||||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||||
await session.restore();
|
await session.restore();
|
||||||
const router = createAppRouter(session);
|
const router = createAppRouter(session, { desktop });
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<App session={session} router={router} />
|
<DesktopProvider value={desktop}>
|
||||||
|
<App session={session} router={router} />
|
||||||
|
</DesktopProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -4,11 +4,12 @@ import { Link } from '@tanstack/react-router';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { Cover, TrackList } from '../entities/catalog';
|
import { Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||||
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
import { Button } from '../shared/ui/ui';
|
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
@@ -22,29 +23,41 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (album.isPending) {
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (album.isError) return <NotFound error={album.error} />;
|
if (album.isError) return <NotFound error={album.error} />;
|
||||||
const a = album.data;
|
const a = album.data;
|
||||||
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
|
if (!a) return null;
|
||||||
const ctx = { type: 'album' as const, id, label: a?.title };
|
const colors = a.cover_colors.length === 3 ? a.cover_colors : null;
|
||||||
|
const ctx = { type: 'album' as const, id, label: a.title };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<header className={c.hero}>
|
<header className={c.hero}>
|
||||||
|
{a.cover_url && (
|
||||||
|
<span className={c.heroBg} aria-hidden>
|
||||||
|
<img src={coverSrc(a.cover_url, 300)} alt="" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
{colors && <span className={c.tint} style={{ background: `linear-gradient(180deg, ${colors[0]}66, transparent)` }} aria-hidden />}
|
||||||
<Cover album={a ?? { id }} size={200} />
|
<Cover album={a} size={200} className={c.heroCover} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('catalog.album')}</span>
|
<span className={c.kind}>{t('catalog.album')}</span>
|
||||||
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
|
<h1 className={c.heroTitle}>{a.title}</h1>
|
||||||
{a && (
|
<span className={c.heroMeta}>
|
||||||
<span className={c.heroMeta}>
|
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
||||||
<Link to="/artist/$id" params={{ id: a.artist.id }}>
|
{a.artist.name}
|
||||||
{a.artist.name}
|
</Link>
|
||||||
</Link>
|
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
||||||
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} · {formatDuration(a.duration_ms)}
|
</span>
|
||||||
</span>
|
{a.tracks.some((tr) => tr.available) && (
|
||||||
)}
|
|
||||||
{a && a.tracks.some((tr) => tr.available) && (
|
|
||||||
<div className={c.heroActions}>
|
<div className={c.heroActions}>
|
||||||
|
<AddToPlaylist trackIds={a.tracks.map((tr) => tr.id)} />
|
||||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||||
{t('catalog.play')}
|
{t('catalog.play')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -65,7 +78,7 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{a && <TrackList tracks={a.tracks} numbered context={ctx} />}
|
<TrackList tracks={a.tracks} numbered context={ctx} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { formatBytes } from '../shared/lib/format';
|
||||||
|
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
|
||||||
|
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||||
|
import { Icon } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||||
|
* Сборки — до 8 последних релизов Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||||
|
*/
|
||||||
|
export function AppDownloadPage() {
|
||||||
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
|
const session = useSession();
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const detected = useMemo(() => detectAppClient(), []);
|
||||||
|
const latest = useQuery({
|
||||||
|
queryKey: ['app-clients'],
|
||||||
|
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).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 data = latest.data;
|
||||||
|
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
|
||||||
|
const primary = detected ? files[detected] : undefined;
|
||||||
|
const others = otherAppClients(detected).filter((id) => files[id]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||||
|
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||||
|
<h1>{t('app.title')}</h1>
|
||||||
|
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||||
|
{latest.isPending ? (
|
||||||
|
<p role="status">{t('common:loading')}</p>
|
||||||
|
) : latest.isError ? (
|
||||||
|
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||||
|
) : data ? (
|
||||||
|
<>
|
||||||
|
{primary ? (
|
||||||
|
<>
|
||||||
|
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: primary.version ?? data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||||
|
<OlderVersions items={primary.older ?? []} />
|
||||||
|
</>
|
||||||
|
) : detected ? (
|
||||||
|
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||||
|
) : null}
|
||||||
|
{(primary || others.length > 0) && (
|
||||||
|
<ol className={s.steps}>
|
||||||
|
{detected && <li>{t(`app.step_install.${detected}`)}</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>
|
||||||
|
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{primary?.sha256 && (
|
||||||
|
<details className={s.details}>
|
||||||
|
<summary>{t('app.checksum')}</summary>
|
||||||
|
<code>{primary.sha256}</code>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div className={s.otherClients}>
|
||||||
|
<p className={s.otherTitle}>{t('app.other')}</p>
|
||||||
|
{others.map((id) => {
|
||||||
|
const file = files[id]!;
|
||||||
|
return (
|
||||||
|
<div key={id} className={s.otherBlock}>
|
||||||
|
<a className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||||
|
{t(`app.download.${id}`)}
|
||||||
|
<span>
|
||||||
|
{t('app.version', { version: file.version ?? data.version })} · {formatBytes(file.size, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<OlderVersions items={file.older ?? []} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||||
|
)}
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OlderVersions({ items }: { items: { version: string; filename?: string; size: number; download_url: string }[] }) {
|
||||||
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<details className={s.older}>
|
||||||
|
<summary>{t('app.older')}</summary>
|
||||||
|
{items.map((b) => (
|
||||||
|
<a key={b.version} className={s.downloadAlt} href={b.download_url} download={b.filename}>
|
||||||
|
{t('app.version', { version: b.version })}
|
||||||
|
<span>{formatBytes(b.size, i18n.language)}</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
return (
|
||||||
|
<a className={s.download} href={href} download>
|
||||||
|
<Icon src={downloadIcon} size={22} />
|
||||||
|
<span>
|
||||||
|
{t(`app.download.${client}`)}
|
||||||
|
<span className={s.downloadMeta}>{meta}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,10 +3,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { AlbumGrid, Cover, TrackList } from '../entities/catalog';
|
import { AlbumGrid, Cover, TrackList, coverSrc } from '../entities/catalog';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { playIcon, waveIcon } from '../shared/ui/icons';
|
import { playIcon, waveIcon } from '../shared/ui/icons';
|
||||||
import { Button } from '../shared/ui/ui';
|
import { Button, PageSkeleton } from '../shared/ui/ui';
|
||||||
import { NotFound } from './NotFound';
|
import { NotFound } from './NotFound';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
@@ -29,42 +29,51 @@ export function ArtistPage({ id }: { id: string }) {
|
|||||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (artist.isPending) {
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<PageSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (artist.isError) return <NotFound error={artist.error} />;
|
if (artist.isError) return <NotFound error={artist.error} />;
|
||||||
const a = artist.data;
|
const a = artist.data;
|
||||||
|
if (!a) return null;
|
||||||
const tracks = top.data ?? [];
|
const tracks = top.data ?? [];
|
||||||
const ctx = { type: 'artist' as const, id, label: a?.name };
|
const ctx = { type: 'artist' as const, id, label: a.name };
|
||||||
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
|
||||||
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.page}>
|
<div className={s.page}>
|
||||||
<header className={c.hero}>
|
<header className={c.hero}>
|
||||||
<Cover album={{ id }} size={168} round />
|
{a.image_url && (
|
||||||
|
<span className={c.heroBg} aria-hidden>
|
||||||
|
<img src={coverSrc(a.image_url, 300)} alt="" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Cover album={{ id, cover_url: a.image_url }} size={168} round className={c.heroCover} />
|
||||||
<div className={c.heroBody}>
|
<div className={c.heroBody}>
|
||||||
<span className={c.kind}>{t('catalog.artist')}</span>
|
<span className={c.kind}>{t('catalog.artist')}</span>
|
||||||
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
|
<h1 className={c.heroTitle}>{a.name}</h1>
|
||||||
{a && (
|
<span className={c.heroMeta}>
|
||||||
<span className={c.heroMeta}>
|
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
||||||
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
|
</span>
|
||||||
</span>
|
<div className={c.heroActions}>
|
||||||
)}
|
<Button
|
||||||
{a && (
|
variant="primary"
|
||||||
<div className={c.heroActions}>
|
className={c.pill}
|
||||||
<Button
|
icon={waveIcon}
|
||||||
variant="primary"
|
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
||||||
className={c.pill}
|
>
|
||||||
icon={waveIcon}
|
{t('catalog.artist_wave')}
|
||||||
onClick={() => void player.startWave(null, null, { type: 'artist', id, label: a.name }).catch(() => undefined)}
|
</Button>
|
||||||
>
|
{tracks.length > 0 && (
|
||||||
{t('catalog.artist_wave')}
|
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
||||||
|
{t('catalog.play')}
|
||||||
</Button>
|
</Button>
|
||||||
{tracks.length > 0 && (
|
)}
|
||||||
<Button className={c.pill} icon={playIcon} onClick={() => player.playList(tracks, 0, ctx)}>
|
</div>
|
||||||
{t('catalog.play')}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
|
|||||||
@@ -2,16 +2,21 @@ import type { ReactNode } from 'react';
|
|||||||
|
|
||||||
import { Logo } from '../shared/ui/ui';
|
import { Logo } from '../shared/ui/ui';
|
||||||
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
import { LanguageSwitch } from '../widgets/LanguageSwitch';
|
||||||
|
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||||
import s from './auth.module.css';
|
import s from './auth.module.css';
|
||||||
|
|
||||||
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
|
/** Экран без каркаса: декоративная волна и стеклянная карточка по центру. */
|
||||||
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
|
||||||
|
const theme = typeof document !== 'undefined' && document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
|
||||||
return (
|
return (
|
||||||
<div className={s.screen}>
|
<div className={s.screen}>
|
||||||
<div className={s.backdrop} aria-hidden>
|
<div className={s.backdrop} aria-hidden>
|
||||||
<span className={s.blob1} />
|
<span className={s.blob1} />
|
||||||
<span className={s.blob2} />
|
<span className={s.blob2} />
|
||||||
<span className={s.blob3} />
|
<span className={s.blob3} />
|
||||||
|
<div className={s.waveDecor}>
|
||||||
|
<WaveButton interactive={false} size={620} label="" theme={theme} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<header className={s.top}>
|
<header className={s.top}>
|
||||||
<Logo />
|
<Logo />
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useCollectionCounts } from '../entities/collection';
|
|||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
import { dislikeFillIcon, historyFillIcon, likeFillIcon, playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
import { Button, Dialog, FormError, Icon } from '../shared/ui/ui';
|
import { Button, Dialog, EmptyState, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
|
||||||
import c from './catalog.module.css';
|
import c from './catalog.module.css';
|
||||||
import s from './page.module.css';
|
import s from './page.module.css';
|
||||||
|
|
||||||
@@ -109,7 +109,12 @@ function History() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
|
||||||
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
|
{history.isPending && <PageSkeleton />}
|
||||||
|
{history.isSuccess && items.length === 0 && (
|
||||||
|
<EmptyState icon={historyFillIcon} action={<Link to="/">{t('catalog.back_home')}</Link>}>
|
||||||
|
{t('collection.empty_history')}
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
{groups.map((g) => (
|
{groups.map((g) => (
|
||||||
<section key={g.key} className={c.section}>
|
<section key={g.key} className={c.section}>
|
||||||
<h2 className={c.day}>{g.title}</h2>
|
<h2 className={c.day}>{g.title}</h2>
|
||||||
@@ -189,8 +194,18 @@ function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
{list.isPending && <PageSkeleton />}
|
||||||
{list.isSuccess && tracks.length === 0 && (
|
{list.isSuccess && tracks.length === 0 && (
|
||||||
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
|
<EmptyState
|
||||||
|
icon={reaction === 'like' ? likeFillIcon : dislikeFillIcon}
|
||||||
|
action={
|
||||||
|
<Link to="/search" search={{ q: '' }}>
|
||||||
|
{t('playlists.find_tracks')}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}
|
||||||
|
</EmptyState>
|
||||||
)}
|
)}
|
||||||
{tracks.length > 0 && (
|
{tracks.length > 0 && (
|
||||||
<TrackList
|
<TrackList
|
||||||
|
|||||||