Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29a8b18ad | ||
|
|
e33abc948c | ||
|
|
ab49e22357 | ||
|
|
75ae2092ad | ||
|
|
0dc0384c78 | ||
|
|
17b6dd2041 | ||
|
|
0a59059738 | ||
|
|
bfcf6e48b2 | ||
|
|
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 |
No files matched your search
@@ -4,14 +4,43 @@
|
||||
{
|
||||
"name": "design",
|
||||
"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
|
||||
},
|
||||
{
|
||||
"name": "web",
|
||||
"runtimeExecutable": "pnpm",
|
||||
"runtimeArgs": ["--filter", "@pipu/web", "dev"],
|
||||
"runtimeArgs": [
|
||||
"--filter",
|
||||
"@pipu/web",
|
||||
"dev"
|
||||
],
|
||||
"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
|
||||
|
||||
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
|
||||
|
||||
on:
|
||||
|
||||
@@ -10,13 +10,11 @@ on:
|
||||
paths:
|
||||
- 'services/api/**'
|
||||
- 'services/recommender/**'
|
||||
- 'deploy/backup.sh'
|
||||
- '.gitea/workflows/ci-db.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'services/api/**'
|
||||
- 'services/recommender/**'
|
||||
- 'deploy/backup.sh'
|
||||
- '.gitea/workflows/ci-db.yml'
|
||||
|
||||
concurrency:
|
||||
@@ -84,26 +82,3 @@ jobs:
|
||||
run: |
|
||||
uv sync --frozen
|
||||
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
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Python: recommender и analyzer (docs/ci-cd.md).
|
||||
# Python: recommender и analyzer.
|
||||
name: ci / recommender
|
||||
|
||||
on:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md).
|
||||
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза.
|
||||
name: ci / ts
|
||||
|
||||
on:
|
||||
@@ -51,3 +51,15 @@ jobs:
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
- 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
|
||||
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
|
||||
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
|
||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md).
|
||||
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
|
||||
- name: Сборка образов
|
||||
env: { DOCKER_BUILDKIT: "1" }
|
||||
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.
|
||||
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
|
||||
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
|
||||
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
|
||||
# После сборок job prune оставляет файлы у последних 8 релизов и последние 8 прогонов Actions.
|
||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||
#
|
||||
# Секреты (Settings → Actions → Secrets):
|
||||
@@ -53,7 +55,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||
# переживают 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)
|
||||
defaults:
|
||||
run:
|
||||
@@ -132,9 +134,10 @@ jobs:
|
||||
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||
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
|
||||
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
||||
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 занят или повреждён — останавливаем демоны и собираем заново"
|
||||
./gradlew --stop || true
|
||||
find ~/.gradle -name "*.lock" -delete || true
|
||||
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||
else
|
||||
@@ -143,6 +146,11 @@ jobs:
|
||||
fi
|
||||
./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)
|
||||
if: failure()
|
||||
run: |
|
||||
@@ -191,3 +199,142 @@ jobs:
|
||||
- name: Статус в релизе — сборка отменена
|
||||
if: 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
|
||||
@@ -20,6 +20,8 @@ __pycache__/
|
||||
.ruff_cache/
|
||||
|
||||
# env / secrets
|
||||
deploy/config.local
|
||||
deploy/.management.lock
|
||||
.env
|
||||
*.pem
|
||||
*.keystore
|
||||
@@ -27,6 +29,8 @@ __pycache__/
|
||||
|
||||
# generated test audio
|
||||
tests/fixtures/music/out/
|
||||
apps/web/test-results/
|
||||
apps/web/playwright-report/
|
||||
|
||||
# os / editors
|
||||
.DS_Store
|
||||
@@ -35,3 +39,10 @@ Thumbs.db
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.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 — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
|
||||
- Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в CI/CD; не запускай все проверки проекта перед каждой сдачей без необходимости. Расширяй локальную проверку при изменении общих контрактов, межмодульных зависимостей или при обнаружении ошибок. Команды запускаются из корня, если не сказано иначе.
|
||||
- Справочник полных проверок (выбирай локально только относящиеся к изменению): `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
|
||||
|
||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
||||
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||
|
||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||
- Документация для разработки: [docs/README.md](docs/README.md)
|
||||
- План: [docs/roadmap.md](docs/roadmap.md)
|
||||
- Документация: [docs/README.md](docs/README.md)
|
||||
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.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/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
||||
| `apps/web` | Веб-клиент (React + Vite) |
|
||||
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
|
||||
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
|
||||
|
||||
```bash
|
||||
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
|
||||
pnpm check:boundaries
|
||||
```
|
||||
|
||||
```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 докладывается в него.
|
||||
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`.
|
||||
@@ -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,45 @@
|
||||
[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"
|
||||
sha2 = "0.10"
|
||||
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,160 @@
|
||||
mod media;
|
||||
mod proxy;
|
||||
mod store;
|
||||
mod update;
|
||||
|
||||
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())
|
||||
.manage(update::Updater::default())
|
||||
.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,
|
||||
update::update_check, update::update_install
|
||||
])
|
||||
.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,174 @@
|
||||
//! 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, x-pipu-transport-error"));
|
||||
}
|
||||
|
||||
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(_) => {
|
||||
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
|
||||
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
|
||||
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,152 @@
|
||||
//! Server-proxied releases: never accept installer paths or commands from the UI.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{io::Write, path::Path, time::Duration};
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone, Deserialize, Serialize)]
|
||||
pub struct Build {
|
||||
client: String,
|
||||
version: String,
|
||||
filename: String,
|
||||
size: u64,
|
||||
sha256: Option<String>,
|
||||
download_url: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
|
||||
#[derive(Clone, Serialize)]
|
||||
pub struct Release { build: Build, notes: String }
|
||||
#[derive(Default)]
|
||||
pub struct Updater { release: Mutex<Option<(String, Release)>> }
|
||||
|
||||
fn client() -> &'static str {
|
||||
if cfg!(target_os = "windows") { "windows" }
|
||||
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
|
||||
}
|
||||
|
||||
fn version_code(raw: &str) -> Option<u64> {
|
||||
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
|
||||
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
|
||||
let rc: u64 = rc.parse().ok()?;
|
||||
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
|
||||
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
|
||||
}
|
||||
|
||||
fn http() -> Result<reqwest::Client, String> {
|
||||
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())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
|
||||
let mut saved = updater.release.lock().await;
|
||||
*saved = None;
|
||||
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
|
||||
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
|
||||
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
|
||||
.json().await.map_err(|e| e.to_string())?;
|
||||
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
|
||||
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
|
||||
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
|
||||
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
|
||||
let release = Release { build, notes };
|
||||
validate_build(&release.build)?;
|
||||
*saved = Some((server, release.clone()));
|
||||
Ok(Some(release))
|
||||
}
|
||||
|
||||
fn validate_build(build: &Build) -> Result<(), String> {
|
||||
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
|
||||
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
|
||||
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
|
||||
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
|
||||
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
|
||||
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
|
||||
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
|
||||
return Err("Invalid installer URL".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
|
||||
let saved = updater.release.lock().await;
|
||||
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
|
||||
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
|
||||
let build = &release.build;
|
||||
validate_build(build)?;
|
||||
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let path = dir.join(&build.filename);
|
||||
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
|
||||
let result = async {
|
||||
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
|
||||
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
|
||||
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
|
||||
let mut hash = Sha256::new();
|
||||
let mut bytes = 0u64;
|
||||
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
|
||||
bytes += chunk.len() as u64;
|
||||
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
|
||||
hash.update(&chunk);
|
||||
file.write_all(&chunk).map_err(|e| e.to_string())?;
|
||||
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
|
||||
}
|
||||
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
|
||||
file.sync_all().map_err(|e| e.to_string())?;
|
||||
drop(file);
|
||||
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
|
||||
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
|
||||
launch(&path)?;
|
||||
Ok(())
|
||||
}.await;
|
||||
let _ = std::fs::remove_file(&part);
|
||||
result
|
||||
}
|
||||
|
||||
fn launch(path: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
|
||||
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
|
||||
} else { std::process::Command::new(path) };
|
||||
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
|
||||
#[cfg(target_os = "macos")]
|
||||
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn release_order() {
|
||||
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
|
||||
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
|
||||
assert_eq!(version_code("1.7.1"), Some(1070199));
|
||||
assert_eq!(version_code("1.7.100"), None);
|
||||
assert_eq!(version_code("1.7.1;cmd"), None);
|
||||
}
|
||||
#[test]
|
||||
fn installer_is_restricted_to_verified_server_asset() {
|
||||
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
|
||||
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
|
||||
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
|
||||
assert!(validate_build(&build).is_ok());
|
||||
build.download_url = "https://other.example/installer.exe".into();
|
||||
assert!(validate_build(&build).is_err());
|
||||
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
|
||||
build.filename = "../installer.exe".into();
|
||||
assert!(validate_build(&build).is_err());
|
||||
build.filename = format!("pipu.{extension}");
|
||||
build.sha256 = None;
|
||||
assert!(validate_build(&build).is_err());
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
slug: 'pipu-music',
|
||||
scheme: 'pipu',
|
||||
icon: './assets/icon.png',
|
||||
version,
|
||||
orientation: 'portrait',
|
||||
userInterfaceStyle: 'automatic',
|
||||
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
newArchEnabled: false,
|
||||
backgroundColor: '#05070D',
|
||||
android: {
|
||||
adaptiveIcon: {
|
||||
foregroundImage: './assets/adaptive-icon.png',
|
||||
monochromeImage: './assets/monochrome-icon.png',
|
||||
backgroundColor: '#05070D',
|
||||
},
|
||||
package: 'app.pipu.music',
|
||||
versionCode,
|
||||
edgeToEdgeEnabled: true,
|
||||
@@ -30,7 +36,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
|
||||
'expo-secure-store',
|
||||
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
|
||||
// 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 },
|
||||
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",
|
||||
"android": "expo run:android",
|
||||
"prebuild": "expo prebuild --platform android --clean",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
@@ -42,7 +43,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.20.5",
|
||||
"@types/react": "~19.0.10"
|
||||
"@types/react": "~19.0.10",
|
||||
"vitest": "^3.2.7"
|
||||
},
|
||||
"expo": {
|
||||
"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 { useRouter } from 'expo-router';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
|
||||
const session = useSession();
|
||||
const current = usePlayerState().track;
|
||||
const [tab, setTab] = useState<Tab>('likes');
|
||||
const router = useRouter();
|
||||
|
||||
const list = useInfiniteQuery({
|
||||
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 tabs = (
|
||||
<View style={{ gap: 12 }}>
|
||||
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
|
||||
<Segmented<Tab>
|
||||
value={tab}
|
||||
onChange={setTab}
|
||||
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
|
||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { player } from '../../player/controller';
|
||||
import { usePlayerState } from '../../player/hooks';
|
||||
import { useColors } from '../../theme';
|
||||
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
|
||||
import { OnboardingGate } from '../../ui/Onboarding';
|
||||
import { WaveOrb } from '../../ui/wave/WaveOrb';
|
||||
|
||||
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
|
||||
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
|
||||
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
|
||||
*/
|
||||
export default function HomeScreen() {
|
||||
return <OnboardingGate><WaveHome /></OnboardingGate>;
|
||||
}
|
||||
|
||||
function WaveHome() {
|
||||
const colors = useColors();
|
||||
const { t } = useTranslation();
|
||||
const tr = t as unknown as (k: string) => string;
|
||||
|
||||
@@ -15,6 +15,7 @@ import { player } from '../../player/controller';
|
||||
import { radius, useColors } from '../../theme';
|
||||
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
|
||||
import { useDownloads } from '../../ui/downloads';
|
||||
import { RecommendationSettings } from '../../ui/Onboarding';
|
||||
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
|
||||
import { useUpdateState } from '../../ui/UpdateDialog';
|
||||
|
||||
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
|
||||
@{user.username} · {t(`roles.${user.role}`)}
|
||||
</Muted>
|
||||
|
||||
<RecommendationSettings />
|
||||
|
||||
<Section>
|
||||
<Row label={t('language.label')}>
|
||||
<Segmented<Locale>
|
||||
@@ -116,6 +119,16 @@ export default function ProfileScreen() {
|
||||
onChange={(v) => void setPrefs({ mobile_player: v })}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createI18n, pickLocale, SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
|
||||
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 type { i18n as I18n } from 'i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
@@ -54,8 +54,6 @@ const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly stri
|
||||
function Gate() {
|
||||
const { server, session, ready } = useSessionCtx();
|
||||
const auth = useAuthState();
|
||||
const segments = useSegments();
|
||||
const router = useRouter();
|
||||
const { i18n } = useTranslation();
|
||||
const user = auth.status === 'signed_in' ? auth.user : null;
|
||||
const system = useColorScheme();
|
||||
@@ -79,17 +77,6 @@ function Gate() {
|
||||
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 />;
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
@@ -101,9 +88,30 @@ function Gate() {
|
||||
|
||||
function Screens() {
|
||||
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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useState } from 'react';
|
||||
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 { usePlayerState } from '../player/hooks';
|
||||
import { useColors } from '../theme';
|
||||
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
|
||||
const router = useRouter();
|
||||
const st = usePlayerState();
|
||||
const prefs = useUser().ui_prefs;
|
||||
const { server } = useSessionCtx();
|
||||
const [sharing, setSharing] = useState(false);
|
||||
const [barWidth, setBarWidth] = useState(1);
|
||||
const track = st.track;
|
||||
|
||||
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
|
||||
const duration = st.durationMs || track.duration_ms;
|
||||
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 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 (
|
||||
<Screen>
|
||||
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
|
||||
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||
</Pressable>
|
||||
<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 style={{ alignItems: 'center' }}>
|
||||
@@ -72,6 +91,12 @@ export default function PlayerScreen() {
|
||||
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
|
||||
{track.artists.map((a) => a.name).join(', ')}
|
||||
</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 style={{ gap: 6 }}>
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { playlistShareUrl, 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, Share, View } from 'react-native';
|
||||
import { useSession, useSessionCtx, 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 { server } = useSessionCtx();
|
||||
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 sharing = useMutation({ mutationFn: async () => {
|
||||
if (!server || !p) throw new Error('Server unavailable');
|
||||
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
|
||||
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
|
||||
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
|
||||
} });
|
||||
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
|
||||
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
|
||||
|
||||
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 || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : 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.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
|
||||
<Muted>{t('playlists.share_hint')}</Muted>
|
||||
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
|
||||
<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 { sha256 } from 'js-sha256';
|
||||
|
||||
import { releaseNeed } from './releaseNeed';
|
||||
|
||||
export type AndroidRelease = components['schemas']['AndroidRelease'];
|
||||
|
||||
/** Состояние самообновления (docs/mobile.md#самообновление). */
|
||||
@@ -96,11 +98,12 @@ class Updater {
|
||||
const release = unwrap(await session.client.GET('/api/v1/app/android/latest'));
|
||||
await SecureStore.setItemAsync(CHECKED_KEY, String(Date.now()));
|
||||
const installed = installedVersionCode();
|
||||
if (release.version_code > installed) {
|
||||
const need = releaseNeed(installed, release.version_code, release.min_supported);
|
||||
if (need !== 'current') {
|
||||
this.set({
|
||||
status: 'available',
|
||||
release,
|
||||
required: installed < release.min_supported,
|
||||
required: need === 'required',
|
||||
dismissed: false,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -272,7 +272,7 @@ class Downloads {
|
||||
if (track.album.cover_url) {
|
||||
const cover = this.cover(track.album.id);
|
||||
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));
|
||||
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();
|
||||
@@ -1,7 +1,13 @@
|
||||
import NetInfo from '@react-native-community/netinfo';
|
||||
import {
|
||||
EventBuffer,
|
||||
WaveCoordinator,
|
||||
EARLY_SKIP_MS,
|
||||
type WaveInfo,
|
||||
type WaveOutcome,
|
||||
type WaveFeedback,
|
||||
PlayAttempt,
|
||||
normalizeGain,
|
||||
unwrap,
|
||||
type AuthSession,
|
||||
type PlayContext,
|
||||
@@ -16,15 +22,14 @@ import TrackPlayer, {
|
||||
} from 'react-native-track-player';
|
||||
|
||||
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 { offlineWave } from './offlineWave';
|
||||
import { eventId } from './eventId';
|
||||
|
||||
export interface WaveInfo {
|
||||
sessionId: string;
|
||||
activity: string | null;
|
||||
mood: string | null;
|
||||
fallback: boolean;
|
||||
}
|
||||
export { offlineWave };
|
||||
|
||||
export type { WaveInfo } from '@pipu/core';
|
||||
|
||||
export interface PlayerState {
|
||||
ready: boolean;
|
||||
@@ -39,21 +44,8 @@ export interface PlayerState {
|
||||
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;
|
||||
|
||||
/** Офлайн-волна: скачанные треки без дизлайков, лайкнутые чаще оказываются ближе к началу. */
|
||||
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));
|
||||
|
||||
/** Ссылки API относительные — для приложения нужен полный адрес сервера. */
|
||||
@@ -88,8 +80,13 @@ class Controller {
|
||||
private attempt: PlayAttempt | null = null;
|
||||
private lastPositionMs = 0;
|
||||
private lastSave = 0;
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = false;
|
||||
private readonly wavePolicy = new WaveCoordinator({
|
||||
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 setupDone: Promise<void> | null = null;
|
||||
private listeners = new Set<() => void>();
|
||||
@@ -118,7 +115,7 @@ class Controller {
|
||||
this.server = server;
|
||||
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||
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();
|
||||
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||
if (st.isConnected) void this.persistent?.drain();
|
||||
@@ -159,6 +156,10 @@ class Controller {
|
||||
this.lastPositionMs = ms;
|
||||
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();
|
||||
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 }) => {
|
||||
this.finishAttempt(lastPosition);
|
||||
@@ -167,6 +168,7 @@ class Controller {
|
||||
if (!track) return;
|
||||
this.attempt = new PlayAttempt(track, 0);
|
||||
this.update({ track, index, positionMs: 0, durationMs: track.duration_ms });
|
||||
void this.applyVolume(track, 1);
|
||||
this.events?.push({
|
||||
type: 'play_start', track_id: track.id, position_ms: 0, context_type: this.state.context?.type,
|
||||
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[]> {
|
||||
const q = await quality();
|
||||
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
|
||||
return tracks.map((t) => ({
|
||||
id: t.id,
|
||||
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||
title: t.title,
|
||||
artist: t.artists.map((a) => a.name).join(', '),
|
||||
album: t.album.title,
|
||||
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined),
|
||||
duration: t.duration_ms / 1000,
|
||||
}));
|
||||
return tracks.map((t) => {
|
||||
const cover = absolute(this.server, t.album.cover_url);
|
||||
return {
|
||||
id: t.id,
|
||||
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
|
||||
title: t.title,
|
||||
artist: t.artists.map((a) => a.name).join(', '),
|
||||
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) {
|
||||
@@ -293,18 +309,22 @@ class Controller {
|
||||
if (!this.session) return;
|
||||
await this.setup();
|
||||
this.leaveWave();
|
||||
const version = this.wavePolicy.version;
|
||||
let res;
|
||||
try {
|
||||
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
|
||||
} catch (e) {
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
// Нет связи с сервером — офлайн-волна из скачанного.
|
||||
const local = offlineWave(downloads.downloaded());
|
||||
if (!local.length) throw e;
|
||||
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
|
||||
}
|
||||
if (!this.wavePolicy.isCurrent(version)) return;
|
||||
const tracks = res.tracks.filter(playable);
|
||||
if (!tracks.length) return;
|
||||
this.finishAttempt(this.lastPositionMs / 1000);
|
||||
this.wavePolicy.activate(res.session_id);
|
||||
this.update({
|
||||
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 },
|
||||
@@ -317,13 +337,16 @@ class Controller {
|
||||
async setWaveContext(activity: string | null, mood: string | null) {
|
||||
const wave = this.state.wave;
|
||||
if (!wave || !this.session) return this.startWave(activity, mood);
|
||||
const version = this.wavePolicy.changeContext(wave.sessionId);
|
||||
const res = unwrap(
|
||||
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
|
||||
params: { path: { id: wave.sessionId } },
|
||||
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 } });
|
||||
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||
if (res.tracks.some(playable)) {
|
||||
@@ -336,8 +359,8 @@ class Controller {
|
||||
|
||||
private leaveWave() {
|
||||
const wave = this.state.wave;
|
||||
this.wavePolicy.activate(null);
|
||||
if (!wave) return;
|
||||
this.waveFeedback = [];
|
||||
this.update({ wave: null });
|
||||
void this.session?.client
|
||||
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
|
||||
@@ -345,46 +368,36 @@ class Controller {
|
||||
}
|
||||
|
||||
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);
|
||||
await TrackPlayer.removeUpcomingTracks();
|
||||
const add = tracks.filter(playable);
|
||||
await TrackPlayer.add(await this.toRntp(add));
|
||||
this.update({ queue: [...keep, ...add] });
|
||||
const native = await this.toRntp(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) {
|
||||
const wave = this.state.wave;
|
||||
if (!wave || !this.session || this.waveLoading) return;
|
||||
if (!this.session) return;
|
||||
const remaining = this.state.queue.length - this.state.index - 1;
|
||||
if (!refresh && remaining > WAVE_REFILL_AT) return;
|
||||
this.waveLoading = true;
|
||||
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);
|
||||
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
|
||||
if (refresh) await this.replaceUpcoming(res.tracks, current);
|
||||
else {
|
||||
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({ wave: { ...wave, fallback: res.fallback } });
|
||||
} catch {
|
||||
this.waveFeedback = [...feedback, ...this.waveFeedback];
|
||||
} finally {
|
||||
this.waveLoading = false;
|
||||
}
|
||||
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
|
||||
});
|
||||
}
|
||||
|
||||
// --- состояние ---
|
||||
@@ -428,6 +441,7 @@ class Controller {
|
||||
}
|
||||
|
||||
async reset() {
|
||||
this.wavePolicy.activate(null);
|
||||
this.attempt = null;
|
||||
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
|
||||
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 { useEffect, useSyncExternalStore } from 'react';
|
||||
import { releaseNotesText } from '@pipu/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
|
||||
|
||||
@@ -10,7 +11,6 @@ import { Button } from './kit';
|
||||
export function useUpdateState() {
|
||||
return useSyncExternalStore(updater.subscribe, () => updater.state);
|
||||
}
|
||||
|
||||
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
|
||||
export function UpdateDialog() {
|
||||
const colors = useColors();
|
||||
@@ -32,6 +32,7 @@ export function UpdateDialog() {
|
||||
|
||||
const { release, required } = st;
|
||||
const busy = st.status === 'downloading' || st.status === 'verifying';
|
||||
const notes = releaseNotesText(release.notes_md);
|
||||
const action = () => void updater.downloadAndInstall(session, server ?? '');
|
||||
|
||||
return (
|
||||
@@ -57,9 +58,9 @@ export function UpdateDialog() {
|
||||
{t('mobile.update_title', { version: release.version })}
|
||||
</Text>
|
||||
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
|
||||
{release.notes_md.trim() !== '' && (
|
||||
<ScrollView style={{ maxHeight: 240 }}>
|
||||
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
|
||||
{notes !== '' && (
|
||||
<ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
|
||||
<Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
|
||||
</ScrollView>
|
||||
)}
|
||||
<Text style={{ color: colors.text3, fontSize: 12 }}>
|
||||
@@ -115,13 +116,3 @@ export function UpdateDialog() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
|
||||
function plainNotes(md: string): string {
|
||||
return md
|
||||
.replace(/^#{1,6}\s*/gm, '')
|
||||
.replace(/\*\*(.+?)\*\*/g, '$1')
|
||||
.replace(/^\s*[-*]\s+/gm, '• ')
|
||||
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
|
||||
.trim();
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { ApiError, type Track } from '@pipu/core';
|
||||
import type { ComponentProps, ReactNode } from 'react';
|
||||
import { useState, useSyncExternalStore } from 'react';
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { useSessionCtx } from '../lib/session';
|
||||
import { downloads } from '../offline/downloads';
|
||||
import { downloads, sizedCover } from '../offline/downloads';
|
||||
import { absolute } from '../player/controller';
|
||||
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;
|
||||
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
|
||||
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;
|
||||
return (
|
||||
<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 { t } = useTranslation();
|
||||
const liked = track.reaction === 'like';
|
||||
const router = useRouter();
|
||||
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||
return (
|
||||
<Pressable
|
||||
@@ -233,6 +235,10 @@ export function TrackRow({
|
||||
</Pressable>
|
||||
)}
|
||||
{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} />}
|
||||
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
|
||||
import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
|
||||
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
|
||||
import { useFocusEffect } from 'expo-router';
|
||||
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { AppState, Pressable, View } from 'react-native';
|
||||
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
|
||||
|
||||
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
|
||||
}, []),
|
||||
);
|
||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
|
||||
const palette = useMemo(() => wavePalette(colors), [colors]);
|
||||
|
||||
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
|
||||
const state = useSharedValue<WaveState>(initialWaveState(size));
|
||||
|
||||
@@ -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: слой с установкой зависимостей пересобирается лишь при их изменении,
|
||||
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
|
||||
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/mobile/package.json apps/mobile/
|
||||
COPY packages/api-client/package.json packages/api-client/
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('desktop files survive restart, isolate owners and clear only recent cache', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
const saved = await page.evaluate(async () => {
|
||||
const source = '/src/features/desktop/offline.ts';
|
||||
const { DesktopOffline } = await import(source);
|
||||
const track = { id: 'cached-track', title: 'Offline song', available: true, stream_url: 'data:audio/wav;base64,UklGRg==', duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', cover_url: null }, genres: [], reaction: null };
|
||||
const session = { client: { POST: async () => ({ data: { items: [track] }, response: new Response('{}') }) } };
|
||||
const offline = new DesktopOffline('test-server/alice', session);
|
||||
await offline.ready;
|
||||
await offline.save(track, true);
|
||||
await offline.save({ ...track, id: 'recent-track' });
|
||||
const tracks = offline.tracks();
|
||||
const result = { count: tracks.length, blob: tracks[0].stream_url.startsWith('blob:'), pinned: offline.pinned(track.id) };
|
||||
offline.dispose();
|
||||
return result;
|
||||
});
|
||||
expect(saved).toEqual({ count: 2, blob: true, pinned: true });
|
||||
await page.reload();
|
||||
const restored = await page.evaluate(async () => {
|
||||
const source = '/src/features/desktop/offline.ts';
|
||||
const { DesktopOffline } = await import(source);
|
||||
const session = { client: { POST: async () => { throw new TypeError('offline'); } } };
|
||||
const alice = new DesktopOffline('test-server/alice', session);
|
||||
const bob = new DesktopOffline('test-server/bob', session);
|
||||
await Promise.all([alice.ready, bob.ready]);
|
||||
const before = alice.tracks().length;
|
||||
const body = await (await fetch(alice.tracks()[0].stream_url)).blob();
|
||||
await alice.clearRecent();
|
||||
const result = { before, bytes: body.size, after: alice.tracks().map((t: { id: string }) => t.id), other: bob.tracks().length };
|
||||
alice.dispose(); bob.dispose();
|
||||
return result;
|
||||
});
|
||||
expect(restored).toEqual({ before: 2, bytes: 4, after: ['cached-track'], other: 0 });
|
||||
});
|
||||
|
||||
test('desktop update dialog hides build reports and launches installer', async ({ page }) => {
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem('pipu.locale', 'ru');
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
|
||||
core: { invoke: async (command: string) => {
|
||||
if (command === 'bootstrap') return { proxyBase: location.origin, server: 'https://music.example' };
|
||||
if (command === 'update_check') return { build: { version: '1.8.0', size: 1048576 }, notes: '<!-- android-status -->\n> APK SHA-256 abc\n<!-- /android-status -->\n## Изменения\n- **Офлайн-музыка**' };
|
||||
if (command === 'update_install') document.documentElement.dataset.installer = 'opened';
|
||||
return null;
|
||||
} },
|
||||
event: { listen: async () => () => undefined },
|
||||
};
|
||||
});
|
||||
await page.goto('/login');
|
||||
await page.getByLabel('Логин').fill('alice');
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toContainText('Доступна версия 1.8.0');
|
||||
await expect(dialog).toContainText('• Офлайн-музыка');
|
||||
await expect(dialog).not.toContainText('android-status');
|
||||
await expect(dialog).not.toContainText('SHA-256');
|
||||
await dialog.getByRole('button', { name: 'Скачать и установить' }).click();
|
||||
await expect(dialog).toContainText('Установщик открыт');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-installer', 'opened');
|
||||
});
|
||||
@@ -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,52 @@
|
||||
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.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
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 page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
|
||||
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).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') });
|
||||
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
|
||||
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
|
||||
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function login(page: Page, username = 'mobile-admin', target = '/') {
|
||||
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
|
||||
await page.getByLabel('Логин').fill(username);
|
||||
await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`);
|
||||
await page.getByRole('button', { name: 'Войти' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
|
||||
}
|
||||
|
||||
test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/updates');
|
||||
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true');
|
||||
await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click();
|
||||
await page.getByRole('button', { name: 'Добавить источник' }).click();
|
||||
await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа');
|
||||
await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git');
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' });
|
||||
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
|
||||
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
|
||||
await page.getByRole('button', { name: 'Установить обновление', exact: true }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toContainText('Тестовая репа');
|
||||
await expect(dialog).toContainText('v1.10.0');
|
||||
await dialog.getByRole('button', { name: 'Установить обновление' }).click();
|
||||
await expect(page.getByRole('status').filter({ hasText: 'Выполняется' })).toBeVisible();
|
||||
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
|
||||
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeDisabled();
|
||||
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => {
|
||||
await login(page, 'mobile-admin', '/admin/config');
|
||||
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
|
||||
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
|
||||
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
|
||||
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
|
||||
await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).toBeDisabled();
|
||||
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
|
||||
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
|
||||
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
|
||||
await page.screenshot({ path: 'test-results/server-config.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click();
|
||||
await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click();
|
||||
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
|
||||
});
|
||||
|
||||
test('update delegate can update but cannot open configuration', async ({ page }) => {
|
||||
await login(page, 'update-manager', '/admin/updates');
|
||||
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
|
||||
await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); });
|
||||
await expect(page).toHaveURL('/');
|
||||
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('ordinary user is redirected away from server management', async ({ page }) => {
|
||||
await login(page, 'alice', '/admin/updates');
|
||||
await expect(page).toHaveURL('/');
|
||||
});
|
||||
@@ -2,9 +2,11 @@
|
||||
<html lang="ru" data-theme="dark">
|
||||
<head>
|
||||
<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" />
|
||||
<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>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/jetbrains-mono": "^5.2.6",
|
||||
@@ -21,13 +22,16 @@
|
||||
"@pipu/i18n": "workspace:*",
|
||||
"@tanstack/react-query": "^5.80.7",
|
||||
"@tanstack/react-router": "^1.120.20",
|
||||
"@tanstack/react-virtual": "^3.14.21",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"music-metadata": "^12.2.3",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-i18next": "^15.5.3",
|
||||
"tus-js-client": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.1",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
@@ -35,6 +39,7 @@
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.5.2",
|
||||
"axe-core": "^4.14.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"vite": "^6.3.5",
|
||||
"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,12 +4,13 @@ import { QueryClient } from '@tanstack/react-query';
|
||||
import { createMemoryHistory } from '@tanstack/react-router';
|
||||
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import axe from 'axe-core';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { App } from './App';
|
||||
import { createAppRouter, safeRedirect } from './app/router';
|
||||
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
@@ -117,7 +118,8 @@ describe('navigation by role', () => {
|
||||
|
||||
it('shows users but not the audit log to moderators', async () => {
|
||||
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(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -152,6 +154,39 @@ describe('users admin', () => {
|
||||
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 () => {
|
||||
const mod = makeUser('mod', 'moderator');
|
||||
const anna = makeUser('anna', 'user');
|
||||
@@ -164,7 +199,7 @@ describe('users admin', () => {
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
|
||||
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
|
||||
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
|
||||
expect(items).toEqual(['Сбросить пароль', 'Сбросить рекомендации', 'Заблокировать']);
|
||||
await user.keyboard('{Escape}');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
|
||||
@@ -246,6 +281,30 @@ describe('search box', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
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', () => {
|
||||
it.each([
|
||||
['/settings', '/settings'],
|
||||
@@ -323,3 +407,154 @@ describe('safeRedirect', () => {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('playlist sharing', () => {
|
||||
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
|
||||
const alice = makeUser('alice', 'user');
|
||||
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
|
||||
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
|
||||
const playlist = created.data!;
|
||||
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
|
||||
const shareId = api.playlists.get(playlist.id)!.share_id!;
|
||||
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
|
||||
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
|
||||
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
|
||||
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||
await screen.findByRole('heading', { name: 'Общий микс' });
|
||||
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
|
||||
await screen.findByRole('contentinfo', { name: 'Плеер' });
|
||||
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
|
||||
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
|
||||
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
|
||||
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
|
||||
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
|
||||
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
|
||||
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
|
||||
await screen.findByRole('alert');
|
||||
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns to the shared playlist after login without starting playback', async () => {
|
||||
const bob = makeUser('bob', 'user');
|
||||
const shareId = '00000000-0000-4000-9001-000000000001';
|
||||
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
|
||||
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
|
||||
await screen.findByRole('heading', { name: 'Вход' });
|
||||
await user.type(screen.getByLabelText('Логин'), bob.username);
|
||||
await user.type(screen.getByLabelText('Пароль'), bob.password);
|
||||
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||
await screen.findByRole('heading', { name: 'От друга' });
|
||||
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
|
||||
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -7,19 +7,26 @@ import {
|
||||
redirect,
|
||||
type ParsedLocation,
|
||||
} from '@tanstack/react-router';
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { AuditPage } from '../pages/admin/AuditPage';
|
||||
import { LibraryPage } from '../pages/admin/LibraryPage';
|
||||
import { PatternsPage } from '../pages/admin/PatternsPage';
|
||||
import { AdminSettingsPage } from '../pages/admin/AdminSettingsPage';
|
||||
import { UploadQueuePage } from '../pages/admin/UploadQueuePage';
|
||||
import { MyUploadsPage } from '../pages/uploads/MyUploadsPage';
|
||||
import { SuggestPage } from '../pages/uploads/SuggestPage';
|
||||
import { SourcesPage } from '../pages/admin/SourcesPage';
|
||||
import { UsersPage } from '../pages/admin/UsersPage';
|
||||
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
|
||||
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
|
||||
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
|
||||
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
|
||||
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
|
||||
const AdminSettingsPage = lazy(() => import('../pages/admin/AdminSettingsPage').then((m) => ({ default: m.AdminSettingsPage })));
|
||||
const UploadQueuePage = lazy(() => import('../pages/admin/UploadQueuePage').then((m) => ({ default: m.UploadQueuePage })));
|
||||
const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((m) => ({ default: m.MyUploadsPage })));
|
||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||
import type { DesktopHandle } from '../desktop';
|
||||
import { AlbumPage } from '../pages/AlbumPage';
|
||||
import { ConnectPage } from '../pages/ConnectPage';
|
||||
import { TrackPage } from '../pages/TrackPage';
|
||||
import { ArtistPage } from '../pages/ArtistPage';
|
||||
import { CollectionPage } from '../pages/CollectionPage';
|
||||
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||
import { HomePage } from '../pages/HomePage';
|
||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||
import { LoginPage } from '../pages/LoginPage';
|
||||
@@ -28,9 +35,11 @@ import { SettingsPage } from '../pages/SettingsPage';
|
||||
import { SetupPage } from '../pages/SetupPage';
|
||||
import { AppShell } from '../widgets/AppShell';
|
||||
import { RouteError } from '../widgets/RouteError';
|
||||
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
|
||||
|
||||
export interface RouterContext {
|
||||
session: AuthSession;
|
||||
desktop: DesktopHandle | null;
|
||||
}
|
||||
|
||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||
@@ -49,9 +58,12 @@ export function safeRedirect(target: unknown): string {
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -60,8 +72,26 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||
errorComponent: RouteError,
|
||||
});
|
||||
|
||||
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
|
||||
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({
|
||||
getParentRoute: () => rootRoute,
|
||||
@@ -69,6 +99,7 @@ const loginRoute = createRoute({
|
||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||
beforeLoad: ({ context, search }) => {
|
||||
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
||||
const user = signedInUser(context.session);
|
||||
if (!user) return;
|
||||
const home = homeFor(user);
|
||||
@@ -82,7 +113,7 @@ const setupAdminRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup/admin',
|
||||
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) });
|
||||
},
|
||||
component: () => <SetupPage variant="admin" />,
|
||||
@@ -92,7 +123,7 @@ const setupPasswordRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/setup/password',
|
||||
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) });
|
||||
},
|
||||
component: () => <SetupPage variant="password" />,
|
||||
@@ -103,7 +134,7 @@ const appRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
id: 'app',
|
||||
beforeLoad: ({ context, location }) => {
|
||||
const user = requireUser(context.session, location);
|
||||
const user = requireUser(context.session, location, context.desktop);
|
||||
const home = homeFor(user);
|
||||
if (home !== '/') throw redirect({ to: home });
|
||||
return { user };
|
||||
@@ -116,6 +147,13 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
||||
};
|
||||
|
||||
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 sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
|
||||
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||
const downloadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/downloads', component: DesktopDownloadsPage,
|
||||
beforeLoad: ({ context }) => { if (!context.desktop) throw redirect({ to: '/settings' }); },
|
||||
});
|
||||
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||
|
||||
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||
|
||||
@@ -225,7 +263,9 @@ const myUploadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/upl
|
||||
const uploadQueueRoute = createRoute({
|
||||
getParentRoute: () => appRoute,
|
||||
path: '/admin/uploads',
|
||||
beforeLoad: adminOnly,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (!context.user.reviews_uploads) throw redirect({ to: '/' });
|
||||
},
|
||||
component: UploadQueuePage,
|
||||
});
|
||||
|
||||
@@ -236,13 +276,28 @@ const adminSettingsRoute = createRoute({
|
||||
component: AdminSettingsPage,
|
||||
});
|
||||
|
||||
const serverUpdatesRoute = createRoute({
|
||||
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
|
||||
beforeLoad: ({ context }) => {
|
||||
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
|
||||
},
|
||||
});
|
||||
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
downloadAppRoute,
|
||||
androidAppRoute,
|
||||
connectRoute,
|
||||
loginRoute,
|
||||
setupAdminRoute,
|
||||
setupPasswordRoute,
|
||||
appRoute.addChildren([
|
||||
homeRoute,
|
||||
trackRoute,
|
||||
playlistsRoute,
|
||||
downloadsRoute,
|
||||
sharedPlaylistRoute,
|
||||
playlistRoute,
|
||||
settingsRoute,
|
||||
searchRoute,
|
||||
historyRoute,
|
||||
@@ -259,11 +314,17 @@ const routeTree = rootRoute.addChildren([
|
||||
myUploadsRoute,
|
||||
uploadQueueRoute,
|
||||
adminSettingsRoute,
|
||||
serverUpdatesRoute,
|
||||
serverConfigRoute,
|
||||
]),
|
||||
]);
|
||||
|
||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
||||
export function createAppRouter(
|
||||
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.
|
||||
session.subscribe(() => void router.invalidate());
|
||||
return router;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { AuthSession, type AuthUser } from '@pipu/core';
|
||||
|
||||
import { createDesktop, desktopFetch, isDesktop } from './desktop';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('desktop bridge', () => {
|
||||
it('restores the last desktop user when the proxy cannot reach the server', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
|
||||
const user = { id: 'alice', username: 'alice' } as AuthUser;
|
||||
const clear = vi.fn();
|
||||
const session = new AuthSession({ clientType: 'desktop', fetch: desktopFetch, baseUrl: 'http://local', refreshStore: {
|
||||
get: async () => 'refresh', set: vi.fn(), clear, getUser: async () => user,
|
||||
} });
|
||||
expect(await session.restore()).toEqual({ status: 'signed_in', offline: true, user });
|
||||
expect(clear).not.toHaveBeenCalled();
|
||||
});
|
||||
it('reports a failed proxy hop as a network failure, preserving real HTTP errors', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
|
||||
await expect(desktopFetch('http://local/api')).rejects.toThrow('Server unavailable');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('denied', { status: 401 })));
|
||||
expect((await desktopFetch('http://local/api')).status).toBe(401);
|
||||
});
|
||||
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,102 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/** A failed loopback hop must behave like a network failure to offline auth restore. */
|
||||
export const desktopFetch: typeof fetch = async (input, init) => {
|
||||
const response = await fetch(input, init);
|
||||
if (response.headers.get('x-pipu-transport-error') === '1') throw new TypeError('Server unavailable');
|
||||
return response;
|
||||
};
|
||||
|
||||
export function desktopInvoke<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>;
|
||||
}
|
||||
const invoke = desktopInvoke;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
.cover img {
|
||||
.coverImg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.35s ease;
|
||||
}
|
||||
|
||||
.coverImgOn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.round {
|
||||
@@ -59,10 +65,52 @@
|
||||
width: 128px;
|
||||
}
|
||||
|
||||
.cardCoverWrap {
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.cardCover {
|
||||
aspect-ratio: 1;
|
||||
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 {
|
||||
@@ -104,6 +152,12 @@
|
||||
min-height: 56px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 10px;
|
||||
animation: pipuIn 0.2s ease-out both;
|
||||
animation-delay: var(--stagger, 0ms);
|
||||
}
|
||||
|
||||
.trackStatic {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.track:has(.trackAlbum) {
|
||||
@@ -270,3 +324,13 @@
|
||||
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,13 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
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 { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useSession } from '../app/session';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { usePlayer, usePlayerState } from '../player/context';
|
||||
import type { PlayContext } from '../player/player';
|
||||
@@ -26,6 +31,7 @@ function fallbackColors(id: string): string[] {
|
||||
|
||||
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
|
||||
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 size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
|
||||
return `${url}&size=${size}`;
|
||||
@@ -56,7 +62,7 @@ export function Cover({
|
||||
aria-hidden
|
||||
>
|
||||
{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} />
|
||||
)}
|
||||
@@ -80,10 +86,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 }) {
|
||||
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 (
|
||||
<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.cardMeta}>
|
||||
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
|
||||
@@ -114,6 +149,7 @@ export function TrackList({
|
||||
rowMeta,
|
||||
lead,
|
||||
disliked,
|
||||
actions,
|
||||
}: {
|
||||
tracks: Track[];
|
||||
showAlbum?: boolean;
|
||||
@@ -125,86 +161,123 @@ export function TrackList({
|
||||
lead?: (t: Track, i: number) => string;
|
||||
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||
disliked?: boolean;
|
||||
actions?: (track: Track, index: number) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const player = usePlayer();
|
||||
const { track: current, playing } = usePlayerState();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
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 && <SaveOfflineButton track={tr} compact />}
|
||||
{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;
|
||||
return (
|
||||
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
|
||||
{tracks.map((tr, i) => {
|
||||
const disc = tr.disc_no ?? 1;
|
||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||
lastDisc = disc;
|
||||
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 (
|
||||
<Fragment key={`${tr.id}-${i}`}>
|
||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||
<div
|
||||
ref={listRef}
|
||||
className={s.tracks}
|
||||
role="list"
|
||||
aria-label={t('search.tracks')}
|
||||
style={virtual ? { height: virtualizer.getTotalSize(), position: 'relative' } : undefined}
|
||||
>
|
||||
{virtual
|
||||
? virtualizer.getVirtualItems().map((vi) => (
|
||||
<div
|
||||
role="listitem"
|
||||
tabIndex={tr.available ? 0 : -1}
|
||||
aria-current={isCurrent || undefined}
|
||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||
onClick={(e) => {
|
||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||
}}
|
||||
key={vi.key}
|
||||
data-index={vi.index}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ position: 'absolute', top: 0, left: 0, width: '100%', transform: `translateY(${vi.start}px)` }}
|
||||
>
|
||||
{lead && <span className={s.lead}>{isCurrent ? <Equalizer playing={playing} /> : lead(tr, i)}</span>}
|
||||
<span className={cx(s.num, disliked && s.dimmed)}>
|
||||
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={lead ? 44 : 40} />}
|
||||
{tr.available && (
|
||||
<span className={s.playHint} aria-hidden>
|
||||
<Icon src={playIcon} size={20} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className={cx(s.trackMain, disliked && s.dimmed)}>
|
||||
<span className={s.trackTitle}>{tr.title}</span>
|
||||
<span className={s.trackArtists}>
|
||||
<ArtistLinks artists={tr.artists} />
|
||||
{meta && <span className={s.rowMeta}> · {meta}</span>}
|
||||
</span>
|
||||
</span>
|
||||
{showAlbum && (
|
||||
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
|
||||
{tr.album.title}
|
||||
</Link>
|
||||
)}
|
||||
<span className={s.end}>
|
||||
{disliked ? (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, s.liked)}
|
||||
aria-label={t('player.undislike')}
|
||||
onClick={() => void player.react(tr, null).catch(() => undefined)}
|
||||
>
|
||||
<Icon src={undoIcon} size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={cx(s.like, liked && s.liked)}
|
||||
aria-label={t(liked ? 'player.unlike' : 'player.like')}
|
||||
aria-pressed={liked}
|
||||
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
|
||||
>
|
||||
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
|
||||
</button>
|
||||
)}
|
||||
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
|
||||
</span>
|
||||
{row(tracks[vi.index]!, vi.index)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
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, useUser } 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, shared = false) {
|
||||
const session = useSession();
|
||||
const user = useUser();
|
||||
return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
|
||||
queryFn: async () => shared
|
||||
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
|
||||
: 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,147 @@
|
||||
import { releaseNotesText, type Track } from '@pipu/core';
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthState, useSession } from '../../app/session';
|
||||
import { desktopInvoke, useDesktop } from '../../desktop';
|
||||
import { TrackList } from '../../entities/catalog';
|
||||
import { usePlayer, usePlayerState } from '../../player/context';
|
||||
import { Button, Dialog, FormError } from '../../shared/ui/ui';
|
||||
import { formatBytes } from '../../shared/lib/format';
|
||||
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
|
||||
import s from '../../pages/page.module.css';
|
||||
|
||||
export function useOffline() {
|
||||
const offline = usePlayer().offline;
|
||||
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
|
||||
return offline;
|
||||
}
|
||||
|
||||
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
|
||||
const offline = useOffline();
|
||||
const { t } = useTranslation();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
if (!offline) return null;
|
||||
return <span>
|
||||
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
|
||||
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
|
||||
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
|
||||
setBusy(true); setError(false);
|
||||
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
|
||||
.catch(() => setError(true)).finally(() => setBusy(false));
|
||||
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
|
||||
{error && <span role="alert">{t('desktop.download_failed')}</span>}
|
||||
</span>;
|
||||
}
|
||||
|
||||
export function DesktopOfflineSection() {
|
||||
const offline = useOffline();
|
||||
const { track } = usePlayerState();
|
||||
const { t, i18n } = useTranslation();
|
||||
const [error, setError] = useState(false);
|
||||
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
|
||||
if (!offline) return null;
|
||||
const tracks = offline.tracks();
|
||||
return <section className={s.section}>
|
||||
<h2>{t('desktop.offline')}</h2>
|
||||
<p className={s.note}>{t('desktop.cache_hint')}</p>
|
||||
<div className={s.toolbar}>
|
||||
<span>{formatBytes(offline.bytes, i18n.language)}</span>
|
||||
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
|
||||
{track && <SaveOfflineButton track={track} />}
|
||||
</div>
|
||||
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
|
||||
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function DesktopDownloadsPage() {
|
||||
const { t } = useTranslation();
|
||||
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
|
||||
}
|
||||
|
||||
export function DesktopOfflineBanner() {
|
||||
const state = useAuthState();
|
||||
const desktop = useDesktop();
|
||||
const { t } = useTranslation();
|
||||
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
|
||||
}
|
||||
|
||||
type Release = { build: { version: string; size: number }; notes: string };
|
||||
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
|
||||
let update: Update = { status: 'idle' };
|
||||
let generation = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
|
||||
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
|
||||
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
|
||||
async function checkUpdate(server: string, manual = false) {
|
||||
if (update.status === 'checking' || update.status === 'installing') return;
|
||||
const key = `pipu.update.checked.${server}`;
|
||||
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
|
||||
setUpdate({ status: 'checking' });
|
||||
const requestGeneration = generation;
|
||||
try {
|
||||
const release = await desktopInvoke<Release | null>('update_check');
|
||||
if (requestGeneration !== generation) return;
|
||||
localStorage.setItem(key, String(Date.now()));
|
||||
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
|
||||
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
|
||||
}
|
||||
|
||||
export function DesktopUpdateButton() {
|
||||
const desktop = useDesktop();
|
||||
const state = useUpdate();
|
||||
const { t } = useTranslation();
|
||||
if (!desktop?.server) return null;
|
||||
return <section className={s.section}>
|
||||
<h2>{t('desktop.updates')}</h2>
|
||||
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
|
||||
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
|
||||
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
|
||||
</section>;
|
||||
}
|
||||
|
||||
export function DesktopUpdateDialog() {
|
||||
const desktop = useDesktop();
|
||||
const session = useSession();
|
||||
const state = useUpdate();
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
if (!desktop?.server) return;
|
||||
const check = () => void checkUpdate(desktop.server!);
|
||||
check();
|
||||
window.addEventListener('focus', check);
|
||||
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
|
||||
}, [desktop, session]);
|
||||
useEffect(() => {
|
||||
if (!desktop) return;
|
||||
let stop: (() => void) | undefined;
|
||||
let disposed = false;
|
||||
// The native updater emits progress through the same Tauri event surface as media controls.
|
||||
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
|
||||
void api?.event.listen('update-progress', (e) => {
|
||||
const progress = Number(e.payload);
|
||||
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
|
||||
}).then((fn) => { if (disposed) fn(); else stop = fn; });
|
||||
return () => { disposed = true; stop?.(); };
|
||||
}, [desktop]);
|
||||
if (!desktop || !state.release || state.dismissed) return null;
|
||||
const release = state.release;
|
||||
const busy = state.status === 'installing';
|
||||
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
|
||||
const notes = releaseNotesText(release.notes);
|
||||
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
|
||||
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
|
||||
<Button busy={busy} onClick={() => {
|
||||
setUpdate({ status: 'installing', release, progress: 0 });
|
||||
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
|
||||
}}>{t('mobile.update_install')}</Button>
|
||||
</>}>
|
||||
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
|
||||
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
|
||||
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
|
||||
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
|
||||
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
|
||||
</Dialog>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AuthSession, PlayEvent } from '@pipu/core';
|
||||
import { desktopEventSink, evictRecent, offlineOwner } from './offline';
|
||||
import { createDesktop } from '../../desktop';
|
||||
import { albumTracks } from '../../test/fakeApi';
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
|
||||
describe('desktop cache policy', () => {
|
||||
it('evicts oldest recent files while keeping manual downloads outside the limit', () => {
|
||||
const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob });
|
||||
expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']);
|
||||
});
|
||||
});
|
||||
|
||||
it('persists offline events across sink restarts and deduplicates retries', async () => {
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } };
|
||||
await createDesktop();
|
||||
const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') });
|
||||
const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession;
|
||||
const owner = offlineOwner(session)!;
|
||||
const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' };
|
||||
await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline');
|
||||
await desktopEventSink(session, owner)([event]);
|
||||
expect(post.mock.calls[1]?.[1].body.events).toEqual([event]);
|
||||
expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core';
|
||||
import { desktopHandle } from '../../desktop';
|
||||
|
||||
export const RECENT_LIMIT = 500 * 1024 * 1024;
|
||||
type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number };
|
||||
const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0);
|
||||
|
||||
export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] {
|
||||
let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0);
|
||||
const removed: string[] = [];
|
||||
for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) {
|
||||
if (total <= limit) break;
|
||||
removed.push(e.id);
|
||||
total -= size(e);
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
export function offlineOwner(session: AuthSession): string | null {
|
||||
const desktop = desktopHandle();
|
||||
return desktop?.server && session.state.status === 'signed_in'
|
||||
? JSON.stringify([desktop.server, session.state.user.id]) : null;
|
||||
}
|
||||
|
||||
/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */
|
||||
export class DesktopOffline {
|
||||
private db: Promise<IDBDatabase> | null = null;
|
||||
private loaded: Promise<void> | null = null;
|
||||
private entries: Entry[] = [];
|
||||
private urls = new Map<string, Track>();
|
||||
private objects = new Map<string, string[]>();
|
||||
private inUse = new Set<string>();
|
||||
private listeners = new Set<() => void>();
|
||||
private revision = 0;
|
||||
private writes: Promise<unknown> = Promise.resolve();
|
||||
private disposed = false;
|
||||
get ready(): Promise<void> {
|
||||
this.loaded ??= this.reload();
|
||||
return this.loaded;
|
||||
}
|
||||
activate() { this.disposed = false; }
|
||||
constructor(readonly owner: string, private session: AuthSession) {}
|
||||
private database(): Promise<IDBDatabase> {
|
||||
return this.db ??= new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(`pipu.offline.${this.owner}`, 1);
|
||||
request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' });
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
});
|
||||
}
|
||||
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
|
||||
snapshot = () => this.revision;
|
||||
private async reload() {
|
||||
const db = await this.database();
|
||||
const entries = await new Promise<Entry[]>((resolve, reject) => {
|
||||
const req = db.transaction('tracks').objectStore('tracks').getAll();
|
||||
req.onsuccess = () => resolve(req.result as Entry[]);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
if (this.disposed) return;
|
||||
this.entries = entries;
|
||||
for (const e of entries) {
|
||||
if (this.urls.has(e.id)) continue;
|
||||
const audio = URL.createObjectURL(e.audio);
|
||||
const cover = e.cover ? URL.createObjectURL(e.cover) : null;
|
||||
this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]);
|
||||
this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } });
|
||||
}
|
||||
for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) {
|
||||
this.urls.delete(id);
|
||||
if (!this.inUse.has(id)) this.release(id);
|
||||
}
|
||||
this.revision++;
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
tracks() { return this.entries.map((e) => this.local(e.track)); }
|
||||
private release(id: string) {
|
||||
for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url);
|
||||
this.objects.delete(id);
|
||||
}
|
||||
/** Keep only files currently playing/crossfading alive after cache deletion. */
|
||||
keep(ids: string[]) {
|
||||
this.inUse = new Set(ids);
|
||||
for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id);
|
||||
}
|
||||
local(track: Track): Track { return this.urls.get(track.id) ?? track; }
|
||||
has(id: string) { return this.entries.some((e) => e.id === id); }
|
||||
pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); }
|
||||
get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); }
|
||||
private mutate(fn: (store: IDBObjectStore) => void): Promise<void> {
|
||||
const work = this.writes.catch(() => undefined).then(async () => {
|
||||
const db = await this.database();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const tx = db.transaction('tracks', 'readwrite');
|
||||
fn(tx.objectStore('tracks'));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
await this.reload();
|
||||
});
|
||||
this.writes = work;
|
||||
return work;
|
||||
}
|
||||
private downloads = new Map<string, Promise<void>>();
|
||||
private downloadTail: Promise<unknown> = Promise.resolve();
|
||||
async save(track: Track, pinned = false): Promise<void> {
|
||||
await this.ready;
|
||||
const pending = this.downloads.get(track.id);
|
||||
if (pending) { await pending; return this.save(track, pinned); }
|
||||
const existing = this.entries.find((e) => e.id === track.id);
|
||||
if (existing) {
|
||||
await this.mutate((store) => {
|
||||
const latest = this.entries.find((e) => e.id === track.id);
|
||||
if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() });
|
||||
});
|
||||
return;
|
||||
}
|
||||
const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned));
|
||||
this.downloadTail = job;
|
||||
this.downloads.set(track.id, job);
|
||||
try { await job; } finally { this.downloads.delete(track.id); }
|
||||
}
|
||||
private async download(track: Track, pinned: boolean) {
|
||||
if (this.disposed) return;
|
||||
const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0];
|
||||
if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable');
|
||||
const response = await fetch(fresh.stream_url);
|
||||
if (!response.ok) throw new Error('Download failed');
|
||||
const audio = await response.blob();
|
||||
if (!audio.size) throw new Error('Empty download');
|
||||
let cover: Blob | undefined;
|
||||
if (fresh.album.cover_url) {
|
||||
try {
|
||||
const response = await fetch(`${fresh.album.cover_url}&size=300`);
|
||||
if (response.ok) cover = await response.blob();
|
||||
} catch { /* Artwork is optional. */ }
|
||||
}
|
||||
if (this.disposed) return;
|
||||
const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() };
|
||||
await this.mutate((store) => {
|
||||
const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]);
|
||||
for (const id of removed) store.delete(id);
|
||||
if (!removed.includes(entry.id)) store.put(entry);
|
||||
});
|
||||
}
|
||||
async remove(id: string) { await this.mutate((store) => store.delete(id)); }
|
||||
async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); }
|
||||
dispose() {
|
||||
this.disposed = true;
|
||||
queueMicrotask(() => {
|
||||
if (!this.disposed) return;
|
||||
for (const id of this.objects.keys()) this.release(id);
|
||||
void this.downloadTail.catch(() => undefined).then(() => this.writes)
|
||||
.finally(() => this.db?.then((db) => db.close())).catch(() => undefined);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist before sending; retries keep event IDs for server-side deduplication. */
|
||||
export function desktopEventSink(session: AuthSession, owner: string) {
|
||||
const key = `pipu.events.${owner}`;
|
||||
let sending = Promise.resolve();
|
||||
return async (events: PlayEvent[]) => {
|
||||
const work = sending.catch(() => undefined).then(async () => {
|
||||
const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[];
|
||||
const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()];
|
||||
localStorage.setItem(key, JSON.stringify(merged));
|
||||
if (offlineOwner(session) !== owner) return;
|
||||
while (merged.length) {
|
||||
if (offlineOwner(session) !== owner) return;
|
||||
const batch = merged.slice(0, 100);
|
||||
unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } }));
|
||||
merged.splice(0, batch.length);
|
||||
localStorage.setItem(key, JSON.stringify(merged));
|
||||
}
|
||||
});
|
||||
sending = work;
|
||||
await work;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, PageSkeleton } from '../../shared/ui/ui';
|
||||
import { JobPanel } from './JobPanel';
|
||||
import s from './management.module.css';
|
||||
|
||||
export function ConfigPage() {
|
||||
const { t } = useTranslation('admin');
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const qc = useQueryClient();
|
||||
const [draft, setDraft] = useState<Record<string, string | null>>({});
|
||||
const [baseRevision, setBaseRevision] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [group, setGroup] = useState('all');
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const dirty = Object.keys(draft).length > 0;
|
||||
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
|
||||
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
|
||||
const running = status.data?.job?.state === 'running';
|
||||
useEffect(() => {
|
||||
if (!dirty) return;
|
||||
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
|
||||
window.addEventListener('beforeunload', prevent);
|
||||
return () => window.removeEventListener('beforeunload', prevent);
|
||||
}, [dirty]);
|
||||
useEffect(() => {
|
||||
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
|
||||
}, [status.data?.job?.id, status.data?.job?.state, qc]);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
|
||||
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
|
||||
});
|
||||
const apply = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
|
||||
onSuccess: (job) => {
|
||||
setConfirm(false);
|
||||
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
|
||||
void qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||
},
|
||||
});
|
||||
const change = (key: string, value: string | null) => {
|
||||
setBaseRevision((v) => v ?? query.data!.revision);
|
||||
setDraft((v) => ({ ...v, [key]: value })); setSaved(false); save.reset();
|
||||
};
|
||||
if (query.isPending) return <PageSkeleton />;
|
||||
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
|
||||
return <div className={s.page}>
|
||||
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
|
||||
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||
{query.data && <>
|
||||
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
|
||||
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
|
||||
<p className={s.note}>{t('management.config_files_hint')}</p>
|
||||
<div className={s.configList}>{entries.map((entry) => {
|
||||
const changed = Object.hasOwn(draft, entry.key);
|
||||
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
|
||||
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
|
||||
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
|
||||
<div className={s.values}><span>{t('management.effective')}: <code>{entry.effective || '—'}</code></span><span>ENV: <code>{entry.env ?? '—'}</code></span></div>
|
||||
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
|
||||
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} value={value} disabled={running || save.isPending} placeholder={entry.secret && entry.local ? t('management.secret_saved') : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
|
||||
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
|
||||
</div>}
|
||||
</section>;
|
||||
})}</div>
|
||||
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
|
||||
<section className={s.saveBar}>
|
||||
<div role="status">{saved ? t('management.saved') : dirty ? t('management.unsaved', { count: Object.keys(draft).length }) : query.data.restart_required ? t('management.pending_restart') : t('management.no_changes')}</div>
|
||||
<div className={s.actions}><Button disabled={!dirty || save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={!dirty || running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button><Button disabled={dirty || running || !query.data.restart_required} busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button></div>
|
||||
</section>
|
||||
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
|
||||
<JobPanel job={status.data?.job} reconnecting={status.isError} />
|
||||
</>}
|
||||
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import s from './management.module.css';
|
||||
|
||||
export type Job = components['schemas']['ServerJob'];
|
||||
|
||||
export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) {
|
||||
const { t } = useTranslation('admin');
|
||||
if (!job) return null;
|
||||
return <section className={s.card} aria-label={t('management.job')}>
|
||||
<div className={s.cardHead}>
|
||||
<div><span className={s.eyebrow}>{t('management.job')}</span><h2>{job.target}</h2></div>
|
||||
<span className={s.status} data-state={job.state} role="status">{t(`management.state.${job.state}`)}</span>
|
||||
</div>
|
||||
<p className={s.note}>{t('management.started')}: {new Date(job.started_at).toLocaleString()}
|
||||
{job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}</>}</p>
|
||||
{job.state === 'running' && <p className={s.note}>{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}</p>}
|
||||
<pre className={s.log} tabIndex={0} aria-label={t('management.log')}>{job.log || t('management.waiting')}</pre>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
|
||||
import { JobPanel } from './JobPanel';
|
||||
import s from './management.module.css';
|
||||
|
||||
type Source = components['schemas']['ServerSource'];
|
||||
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
|
||||
|
||||
export function UpdatesPage() {
|
||||
const { t } = useTranslation('admin');
|
||||
const session = useSession();
|
||||
const qc = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [channel, setChannel] = useState<Channel>('latest');
|
||||
const [version, setVersion] = useState('');
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [editing, setEditing] = useState<Source[] | null>(null);
|
||||
const query = useQuery({
|
||||
queryKey: ['server-updates', sourceId],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates', {
|
||||
params: { query: { source_id: sourceId || undefined } },
|
||||
})),
|
||||
refetchInterval: 3000,
|
||||
retry: false,
|
||||
});
|
||||
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
|
||||
const running = query.data?.job?.state === 'running';
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||
const save = useMutation({
|
||||
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
|
||||
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
|
||||
});
|
||||
const start = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
|
||||
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined },
|
||||
})),
|
||||
onSuccess: (job) => {
|
||||
setConfirm(false);
|
||||
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', sourceId], (old) => old ? { ...old, job } : old);
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
if (query.isPending) return <PageSkeleton />;
|
||||
return <div className={s.page}>
|
||||
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
|
||||
<Button onClick={() => void query.refetch()} busy={query.isFetching}>{t('management.refresh')}</Button>
|
||||
</header>
|
||||
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||
{query.data && <>
|
||||
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
|
||||
<section className={s.card}>
|
||||
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
|
||||
<label className={s.selectLabel}>{t('management.source')}
|
||||
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
|
||||
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className={s.url}>{source?.url}</p>
|
||||
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
|
||||
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
|
||||
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
|
||||
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
|
||||
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||
<div className={s.actions}><Button variant="primary" disabled={!source || running || !!query.data.source_error || (channel === 'version' && !version)} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button></div>
|
||||
</section>
|
||||
<JobPanel job={query.data.job} reconnecting={query.isError} />
|
||||
</>}
|
||||
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
|
||||
<p>{t('management.confirm_update_text', { source: source?.name, target: channel === 'version' ? version : t(`management.channel_${channel}`) })}</p>
|
||||
<p className={s.note}>{t('management.update_warning')}</p>
|
||||
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
|
||||
</Dialog>}
|
||||
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
|
||||
<p className={s.note}>{t('management.sources_hint')}</p>
|
||||
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
|
||||
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
|
||||
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
|
||||
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
|
||||
</div>)}</div>
|
||||
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
|
||||
</Dialog>}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
|
||||
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
|
||||
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
|
||||
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
|
||||
.eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--text2); font-weight: 600; }
|
||||
.hero { display: flex; gap: 24px; align-items: center; justify-content: space-between; padding: 28px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(120deg, var(--accent-soft), var(--surface-a)); }
|
||||
.hero strong { display: block; font-family: var(--font-display); font-size: 30px; margin-top: 8px; overflow-wrap: anywhere; }
|
||||
.hero strong.priority { font-size: 19px; }
|
||||
.hero p { color: var(--text2); max-width: 340px; line-height: 1.6; font-size: 13px; }
|
||||
.card { padding: 24px; background: var(--surface-a); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: 18px; }
|
||||
.card h2 { margin: 0; font-size: 18px; }
|
||||
.selectLabel { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; }
|
||||
.selectLabel select { height: 42px; padding: 0 12px; font: inherit; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--surface2); max-width: 100%; }
|
||||
.selectLabel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.url { color: var(--text2); font: 12px monospace; overflow-wrap: anywhere; margin: -8px 0 0; }
|
||||
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
|
||||
.status, .tag { padding: 5px 10px; border: 1px solid var(--line); border-radius: 99px; color: var(--text2); font-size: 12px; }
|
||||
.status[data-state='running'] { background: var(--accent-soft); color: var(--accent); }
|
||||
.status[data-state='succeeded'] { color: var(--ok); }
|
||||
.status[data-state='failed'] { color: var(--danger); }
|
||||
.log { margin: 0; padding: 18px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); max-height: 380px; min-height: 100px; overflow: auto; font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.sourceList { display: flex; flex-direction: column; gap: 16px; max-height: 50vh; overflow: auto; margin-bottom: 18px; }
|
||||
.sourceEditor { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
|
||||
.toolbar { display: grid; grid-template-columns: 1fr 220px; gap: 16px; align-items: end; }
|
||||
.configList { display: flex; flex-direction: column; gap: 12px; }
|
||||
.configRow { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-a); display: flex; flex-direction: column; gap: 14px; }
|
||||
.configRow[data-changed] { border-color: var(--accent); }
|
||||
.configMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
||||
.configMeta > code { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.values { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--text2); font-size: 12px; }
|
||||
.values code { overflow-wrap: anywhere; }
|
||||
.editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
|
||||
.saveBar { position: sticky; bottom: 12px; z-index: 2; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); box-shadow: 0 8px 32px #0003; font-size: 13px; }
|
||||
@media (max-width: 650px) { .hero { flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; } .hero p { max-width: none; } .toolbar { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; } .card, .configRow { padding: 16px; } .actions { justify-content: flex-start; } }
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||