Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4b841a8d8 | ||
|
|
597067689a | ||
|
|
efe78d7e07 | ||
|
|
fa79394869 | ||
|
|
3a097c15c8 |
No files matched your search
@@ -0,0 +1,33 @@
|
|||||||
|
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
|
||||||
|
# (Linux на ubuntu-latest, Windows NSIS на windows-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
|
||||||
@@ -57,5 +57,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
key: playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
- run: pnpm --filter @pipu/web exec playwright install --with-deps chromium
|
# 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
|
- run: pnpm --filter @pipu/web test:e2e
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
|
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
|
||||||
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
|
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
|
||||||
# и переписывает статус: собран / не удалась (scripts/release.mjs).
|
# и переписывают статус: собран / не удалась (scripts/release.mjs).
|
||||||
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
|
||||||
#
|
#
|
||||||
# Секреты (Settings → Actions → Secrets):
|
# Секреты (Settings → Actions → Secrets):
|
||||||
@@ -132,9 +132,10 @@ jobs:
|
|||||||
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
|
||||||
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
|
||||||
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
|
||||||
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
|
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
|
||||||
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
|
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
|
||||||
./gradlew --stop || true
|
./gradlew --stop || true
|
||||||
|
find ~/.gradle -name "*.lock" -delete || true
|
||||||
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
|
||||||
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
"${gradle[@]}" assembleRelease -x lintVitalRelease
|
||||||
else
|
else
|
||||||
@@ -196,3 +197,141 @@ jobs:
|
|||||||
- name: Статус в релизе — сборка отменена
|
- name: Статус в релизе — сборка отменена
|
||||||
if: cancelled()
|
if: cancelled()
|
||||||
run: node scripts/release.mjs android cancelled
|
run: node scripts/release.mjs android cancelled
|
||||||
|
|
||||||
|
desktop-linux:
|
||||||
|
name: desktop (Linux в релиз)
|
||||||
|
needs: release
|
||||||
|
timeout-minutes: 60
|
||||||
|
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 }}
|
||||||
|
APPIMAGE_EXTRACT_AND_RUN: "1"
|
||||||
|
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Статус в релизе — собирается
|
||||||
|
run: node scripts/release.mjs desktop linux 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 libfuse2
|
||||||
|
- 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
|
||||||
|
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
||||||
|
restore-keys: cargo-desktop-
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Сборка AppImage и deb
|
||||||
|
working-directory: apps/desktop
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
pnpm bundle:linux
|
||||||
|
bundle=src-tauri/target/release/bundle
|
||||||
|
shopt -s nullglob
|
||||||
|
for f in "$bundle"/appimage/*.AppImage; do
|
||||||
|
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.AppImage"
|
||||||
|
done
|
||||||
|
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.*
|
||||||
|
- name: Файлы в релиз
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
files=()
|
||||||
|
for f in pipu-music-${PIPU_VERSION}-linux.AppImage pipu-music-${PIPU_VERSION}-linux.deb; do
|
||||||
|
[ -f "$f" ] && files+=("$f")
|
||||||
|
done
|
||||||
|
if [ ${#files[@]} -eq 0 ]; then
|
||||||
|
echo "::error::нет AppImage и deb после сборки"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
node scripts/release.mjs desktop linux success "${files[@]}"
|
||||||
|
- name: Статус в релизе — сборка не удалась
|
||||||
|
if: failure()
|
||||||
|
run: node scripts/release.mjs desktop linux failure
|
||||||
|
- name: Статус в релизе — сборка отменена
|
||||||
|
if: cancelled()
|
||||||
|
run: node scripts/release.mjs desktop linux cancelled
|
||||||
|
|
||||||
|
# Нужен act_runner с меткой windows-latest (Windows + MSVC Build Tools + WebView2).
|
||||||
|
# Без такого раннера job останется в очереди; Tauri не кросс-компилирует NSIS с Linux.
|
||||||
|
desktop-windows:
|
||||||
|
name: desktop (Windows в релиз)
|
||||||
|
needs: release
|
||||||
|
timeout-minutes: 60
|
||||||
|
runs-on: windows-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: pwsh
|
||||||
|
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
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: pnpm/action-setup@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
|
||||||
|
- name: Статус в релизе — собирается
|
||||||
|
run: node scripts/release.mjs desktop windows building
|
||||||
|
- uses: ilammy/msvc-dev-cmd@v1
|
||||||
|
- name: Rust
|
||||||
|
run: |
|
||||||
|
$init = Join-Path $env:TEMP 'rustup-init.exe'
|
||||||
|
Invoke-WebRequest -Uri 'https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe' -OutFile $init
|
||||||
|
& $init -y --default-toolchain stable --profile minimal --default-host x86_64-pc-windows-msvc --no-modify-path
|
||||||
|
Add-Content -Path $env:GITHUB_PATH -Value "$env:USERPROFILE\.cargo\bin"
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry/index
|
||||||
|
~/.cargo/registry/cache
|
||||||
|
~/.cargo/git/db
|
||||||
|
key: cargo-desktop-windows-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
|
||||||
|
restore-keys: cargo-desktop-windows-
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
- name: Сборка NSIS
|
||||||
|
working-directory: apps/desktop
|
||||||
|
run: |
|
||||||
|
pnpm bundle:windows
|
||||||
|
$bundle = Join-Path (Get-Location) 'src-tauri/target/release/bundle/nsis'
|
||||||
|
$src = Get-ChildItem -Path $bundle -Filter '*-setup.exe' | Select-Object -First 1
|
||||||
|
if (-not $src) { throw "нет NSIS setup.exe после сборки в $bundle" }
|
||||||
|
$dest = Join-Path $env:GITHUB_WORKSPACE "pipu-music-$env:PIPU_VERSION-windows-setup.exe"
|
||||||
|
Copy-Item $src.FullName $dest
|
||||||
|
Get-Item $dest | Format-List Name, Length
|
||||||
|
- name: Файл в релиз
|
||||||
|
run: |
|
||||||
|
$file = "pipu-music-$env:PIPU_VERSION-windows-setup.exe"
|
||||||
|
if (-not (Test-Path $file)) { throw "нет $file после сборки" }
|
||||||
|
node scripts/release.mjs desktop windows success $file
|
||||||
|
- name: Статус в релизе — сборка не удалась
|
||||||
|
if: failure()
|
||||||
|
run: node scripts/release.mjs desktop windows failure
|
||||||
|
- name: Статус в релизе — сборка отменена
|
||||||
|
if: cancelled()
|
||||||
|
run: node scripts/release.mjs desktop windows cancelled
|
||||||
@@ -37,3 +37,7 @@ Thumbs.db
|
|||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.cache/
|
.cache/
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
|
apps/desktop/src-tauri/gen/
|
||||||
|
apps/desktop/src-tauri/tauri.release.json
|
||||||
|
apps/desktop/release/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# pipu-music
|
# pipu-music
|
||||||
|
|
||||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||||
|
|
||||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||||
@@ -15,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
|||||||
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
||||||
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
||||||
| `apps/web` | Веб-клиент (React + Vite) |
|
| `apps/web` | Веб-клиент (React + Vite) |
|
||||||
|
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
|
||||||
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
||||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||||
|
|||||||
+205
-4
@@ -1497,11 +1497,57 @@ paths:
|
|||||||
$ref: "#/components/schemas/WaveBatch"
|
$ref: "#/components/schemas/WaveBatch"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/app/clients:
|
||||||
|
get:
|
||||||
|
operationId: getAppClients
|
||||||
|
tags: [app]
|
||||||
|
security: [] # публично: страница /download без входа
|
||||||
|
summary: Клиенты последнего релиза (Android и десктоп)
|
||||||
|
description: |
|
||||||
|
Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
|
||||||
|
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Доступные сборки
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AppClients"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/app/download/{client}/{version}:
|
||||||
|
get:
|
||||||
|
operationId: downloadAppClient
|
||||||
|
tags: [app]
|
||||||
|
security: []
|
||||||
|
summary: Файл клиента (кэшируется на сервере, поддерживает Range)
|
||||||
|
parameters:
|
||||||
|
- name: client
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AppClient"
|
||||||
|
- name: version
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$'
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Установщик или APK
|
||||||
|
content:
|
||||||
|
application/octet-stream:
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
format: binary
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/app/android/latest:
|
/api/v1/app/android/latest:
|
||||||
get:
|
get:
|
||||||
operationId: getAndroidLatest
|
operationId: getAndroidLatest
|
||||||
tags: [app]
|
tags: [app]
|
||||||
security: [] # публично: страница /android раздаёт приложение без входа
|
security: [] # публично: страница /download раздаёт приложение без входа
|
||||||
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||||
description: |
|
description: |
|
||||||
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
||||||
@@ -1538,6 +1584,82 @@ paths:
|
|||||||
format: binary
|
format: binary
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/me/playlists:
|
||||||
|
get:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: listPlaylists
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistList'
|
||||||
|
post:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: createPlaylist
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistInput'
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'201':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
/api/v1/me/playlists/{id}:
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
get:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: getPlaylist
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
put:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: updatePlaylist
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistUpdate'
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
delete:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: deletePlaylist
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'204':
|
||||||
|
description: Deleted
|
||||||
|
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
SourceId:
|
SourceId:
|
||||||
@@ -1598,6 +1720,53 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Problem"
|
$ref: "#/components/schemas/Problem"
|
||||||
schemas:
|
schemas:
|
||||||
|
PlaylistInput:
|
||||||
|
type: object
|
||||||
|
required: [name, description, track_ids]
|
||||||
|
properties:
|
||||||
|
name: { type: string, minLength: 1, maxLength: 120 }
|
||||||
|
description: { type: string, maxLength: 2000 }
|
||||||
|
track_ids:
|
||||||
|
type: array
|
||||||
|
maxItems: 2000
|
||||||
|
uniqueItems: true
|
||||||
|
items: { type: string, format: uuid }
|
||||||
|
PlaylistUpdate:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/PlaylistInput'
|
||||||
|
- type: object
|
||||||
|
required: [revision]
|
||||||
|
properties:
|
||||||
|
revision: { type: integer, minimum: 1 }
|
||||||
|
PlaylistSummary:
|
||||||
|
type: object
|
||||||
|
required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at]
|
||||||
|
properties:
|
||||||
|
id: { type: string, format: uuid }
|
||||||
|
name: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
revision: { type: integer }
|
||||||
|
track_count: { type: integer }
|
||||||
|
duration_ms: { type: integer, format: int64 }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
Playlist:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/PlaylistSummary'
|
||||||
|
- type: object
|
||||||
|
required: [tracks]
|
||||||
|
properties:
|
||||||
|
tracks:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/Track' }
|
||||||
|
PlaylistList:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/PlaylistSummary' }
|
||||||
|
|
||||||
Onboarding:
|
Onboarding:
|
||||||
type: object
|
type: object
|
||||||
required: [required, artists, genres]
|
required: [required, artists, genres]
|
||||||
@@ -2100,7 +2269,7 @@ components:
|
|||||||
enum: [admin, moderator, user]
|
enum: [admin, moderator, user]
|
||||||
ClientType:
|
ClientType:
|
||||||
type: string
|
type: string
|
||||||
enum: [web, android]
|
enum: [web, android, desktop]
|
||||||
LoginRequest:
|
LoginRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [username, password, client_type]
|
required: [username, password, client_type]
|
||||||
@@ -2120,7 +2289,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
refresh_token:
|
refresh_token:
|
||||||
type: string
|
type: string
|
||||||
description: Только Android; веб передаёт токен в cookie
|
description: Android и desktop; веб передаёт токен в cookie
|
||||||
AuthTokens:
|
AuthTokens:
|
||||||
type: object
|
type: object
|
||||||
required: [access_token, token_type, expires_in, user]
|
required: [access_token, token_type, expires_in, user]
|
||||||
@@ -2135,7 +2304,7 @@ components:
|
|||||||
description: Срок жизни access-токена, секунды
|
description: Срок жизни access-токена, секунды
|
||||||
refresh_token:
|
refresh_token:
|
||||||
type: string
|
type: string
|
||||||
description: "Только для `client_type: android`"
|
description: "Для `client_type: android` и `client_type: desktop`"
|
||||||
user:
|
user:
|
||||||
$ref: "#/components/schemas/Me"
|
$ref: "#/components/schemas/Me"
|
||||||
UiPrefs:
|
UiPrefs:
|
||||||
@@ -3323,6 +3492,38 @@ components:
|
|||||||
like_rate:
|
like_rate:
|
||||||
type: number
|
type: number
|
||||||
nullable: true
|
nullable: true
|
||||||
|
AppClient:
|
||||||
|
type: string
|
||||||
|
enum: [android, windows, linux, macos]
|
||||||
|
AppClientFile:
|
||||||
|
type: object
|
||||||
|
required: [client, filename, size, download_url]
|
||||||
|
properties:
|
||||||
|
client:
|
||||||
|
$ref: "#/components/schemas/AppClient"
|
||||||
|
filename:
|
||||||
|
type: string
|
||||||
|
size:
|
||||||
|
type: integer
|
||||||
|
format: int64
|
||||||
|
sha256:
|
||||||
|
type: string
|
||||||
|
download_url:
|
||||||
|
type: string
|
||||||
|
AppClients:
|
||||||
|
type: object
|
||||||
|
required: [version, version_code, notes_md, clients]
|
||||||
|
properties:
|
||||||
|
version:
|
||||||
|
type: string
|
||||||
|
version_code:
|
||||||
|
type: integer
|
||||||
|
notes_md:
|
||||||
|
type: string
|
||||||
|
clients:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/AppClientFile"
|
||||||
AndroidRelease:
|
AndroidRelease:
|
||||||
type: object
|
type: object
|
||||||
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
|
||||||
|
let version = process.env.PIPU_VERSION ?? '0.0.0';
|
||||||
|
if (!process.env.PIPU_VERSION) {
|
||||||
|
try {
|
||||||
|
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
|
||||||
|
version = tag.replace(/^v/, '');
|
||||||
|
} catch {
|
||||||
|
// local untagged build
|
||||||
|
}
|
||||||
|
}
|
||||||
|
conf.version = version;
|
||||||
|
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
|
||||||
|
console.log(`desktop version ${version}`);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"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 appimage,deb",
|
||||||
|
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
|
||||||
|
"tauri": "tauri",
|
||||||
|
"icons": "tauri icon ../web/public/favicon.svg"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tauri-apps/cli": "^2.9.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
[package]
|
||||||
|
name = "pipu-desktop"
|
||||||
|
version = "0.0.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "pipu_desktop_lib"
|
||||||
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "pipu-desktop"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = ["tray-icon"] }
|
||||||
|
tauri-plugin-window-state = "2"
|
||||||
|
tauri-plugin-single-instance = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||||
|
axum = "0.8"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
url = "2"
|
||||||
|
souvlaki = "0.8.3"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "windows")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
|
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
strip = true
|
||||||
|
lto = "thin"
|
||||||
|
codegen-units = 1
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,157 @@
|
|||||||
|
mod media;
|
||||||
|
mod proxy;
|
||||||
|
mod store;
|
||||||
|
|
||||||
|
use media::Media;
|
||||||
|
use proxy::Proxy;
|
||||||
|
use serde::Serialize;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{
|
||||||
|
image::Image,
|
||||||
|
include_image,
|
||||||
|
menu::{Menu, MenuItem},
|
||||||
|
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||||
|
AppHandle, Manager, State,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AppState {
|
||||||
|
proxy: Proxy,
|
||||||
|
media: Media,
|
||||||
|
server: Mutex<Option<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
struct Bootstrap {
|
||||||
|
proxy_base: String,
|
||||||
|
server: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_main(app: &AppHandle) {
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = window.unminimize();
|
||||||
|
let _ = window.show();
|
||||||
|
let _ = window.set_focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
|
||||||
|
Ok(Bootstrap {
|
||||||
|
proxy_base: state.proxy.base.clone(),
|
||||||
|
server: store::load_server(&app)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
|
||||||
|
let server = proxy::validate_server(&url)?;
|
||||||
|
state.proxy.check(&server).await?;
|
||||||
|
store::save_server(&app, Some(&server))?;
|
||||||
|
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
|
||||||
|
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
|
||||||
|
Ok(server)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
|
||||||
|
store::save_server(&app, None)?;
|
||||||
|
store::save_user(&app, None)?;
|
||||||
|
store::save_refresh(None)?;
|
||||||
|
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
|
||||||
|
*state.server.lock().map_err(|e| e.to_string())? = None;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_get() -> Result<Option<String>, String> {
|
||||||
|
store::load_refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_set(token: String) -> Result<(), String> {
|
||||||
|
store::save_refresh(Some(&token))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn token_clear() -> Result<(), String> {
|
||||||
|
store::save_refresh(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
|
||||||
|
store::load_user(&app)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
|
||||||
|
store::save_user(&app, Some(&json))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn user_clear(app: AppHandle) -> Result<(), String> {
|
||||||
|
store::save_user(&app, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
fn media_set(
|
||||||
|
state: State<AppState>,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
album: String,
|
||||||
|
cover_url: Option<String>,
|
||||||
|
playing: bool,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||||
|
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||||
|
.setup(|app| {
|
||||||
|
let saved = store::load_server(app.handle())?;
|
||||||
|
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||||
|
let media = Media::new();
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
let _ = media.attach(app.handle().clone(), &window);
|
||||||
|
}
|
||||||
|
app.manage(AppState {
|
||||||
|
proxy,
|
||||||
|
media,
|
||||||
|
server: Mutex::new(saved),
|
||||||
|
});
|
||||||
|
|
||||||
|
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
|
||||||
|
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||||
|
let menu = Menu::with_items(app, &[&open, &quit])?;
|
||||||
|
let icon: Image = include_image!("icons/32x32.png");
|
||||||
|
TrayIconBuilder::new()
|
||||||
|
.icon(icon)
|
||||||
|
.menu(&menu)
|
||||||
|
.show_menu_on_left_click(false)
|
||||||
|
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||||
|
"open" => show_main(app),
|
||||||
|
"quit" => app.exit(0),
|
||||||
|
_ => {}
|
||||||
|
})
|
||||||
|
.on_tray_icon_event(|tray, event| {
|
||||||
|
if let TrayIconEvent::Click {
|
||||||
|
button: MouseButton::Left,
|
||||||
|
button_state: MouseButtonState::Up,
|
||||||
|
..
|
||||||
|
} = event
|
||||||
|
{
|
||||||
|
show_main(tray.app_handle());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.build(app)?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("desktop app failed to start");
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
pipu_desktop_lib::run();
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::{AppHandle, Emitter, WebviewWindow};
|
||||||
|
|
||||||
|
pub struct Media {
|
||||||
|
controls: Mutex<Option<MediaControls>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Media {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self { controls: Mutex::new(None) }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
|
||||||
|
#[cfg(not(target_os = "windows"))]
|
||||||
|
let hwnd = {
|
||||||
|
let _ = window;
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let mut controls = MediaControls::new(PlatformConfig {
|
||||||
|
dbus_name: "app.pipu.music.desktop",
|
||||||
|
display_name: "Pipu Music",
|
||||||
|
hwnd,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let handle = app.clone();
|
||||||
|
controls
|
||||||
|
.attach(move |event| {
|
||||||
|
let name = match event {
|
||||||
|
MediaControlEvent::Play => "play",
|
||||||
|
MediaControlEvent::Pause => "pause",
|
||||||
|
MediaControlEvent::Toggle => "toggle",
|
||||||
|
MediaControlEvent::Next => "next",
|
||||||
|
MediaControlEvent::Previous => "previous",
|
||||||
|
MediaControlEvent::Stop => "stop",
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
let _ = handle.emit("media", name);
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
|
||||||
|
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
|
||||||
|
let Some(controls) = slot.as_mut() else { return Ok(()); };
|
||||||
|
controls
|
||||||
|
.set_metadata(MediaMetadata {
|
||||||
|
title: Some(title),
|
||||||
|
artist: Some(artist),
|
||||||
|
album: Some(album),
|
||||||
|
cover_url,
|
||||||
|
duration: None,
|
||||||
|
})
|
||||||
|
.map_err(|e| format!("{e:?}"))?;
|
||||||
|
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
|
||||||
|
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
//! Loopback-only streaming transport. The random per-process path is a capability;
|
||||||
|
//! it never leaves the app except for OS media artwork requests. No open proxy.
|
||||||
|
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::{sync::{Arc, RwLock}, time::Duration};
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Proxy {
|
||||||
|
pub server: Arc<RwLock<Option<String>>>,
|
||||||
|
pub base: String,
|
||||||
|
secret: String,
|
||||||
|
authority: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_server(raw: &str) -> Result<String, String> {
|
||||||
|
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
|
||||||
|
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|
||||||
|
|| !url.username().is_empty() || url.password().is_some()
|
||||||
|
|| url.query().is_some() || url.fragment().is_some()
|
||||||
|
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
|
||||||
|
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Proxy {
|
||||||
|
pub async fn start(server: Option<String>) -> Result<Self, String> {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
|
||||||
|
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
|
||||||
|
let secret = uuid::Uuid::new_v4().simple().to_string();
|
||||||
|
let proxy = Self {
|
||||||
|
server: Arc::new(RwLock::new(server)),
|
||||||
|
base: format!("http://{authority}/{secret}"), secret, authority,
|
||||||
|
client: reqwest::Client::builder()
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.connect_timeout(Duration::from_secs(10))
|
||||||
|
.read_timeout(Duration::from_secs(60))
|
||||||
|
.build().map_err(|e| e.to_string())?,
|
||||||
|
};
|
||||||
|
let router = Router::new().fallback(forward).with_state(proxy.clone());
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
|
||||||
|
});
|
||||||
|
Ok(proxy)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn check(&self, server: &str) -> Result<(), String> {
|
||||||
|
let response = self.client.get(format!("{server}/api/v1/server-info"))
|
||||||
|
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
|
||||||
|
if !response.status().is_success() { return Err("Server unavailable".into()); }
|
||||||
|
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
|
||||||
|
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn allowed_origin(origin: &str) -> bool {
|
||||||
|
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|
||||||
|
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
|
||||||
|
// the configured server; never route arbitrary URLs supplied in server content.
|
||||||
|
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
|
||||||
|
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
|
||||||
|
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
|
||||||
|
.map(|p| format!("{base}{p}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
|
||||||
|
Value::Object(fields) => for (key, v) in fields {
|
||||||
|
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
|
||||||
|
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
|
||||||
|
} else { rewrite_json(v, server, base); }
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||||
|
if let Some(origin) = origin {
|
||||||
|
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
|
||||||
|
headers.insert("vary", HeaderValue::from_static("Origin"));
|
||||||
|
}
|
||||||
|
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||||
|
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||||
|
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||||
|
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||||
|
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|
||||||
|
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
|
||||||
|
{ return StatusCode::FORBIDDEN.into_response(); }
|
||||||
|
let prefix = format!("/{}/api/", proxy.secret);
|
||||||
|
let path = request.uri().path().to_owned();
|
||||||
|
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||||
|
let tail = tail.to_owned();
|
||||||
|
// Prevent URL normalization escaping the configured API path, including encoded segments.
|
||||||
|
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|
||||||
|
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
|
||||||
|
{ return StatusCode::BAD_REQUEST.into_response(); }
|
||||||
|
if origin.is_none() && !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||||
|
return StatusCode::FORBIDDEN.into_response();
|
||||||
|
}
|
||||||
|
let mut response = if request.method() == Method::OPTIONS {
|
||||||
|
StatusCode::NO_CONTENT.into_response()
|
||||||
|
} else {
|
||||||
|
match transfer(&proxy, request, tail).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cors(response.headers_mut(), origin.as_deref());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||||
|
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||||
|
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||||
|
let url = format!("{server}/api/{tail}{query}");
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||||
|
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||||
|
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||||
|
}
|
||||||
|
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||||
|
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||||
|
}
|
||||||
|
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||||
|
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||||
|
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||||
|
let body = if json && parts.method != Method::HEAD {
|
||||||
|
// Bound only JSON, never buffer a song or an upload in memory.
|
||||||
|
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
rewrite_json(&mut value, &server, &proxy.base);
|
||||||
|
headers.remove("content-length");
|
||||||
|
headers.remove("etag");
|
||||||
|
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||||
|
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||||
|
let mut response = Response::new(body);
|
||||||
|
*response.status_mut() = status;
|
||||||
|
*response.headers_mut() = headers;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn validates_server_and_scopes_media_urls() {
|
||||||
|
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||||
|
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||||
|
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||||
|
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||||
|
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||||
|
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||||
|
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||||
|
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
const SERVICE: &str = "app.pipu.music.desktop";
|
||||||
|
|
||||||
|
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => {
|
||||||
|
let s = s.trim().to_string();
|
||||||
|
Ok((!s.is_empty()).then_some(s))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match server {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||||
|
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||||
|
match refresh_entry()?.get_password() {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||||
|
let entry = refresh_entry()?;
|
||||||
|
match token {
|
||||||
|
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||||
|
None => match entry.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match json {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Pipu Music",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"identifier": "app.pipu.music.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm --filter @pipu/web dev",
|
||||||
|
"devUrl": "http://127.0.0.1:5173",
|
||||||
|
"beforeBuildCommand": "pnpm --filter @pipu/web build",
|
||||||
|
"frontendDist": "../../web/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis", "appimage", "deb", "rpm"],
|
||||||
|
"category": "Music",
|
||||||
|
"shortDescription": "Self-hosted music player",
|
||||||
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
||||||
|
"linux": {
|
||||||
|
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
||||||
|
"appimage": { "bundleMediaFramework": true }
|
||||||
|
},
|
||||||
|
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { unwrap, type Track } from '@pipu/core';
|
import { unwrap, type Track } from '@pipu/core';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
|
|||||||
const session = useSession();
|
const session = useSession();
|
||||||
const current = usePlayerState().track;
|
const current = usePlayerState().track;
|
||||||
const [tab, setTab] = useState<Tab>('likes');
|
const [tab, setTab] = useState<Tab>('likes');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const list = useInfiniteQuery({
|
const list = useInfiniteQuery({
|
||||||
queryKey: ['m-collection', tab],
|
queryKey: ['m-collection', tab],
|
||||||
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
|
|||||||
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||||
|
|
||||||
const tabs = (
|
const tabs = (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
|
||||||
<Segmented<Tab>
|
<Segmented<Tab>
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
|
|||||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function Screens() {
|
|||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="album/[id]" />
|
<Stack.Screen name="album/[id]" />
|
||||||
<Stack.Screen name="artist/[id]" />
|
<Stack.Screen name="artist/[id]" />
|
||||||
|
<Stack.Screen name="playlists" />
|
||||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||||
|
import { trackShareUrl } from '@pipu/core';
|
||||||
|
|
||||||
import { useUser } from '../lib/session';
|
import { useSessionCtx, useUser } from '../lib/session';
|
||||||
import { player } from '../player/controller';
|
import { player } from '../player/controller';
|
||||||
import { usePlayerState } from '../player/hooks';
|
import { usePlayerState } from '../player/hooks';
|
||||||
import { useColors } from '../theme';
|
import { useColors } from '../theme';
|
||||||
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const prefs = useUser().ui_prefs;
|
const prefs = useUser().ui_prefs;
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
const [barWidth, setBarWidth] = useState(1);
|
const [barWidth, setBarWidth] = useState(1);
|
||||||
const track = st.track;
|
const track = st.track;
|
||||||
|
|
||||||
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
|
|||||||
const duration = st.durationMs || track.duration_ms;
|
const duration = st.durationMs || track.duration_ms;
|
||||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const share = async () => {
|
||||||
|
if (!server || sharing) return;
|
||||||
|
setSharing(true);
|
||||||
|
try {
|
||||||
|
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t('player.share'), t('player.share_error'));
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
|
|||||||
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
||||||
<DownloadButton track={track} />
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||||
|
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
|
||||||
|
<Icon name="share" size={24} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<DownloadButton track={track} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ alignItems: 'center' }}>
|
<View style={{ alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||||
|
import { useSession, useUser } from '../lib/session';
|
||||||
|
import { player } from '../player/controller';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
|
type Playlist = components['schemas']['Playlist'];
|
||||||
|
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
|
||||||
|
|
||||||
|
export default function PlaylistsScreen() {
|
||||||
|
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
|
||||||
|
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function List({ track }: { track?: string }) {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
|
const add = useMutation({ mutationFn: async (id: string) => {
|
||||||
|
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||||
|
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
|
||||||
|
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||||
|
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||||
|
return <Screen><FlatList
|
||||||
|
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
|
||||||
|
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
|
||||||
|
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||||
|
ListHeaderComponent={<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
|
||||||
|
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
|
||||||
|
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
|
||||||
|
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
|
||||||
|
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
|
</View>}
|
||||||
|
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
|
||||||
|
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
|
||||||
|
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
|
||||||
|
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
|
||||||
|
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
|
||||||
|
}} />}</Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
|
||||||
|
const session = useSession();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [original] = useState(playlist);
|
||||||
|
const [name, setName] = useState(playlist?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||||
|
const save = useMutation({ mutationFn: async () => {
|
||||||
|
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
|
||||||
|
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||||
|
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||||
|
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
|
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||||
|
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
|
||||||
|
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
|
||||||
|
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
|
||||||
|
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||||
|
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
|
||||||
|
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
|
||||||
|
</ScrollView></Screen></Modal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ id }: { id: string }) {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [edit, setEdit] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const [playError, setPlayError] = useState<unknown>();
|
||||||
|
const key = ['m-playlists', user.id, id];
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
|
const p = query.data;
|
||||||
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||||
|
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
|
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||||
|
const play = (shuffle: boolean) => {
|
||||||
|
const tracks = p?.tracks.filter(t => t.available) ?? [];
|
||||||
|
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
|
||||||
|
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
|
||||||
|
};
|
||||||
|
const move = (index: number, delta: number) => {
|
||||||
|
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||||
|
};
|
||||||
|
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||||
|
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||||
|
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
|
||||||
|
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
|
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||||
|
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||||
|
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||||
|
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||||
|
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||||
|
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||||
|
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
|
||||||
|
])} />
|
||||||
|
</>}
|
||||||
|
</View>}
|
||||||
|
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
|
||||||
|
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
|
||||||
|
{manage && <View style={{padding:12,gap:6}}>
|
||||||
|
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
|
||||||
|
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
|
||||||
|
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
|
||||||
|
</View>}
|
||||||
|
</View>}
|
||||||
|
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { ApiError, type Track } from '@pipu/core';
|
import { ApiError, type Track } from '@pipu/core';
|
||||||
import type { ComponentProps, ReactNode } from 'react';
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
import { useState, useSyncExternalStore } from 'react';
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
@@ -199,6 +200,7 @@ export function TrackRow({
|
|||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const liked = track.reaction === 'like';
|
const liked = track.reaction === 'like';
|
||||||
|
const router = useRouter();
|
||||||
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -233,6 +235,10 @@ export function TrackRow({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
{trailing}
|
{trailing}
|
||||||
|
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
|
||||||
|
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
|
||||||
|
<Icon name="playlist-add" size={22} />
|
||||||
|
</Pressable>
|
||||||
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
||||||
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { expect, test } from '@playwright/test';
|
||||||
|
|
||||||
|
for (const width of [320, 1280]) {
|
||||||
|
test(`playlists at ${width}px`, async ({ page }, info) => {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
|
||||||
|
await page.goto('/login');
|
||||||
|
await page.getByLabel('Логин', { exact: true }).fill('alice');
|
||||||
|
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
|
||||||
|
await page.getByRole('button', { name: 'Войти', exact: true }).click();
|
||||||
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||||
|
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
|
||||||
|
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
|
||||||
|
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
|
||||||
|
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
|
||||||
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
|
||||||
|
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
|
||||||
|
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
|
||||||
|
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
|
||||||
|
await expect(page.getByRole('dialog')).toHaveCount(0);
|
||||||
|
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
|
||||||
|
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
|
||||||
|
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
|
||||||
|
await page.getByRole('button', { name: 'Управлять треками' }).click();
|
||||||
|
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
|
||||||
|
await expect.poll(() => page.evaluate(() => {
|
||||||
|
const main = document.querySelector('main')!;
|
||||||
|
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
|
||||||
|
})).toBeLessThanOrEqual(1);
|
||||||
|
await page.screenshot({ path: info.outputPath('playlist.png') });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -281,6 +281,30 @@ describe('search box', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('player', () => {
|
describe('player', () => {
|
||||||
|
it('opens a shared track after login and copies its page URL from the player', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const path = '/track/00000000-0000-4000-8000-000000000101';
|
||||||
|
const { user, router } = await renderApp([alice], path);
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
await user.type(screen.getByLabelText('Логин'), alice.username);
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), alice.password);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||||
|
expect(router.state.location.pathname).toBe(path);
|
||||||
|
await user.click(screen.getByRole('listitem'));
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||||
|
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||||
|
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a missing shared track without starting playback', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
await renderApp([alice], '/track/missing', { signedInAs: alice });
|
||||||
|
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
||||||
const alice = makeUser('alice', 'user');
|
const alice = makeUser('alice', 'user');
|
||||||
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
@@ -383,3 +407,55 @@ describe('safeRedirect', () => {
|
|||||||
expect(safeRedirect(input)).toBe(expected);
|
expect(safeRedirect(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('playlists', () => {
|
||||||
|
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'В дорогу');
|
||||||
|
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'В дорогу' });
|
||||||
|
const id = [...api.playlists.keys()][0]!;
|
||||||
|
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
|
||||||
|
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
|
||||||
|
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
|
||||||
|
await router.navigate({ to: '/playlist/$id', params: { id } });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||||
|
await user.clear(screen.getByLabelText('Название'));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Вечер');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Вечер' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
|
||||||
|
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Плейлисты' });
|
||||||
|
expect(api.playlists.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Первый');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Первый' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||||
|
const item = [...api.playlists.values()][0]!;
|
||||||
|
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
|
||||||
|
await user.type(screen.getByLabelText('Название'), ' изменён');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,9 +18,13 @@ const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((
|
|||||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
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 SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||||
|
import type { DesktopHandle } from '../desktop';
|
||||||
import { AlbumPage } from '../pages/AlbumPage';
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
|
import { ConnectPage } from '../pages/ConnectPage';
|
||||||
|
import { TrackPage } from '../pages/TrackPage';
|
||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
|
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||||
import { HomePage } from '../pages/HomePage';
|
import { HomePage } from '../pages/HomePage';
|
||||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||||
import { LoginPage } from '../pages/LoginPage';
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
@@ -32,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
|
|||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
session: AuthSession;
|
session: AuthSession;
|
||||||
|
desktop: DesktopHandle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||||
@@ -50,9 +55,12 @@ export function safeRedirect(target: unknown): string {
|
|||||||
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
|
||||||
const user = signedInUser(session);
|
const user = signedInUser(session);
|
||||||
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
if (!user) {
|
||||||
|
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
|
||||||
|
throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +69,26 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|||||||
errorComponent: RouteError,
|
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({
|
const loginRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
@@ -70,6 +96,7 @@ const loginRoute = createRoute({
|
|||||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||||
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||||
beforeLoad: ({ context, search }) => {
|
beforeLoad: ({ context, search }) => {
|
||||||
|
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
||||||
const user = signedInUser(context.session);
|
const user = signedInUser(context.session);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
@@ -83,7 +110,7 @@ const setupAdminRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/admin',
|
path: '/setup/admin',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="admin" />,
|
component: () => <SetupPage variant="admin" />,
|
||||||
@@ -93,7 +120,7 @@ const setupPasswordRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/password',
|
path: '/setup/password',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="password" />,
|
component: () => <SetupPage variant="password" />,
|
||||||
@@ -104,7 +131,7 @@ const appRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
id: 'app',
|
id: 'app',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
if (home !== '/') throw redirect({ to: home });
|
if (home !== '/') throw redirect({ to: home });
|
||||||
return { user };
|
return { user };
|
||||||
@@ -117,6 +144,9 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||||
|
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||||
|
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||||
|
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||||
|
|
||||||
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||||
|
|
||||||
@@ -240,12 +270,17 @@ const adminSettingsRoute = createRoute({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
|
downloadAppRoute,
|
||||||
androidAppRoute,
|
androidAppRoute,
|
||||||
|
connectRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupAdminRoute,
|
setupAdminRoute,
|
||||||
setupPasswordRoute,
|
setupPasswordRoute,
|
||||||
appRoute.addChildren([
|
appRoute.addChildren([
|
||||||
homeRoute,
|
homeRoute,
|
||||||
|
trackRoute,
|
||||||
|
playlistsRoute,
|
||||||
|
playlistRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
searchRoute,
|
searchRoute,
|
||||||
historyRoute,
|
historyRoute,
|
||||||
@@ -265,8 +300,12 @@ const routeTree = rootRoute.addChildren([
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
export function createAppRouter(
|
||||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
session: AuthSession,
|
||||||
|
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
||||||
|
) {
|
||||||
|
const { desktop = null, ...rest } = opts;
|
||||||
|
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
||||||
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||||
session.subscribe(() => void router.invalidate());
|
session.subscribe(() => void router.invalidate());
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createDesktop, isDesktop } from './desktop';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('desktop bridge', () => {
|
||||||
|
it('is inactive in the browser', () => {
|
||||||
|
expect(isDesktop()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bootstraps through the Tauri invoke surface', async () => {
|
||||||
|
const invoke = vi.fn(async (cmd: string) => {
|
||||||
|
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||||
|
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||||
|
const desktop = await createDesktop();
|
||||||
|
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||||
|
expect(desktop?.server).toBe('https://music.example');
|
||||||
|
await desktop?.setServer('https://music.example/pipu');
|
||||||
|
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||||
|
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||||
|
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Tauri = {
|
||||||
|
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||||
|
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
function api(): Tauri | undefined {
|
||||||
|
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDesktop(): boolean {
|
||||||
|
return api() !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const tauri = api();
|
||||||
|
if (!tauri) throw new Error('Tauri API unavailable');
|
||||||
|
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesktopHandle = {
|
||||||
|
proxyBase: string;
|
||||||
|
server: string | null;
|
||||||
|
setServer: (url: string) => Promise<string>;
|
||||||
|
clearServer: () => Promise<void>;
|
||||||
|
refreshStore: RefreshStore;
|
||||||
|
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||||
|
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||||
|
|
||||||
|
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||||
|
return createElement(DesktopContext.Provider, { value }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDesktop(): DesktopHandle | null {
|
||||||
|
return useContext(DesktopContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let active: DesktopHandle | null = null;
|
||||||
|
|
||||||
|
export function desktopHandle(): DesktopHandle | null {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||||
|
if (!isDesktop()) return null;
|
||||||
|
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||||
|
const handle: DesktopHandle = {
|
||||||
|
proxyBase: boot.proxyBase,
|
||||||
|
server: boot.server,
|
||||||
|
setServer: async (url) => {
|
||||||
|
const server = await invoke<string>('set_server', { url });
|
||||||
|
handle.server = server;
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
clearServer: async () => {
|
||||||
|
await invoke('clear_server');
|
||||||
|
handle.server = null;
|
||||||
|
},
|
||||||
|
refreshStore: {
|
||||||
|
get: () => invoke<string | null>('token_get'),
|
||||||
|
set: (token) => invoke('token_set', { token }),
|
||||||
|
clear: async () => {
|
||||||
|
await invoke('token_clear');
|
||||||
|
await invoke('user_clear');
|
||||||
|
},
|
||||||
|
getUser: async () => {
|
||||||
|
const json = await invoke<string | null>('user_get');
|
||||||
|
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||||
|
},
|
||||||
|
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||||
|
},
|
||||||
|
listenMedia: async (handler) => {
|
||||||
|
const tauri = api();
|
||||||
|
if (!tauri) return () => undefined;
|
||||||
|
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||||
|
},
|
||||||
|
setMedia: (info) => {
|
||||||
|
void invoke('media_set', {
|
||||||
|
title: info.title,
|
||||||
|
artist: info.artist,
|
||||||
|
album: info.album,
|
||||||
|
coverUrl: info.coverUrl ?? null,
|
||||||
|
playing: info.playing,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
active = handle;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
@@ -277,4 +277,5 @@
|
|||||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||||
.end { gap: 4px; }
|
.end { gap: 4px; }
|
||||||
.like { opacity: 1; width: 44px; height: 44px; }
|
.like { opacity: 1; width: 44px; height: 44px; }
|
||||||
|
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Fragment, useRef } from 'react';
|
import { Fragment, useRef, type ReactNode } from 'react';
|
||||||
|
import { AddToPlaylist } from './playlists';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
@@ -115,6 +116,7 @@ export function TrackList({
|
|||||||
rowMeta,
|
rowMeta,
|
||||||
lead,
|
lead,
|
||||||
disliked,
|
disliked,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
showAlbum?: boolean;
|
showAlbum?: boolean;
|
||||||
@@ -126,6 +128,7 @@ export function TrackList({
|
|||||||
lead?: (t: Track, i: number) => string;
|
lead?: (t: Track, i: number) => string;
|
||||||
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||||
disliked?: boolean;
|
disliked?: boolean;
|
||||||
|
actions?: (track: Track, index: number) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
@@ -154,7 +157,7 @@ export function TrackList({
|
|||||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||||
@@ -181,7 +184,8 @@ export function TrackList({
|
|||||||
{tr.album.title}
|
{tr.album.title}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<span className={s.end}>
|
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||||
|
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||||
{disliked ? (
|
{disliked ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||||
|
import { queueIcon } from '../shared/ui/icons';
|
||||||
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
|
export type Playlist = components['schemas']['Playlist'];
|
||||||
|
export type PlaylistInput = components['schemas']['PlaylistInput'];
|
||||||
|
|
||||||
|
export function usePlaylists(enabled = true) {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({ queryKey: ['playlists'], enabled,
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaylist(id: string) {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({ queryKey: ['playlists', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||||
|
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
||||||
|
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [original] = useState(playlist);
|
||||||
|
const [name, setName] = useState(playlist?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||||
|
const save = useMutation({ mutationFn: async () => {
|
||||||
|
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
|
||||||
|
return original
|
||||||
|
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||||
|
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||||
|
}, onSuccess: p => {
|
||||||
|
cache.setQueryData(['playlists', p.id], p);
|
||||||
|
void cache.invalidateQueries({ queryKey: ['playlists'] });
|
||||||
|
onSaved(p);
|
||||||
|
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||||
|
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
|
||||||
|
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
|
||||||
|
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
|
||||||
|
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
|
||||||
|
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
|
||||||
|
</form>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||||
|
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const list = usePlaylists();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const add = useMutation({ mutationFn: async (id: string) => {
|
||||||
|
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||||
|
const body = playlistInput(p);
|
||||||
|
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
|
||||||
|
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||||
|
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
|
||||||
|
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
|
||||||
|
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
|
||||||
|
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
|
||||||
|
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||||
|
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
|
||||||
|
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return <>
|
||||||
|
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
|
||||||
|
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
||||||
|
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
+12
-3
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createAppRouter } from './app/router';
|
import { createAppRouter } from './app/router';
|
||||||
import { applyTheme } from './app/session';
|
import { applyTheme } from './app/session';
|
||||||
|
import { createDesktop, DesktopProvider } from './desktop';
|
||||||
|
|
||||||
const LOCALE_KEY = 'pipu.locale';
|
const LOCALE_KEY = 'pipu.locale';
|
||||||
|
|
||||||
@@ -63,15 +64,23 @@ i18n.on('languageChanged', (l) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
const desktop = await createDesktop();
|
||||||
|
const session = new AuthSession({
|
||||||
|
clientType: desktop ? 'desktop' : 'web',
|
||||||
|
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||||
|
baseUrl: desktop?.proxyBase,
|
||||||
|
refreshStore: desktop?.refreshStore,
|
||||||
|
});
|
||||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||||
await session.restore();
|
await session.restore();
|
||||||
const router = createAppRouter(session);
|
const router = createAppRouter(session, { desktop });
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<App session={session} router={router} />
|
<DesktopProvider value={desktop}>
|
||||||
|
<App session={session} router={router} />
|
||||||
|
</DesktopProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { Cover, TrackList } from '../entities/catalog';
|
import { Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
@@ -45,6 +46,7 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
{a && a.tracks.some((tr) => tr.available) && (
|
{a && a.tracks.some((tr) => tr.available) && (
|
||||||
<div className={c.heroActions}>
|
<div className={c.heroActions}>
|
||||||
|
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
|
||||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||||
{t('catalog.play')}
|
{t('catalog.play')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,25 +1,27 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { formatBytes } from '../shared/lib/format';
|
import { formatBytes } from '../shared/lib/format';
|
||||||
|
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
|
||||||
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
|
||||||
import { Icon } from '../shared/ui/ui';
|
import { Icon } from '../shared/ui/ui';
|
||||||
import { AuthLayout } from './AuthLayout';
|
import { AuthLayout } from './AuthLayout';
|
||||||
import s from './auth.module.css';
|
import s from './auth.module.css';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
|
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
|
||||||
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
|
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
|
||||||
*/
|
*/
|
||||||
export function AppDownloadPage() {
|
export function AppDownloadPage() {
|
||||||
const { t, i18n } = useTranslation(['auth', 'common']);
|
const { t, i18n } = useTranslation(['auth', 'common']);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
const detected = useMemo(() => detectAppClient(), []);
|
||||||
const latest = useQuery({
|
const latest = useQuery({
|
||||||
queryKey: ['android-latest'],
|
queryKey: ['app-clients'],
|
||||||
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
|
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
|
||||||
staleTime: 10 * 60_000,
|
staleTime: 10 * 60_000,
|
||||||
});
|
});
|
||||||
const server = location.origin;
|
const server = location.origin;
|
||||||
@@ -28,39 +30,59 @@ export function AppDownloadPage() {
|
|||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 1500);
|
setTimeout(() => setCopied(false), 1500);
|
||||||
});
|
});
|
||||||
const r = latest.data;
|
const data = latest.data;
|
||||||
|
const files = Object.fromEntries((data?.clients ?? []).map((c) => [c.client, c]));
|
||||||
|
const primary = detected ? files[detected] : undefined;
|
||||||
|
const others = otherAppClients(detected).filter((id) => files[id]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
|
||||||
<span className={s.badge}>Android</span>
|
<span className={s.badge}>{detected ? t(`app.platform.${detected}`) : t('app.badge')}</span>
|
||||||
<h1>{t('app.title')}</h1>
|
<h1>{t('app.title')}</h1>
|
||||||
<p className={s.lead}>{t('app.subtitle')}</p>
|
<p className={s.lead}>{t('app.subtitle')}</p>
|
||||||
{latest.isPending ? null : r ? (
|
{latest.isPending ? null : data ? (
|
||||||
<>
|
<>
|
||||||
<a className={s.download} href={r.download_url} download>
|
{primary ? (
|
||||||
<Icon src={downloadIcon} size={22} />
|
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
|
||||||
<span>
|
) : detected ? (
|
||||||
{t('app.download')}
|
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
|
||||||
<span className={s.downloadMeta}>
|
) : null}
|
||||||
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
|
{(primary || others.length > 0) && (
|
||||||
</span>
|
<ol className={s.steps}>
|
||||||
</span>
|
{detected && <li>{t(`app.step_install.${detected}`)}</li>}
|
||||||
</a>
|
<li>
|
||||||
<ol className={s.steps}>
|
{t('app.step_server')}
|
||||||
<li>{t('app.step_install')}</li>
|
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
||||||
<li>
|
<code>{server}</code>
|
||||||
{t('app.step_server')}
|
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||||
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
|
</button>
|
||||||
<code>{server}</code>
|
</li>
|
||||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
|
||||||
</button>
|
</ol>
|
||||||
</li>
|
)}
|
||||||
<li>{t('app.step_updates')}</li>
|
{primary?.sha256 && (
|
||||||
</ol>
|
<details className={s.details}>
|
||||||
<details className={s.details}>
|
<summary>{t('app.checksum')}</summary>
|
||||||
<summary>{t('app.checksum')}</summary>
|
<code>{primary.sha256}</code>
|
||||||
<code>{r.sha256}</code>
|
</details>
|
||||||
</details>
|
)}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<div className={s.otherClients}>
|
||||||
|
<p className={s.otherTitle}>{t('app.other')}</p>
|
||||||
|
{others.map((id) => {
|
||||||
|
const file = files[id]!;
|
||||||
|
return (
|
||||||
|
<a key={id} className={s.downloadAlt} href={file.download_url} download={file.filename}>
|
||||||
|
{t(`app.download.${id}`)}
|
||||||
|
<span>
|
||||||
|
{t('app.version', { version: data.version })} · {formatBytes(file.size, i18n.language)}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!primary && others.length === 0 && <div className={s.hint}>{t('app.unavailable')}</div>}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className={s.hint}>{t('app.unavailable')}</div>
|
<div className={s.hint}>{t('app.unavailable')}</div>
|
||||||
@@ -68,3 +90,16 @@ export function AppDownloadPage() {
|
|||||||
</AuthLayout>
|
</AuthLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function DownloadButton({ client, href, meta }: { client: AppClient; href: string; meta: string }) {
|
||||||
|
const { t } = useTranslation('auth');
|
||||||
|
return (
|
||||||
|
<a className={s.download} href={href} download>
|
||||||
|
<Icon src={downloadIcon} size={22} />
|
||||||
|
<span>
|
||||||
|
{t(`app.download.${client}`)}
|
||||||
|
<span className={s.downloadMeta}>{meta}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRouter } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
export function ConnectPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const router = useRouter();
|
||||||
|
const [address, setAddress] = useState(desktop?.server ?? '');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!desktop) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await desktop.setServer(address);
|
||||||
|
await router.invalidate();
|
||||||
|
await router.navigate({ to: '/login' });
|
||||||
|
} catch (err) {
|
||||||
|
const text = err instanceof Error ? err.message : '';
|
||||||
|
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<h1>{t('server.title')}</h1>
|
||||||
|
<p className={s.lead}>{t('app.tagline')}</p>
|
||||||
|
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||||
|
<Field
|
||||||
|
label={t('server.address')}
|
||||||
|
value={address}
|
||||||
|
onChange={setAddress}
|
||||||
|
autoComplete="url"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError>{error}</FormError>
|
||||||
|
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
|
||||||
|
{t('mobile.connect')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
import { AuthLayout } from './AuthLayout';
|
import { AuthLayout } from './AuthLayout';
|
||||||
@@ -11,6 +12,7 @@ import s from './auth.module.css';
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation(['auth', 'common']);
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -41,7 +43,13 @@ export function LoginPage() {
|
|||||||
footer={
|
footer={
|
||||||
info.data ? (
|
info.data ? (
|
||||||
<>
|
<>
|
||||||
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
pipu {info.data.version}
|
||||||
|
{desktop ? null : (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<a href="/download">{t('app.link')}</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
||||||
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import p from './playlists.module.css';
|
||||||
|
|
||||||
|
export function PlaylistsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const list = usePlaylists();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? [];
|
||||||
|
return <div className={s.page}>
|
||||||
|
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||||||
|
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||||||
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||||
|
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
||||||
|
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||||
|
<Cover album={item} size="100%" />
|
||||||
|
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||||
|
{item.description && <span className={p.description}>{item.description}</span>}
|
||||||
|
</Link>)}</div>
|
||||||
|
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const query = usePlaylist(id);
|
||||||
|
const errorText = useErrorText();
|
||||||
|
return <div className={s.page}>
|
||||||
|
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||||
|
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||||
|
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||||
|
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const player = usePlayer();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [edit, setEdit] = useState(false);
|
||||||
|
const [remove, setRemove] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||||||
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||||||
|
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
||||||
|
})), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||||
|
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })),
|
||||||
|
onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } });
|
||||||
|
const play = (shuffle: boolean) => {
|
||||||
|
const tracks = playlist.tracks.filter(x => x.available);
|
||||||
|
if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; }
|
||||||
|
if (tracks.length) player.playList(tracks, 0, context);
|
||||||
|
};
|
||||||
|
const move = (index: number, delta: number) => {
|
||||||
|
const ids = playlist.tracks.map(x => x.id);
|
||||||
|
[ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!];
|
||||||
|
save.mutate(ids);
|
||||||
|
};
|
||||||
|
return <>
|
||||||
|
<header className={c.colHero}>
|
||||||
|
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
||||||
|
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||||||
|
<p className={p.description}>{playlist.description}</p>
|
||||||
|
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||||
|
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||||
|
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||||
|
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||||
|
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||||
|
</div>
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
||||||
|
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||||
|
actions={manage ? (track, i) => <span className={p.actions}>
|
||||||
|
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||||
|
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
||||||
|
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||||
|
</span> : undefined} />
|
||||||
|
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||||
|
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
||||||
|
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||||
|
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||||
|
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession, useUser } from '../app/session';
|
import { useSession, useUser } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||||
import { formatDateTime } from '../shared/lib/format';
|
import { formatDateTime } from '../shared/lib/format';
|
||||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||||
@@ -297,6 +299,8 @@ function PasswordSection() {
|
|||||||
function SessionsSection() {
|
function SessionsSection() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const sessions = useQuery({
|
const sessions = useQuery({
|
||||||
@@ -333,6 +337,19 @@ function SessionsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||||
<div>
|
<div>
|
||||||
|
{desktop && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
await desktop.clearServer();
|
||||||
|
await session.logout();
|
||||||
|
await navigate({ to: '/connect' });
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('mobile.change_server')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="danger" onClick={() => void session.logout(true)}>
|
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||||
{t('settings.logout_all')}
|
{t('settings.logout_all')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { NotFound } from './NotFound';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
|
export function TrackPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const track = useQuery({
|
||||||
|
queryKey: ['track', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
if (track.isError) return <NotFound error={track.error} />;
|
||||||
|
const tr = track.data;
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<header className={c.hero}>
|
||||||
|
{tr && <Cover album={tr.album} size={200} />}
|
||||||
|
<div className={c.heroBody}>
|
||||||
|
<span className={c.kind}>{t('player.track_page')}</span>
|
||||||
|
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
|
||||||
|
{tr && <>
|
||||||
|
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
|
||||||
|
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -165,7 +165,7 @@
|
|||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Страница Android-приложения (/android). */
|
/* Страница приложений (/download). */
|
||||||
.download {
|
.download {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -238,6 +238,40 @@
|
|||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.otherClients {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otherTitle {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAlt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAlt span {
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
||||||
|
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
|
||||||
|
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
|
||||||
|
.card strong { overflow-wrap: anywhere; }
|
||||||
|
.card span { font-size: 13px; }
|
||||||
|
.card:hover { background: #ffffff10; }
|
||||||
|
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
.actions { display: flex; align-items: center; }
|
||||||
|
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
||||||
|
.actions button:disabled { opacity: .3; cursor: default; }
|
||||||
|
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type Track,
|
type Track,
|
||||||
} from '@pipu/core';
|
} from '@pipu/core';
|
||||||
|
|
||||||
|
import { desktopHandle } from '../desktop';
|
||||||
|
|
||||||
export type { PlayContext, Track };
|
export type { PlayContext, Track };
|
||||||
|
|
||||||
export interface WaveInfo {
|
export interface WaveInfo {
|
||||||
@@ -97,6 +99,7 @@ export class Player {
|
|||||||
private timers: ReturnType<typeof setInterval>[] = [];
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||||
private waveLoading = false;
|
private waveLoading = false;
|
||||||
|
private restoreVersion = 0;
|
||||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||||
onReaction: (() => void) | null = null;
|
onReaction: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -120,6 +123,7 @@ export class Player {
|
|||||||
};
|
};
|
||||||
this.bindAudio();
|
this.bindAudio();
|
||||||
this.bindMediaSession();
|
this.bindMediaSession();
|
||||||
|
this.bindDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||||
@@ -136,9 +140,13 @@ export class Player {
|
|||||||
|
|
||||||
getState = () => this.state;
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||||
|
private desktopUnlisten: (() => void) | null = null;
|
||||||
|
|
||||||
private update(patch: Partial<PlayerState>) {
|
private update(patch: Partial<PlayerState>) {
|
||||||
this.state = { ...this.state, ...patch };
|
this.state = { ...this.state, ...patch };
|
||||||
for (const fn of this.listeners) fn();
|
for (const fn of this.listeners) fn();
|
||||||
|
this.syncDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncQueue() {
|
private syncQueue() {
|
||||||
@@ -159,6 +167,7 @@ export class Player {
|
|||||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||||
const target = tracks[index];
|
const target = tracks[index];
|
||||||
if (!target || !playable.length) return;
|
if (!target || !playable.length) return;
|
||||||
|
this.restoreVersion++;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
this.context = context;
|
this.context = context;
|
||||||
@@ -171,6 +180,7 @@ export class Player {
|
|||||||
|
|
||||||
/** Запустить волну (или перезапустить с новым контекстом). */
|
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||||
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||||
|
this.restoreVersion++;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
const res = unwrap(
|
const res = unwrap(
|
||||||
@@ -275,7 +285,7 @@ export class Player {
|
|||||||
|
|
||||||
play() {
|
play() {
|
||||||
if (!this.queue.current) return;
|
if (!this.queue.current) return;
|
||||||
if (!this.audio.src) {
|
if (!this.audio.src || !this.attempt) {
|
||||||
void this.load(true, this.state.positionMs);
|
void this.load(true, this.state.positionMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -388,9 +398,12 @@ export class Player {
|
|||||||
private async load(autoplay: boolean, startMs = 0) {
|
private async load(autoplay: boolean, startMs = 0) {
|
||||||
const track = this.queue.current;
|
const track = this.queue.current;
|
||||||
if (!track?.stream_url) return;
|
if (!track?.stream_url) return;
|
||||||
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||||
this.audio.src = track.stream_url;
|
// Keep the restored element's buffer when the user presses Play.
|
||||||
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||||
|
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||||
|
this.updateMediaMetadata(track);
|
||||||
|
if (!autoplay) return;
|
||||||
this.applyLoudness();
|
this.applyLoudness();
|
||||||
this.attempt = new PlayAttempt(track, startMs);
|
this.attempt = new PlayAttempt(track, startMs);
|
||||||
this.preloaded = null;
|
this.preloaded = null;
|
||||||
@@ -401,7 +414,6 @@ export class Player {
|
|||||||
context_type: this.context?.type,
|
context_type: this.context?.type,
|
||||||
context_id: this.context?.id,
|
context_id: this.context?.id,
|
||||||
});
|
});
|
||||||
this.updateMediaMetadata(track);
|
|
||||||
this.saveServer();
|
this.saveServer();
|
||||||
void this.refillWave();
|
void this.refillWave();
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
@@ -597,22 +609,28 @@ export class Player {
|
|||||||
if (a.ended) return;
|
if (a.ended) return;
|
||||||
this.update({ playing: false });
|
this.update({ playing: false });
|
||||||
const t = this.queue.current;
|
const t = this.queue.current;
|
||||||
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
if (t && this.attempt) {
|
||||||
this.saveServer();
|
this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||||
|
this.saveServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('waiting', () => {
|
||||||
|
if (this.attempt) this.update({ loading: true });
|
||||||
});
|
});
|
||||||
a.addEventListener('waiting', () => this.update({ loading: true }));
|
|
||||||
a.addEventListener('timeupdate', () => {
|
a.addEventListener('timeupdate', () => {
|
||||||
const ms = Math.round(a.currentTime * 1000);
|
const ms = Math.round(a.currentTime * 1000);
|
||||||
this.attempt?.tick(ms);
|
this.attempt?.tick(ms);
|
||||||
this.update({ positionMs: ms });
|
this.update({ positionMs: ms });
|
||||||
this.updatePositionState();
|
this.updatePositionState();
|
||||||
this.maybePreload(ms);
|
if (this.state.playing) {
|
||||||
this.maybeCrossfade(ms);
|
this.maybePreload(ms);
|
||||||
|
this.maybeCrossfade(ms);
|
||||||
|
}
|
||||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||||
this.saveLocal();
|
this.saveLocal();
|
||||||
});
|
});
|
||||||
a.addEventListener('ended', () => {
|
a.addEventListener('ended', () => {
|
||||||
if (this.ignoreEnded || this.fading) return;
|
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||||
this.finishAttempt('play_end');
|
this.finishAttempt('play_end');
|
||||||
if (this.queue.next(true)) {
|
if (this.queue.next(true)) {
|
||||||
this.syncQueue();
|
this.syncQueue();
|
||||||
@@ -629,11 +647,18 @@ export class Player {
|
|||||||
private async onError() {
|
private async onError() {
|
||||||
const track = this.queue.current;
|
const track = this.queue.current;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
// A speculative load must never start playback or skip the saved track.
|
||||||
|
if (!this.attempt) {
|
||||||
|
this.audio.removeAttribute('src');
|
||||||
|
this.audio.load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.refreshedFor !== track.id) {
|
if (this.refreshedFor !== track.id) {
|
||||||
this.refreshedFor = track.id;
|
this.refreshedFor = track.id;
|
||||||
try {
|
try {
|
||||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||||
const fresh = res.items[0];
|
const fresh = res.items[0];
|
||||||
|
if (this.queue.current !== track || !this.attempt) return;
|
||||||
if (fresh?.stream_url) {
|
if (fresh?.stream_url) {
|
||||||
Object.assign(track, fresh);
|
Object.assign(track, fresh);
|
||||||
void this.load(true, this.state.positionMs);
|
void this.load(true, this.state.positionMs);
|
||||||
@@ -660,6 +685,33 @@ export class Player {
|
|||||||
|
|
||||||
// --- Media Session ---
|
// --- Media Session ---
|
||||||
|
|
||||||
|
private bindDesktopMedia() {
|
||||||
|
void desktopHandle()?.listenMedia((action) => {
|
||||||
|
if (action === 'play') this.play();
|
||||||
|
else if (action === 'pause' || action === 'stop') this.pause();
|
||||||
|
else if (action === 'toggle') this.toggle();
|
||||||
|
else if (action === 'next') this.next();
|
||||||
|
else if (action === 'previous') this.prev();
|
||||||
|
}).then((stop) => {
|
||||||
|
this.desktopUnlisten = stop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncDesktopMedia() {
|
||||||
|
const desktop = desktopHandle();
|
||||||
|
const track = this.state.track;
|
||||||
|
if (!desktop || !track) return;
|
||||||
|
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
|
||||||
|
this.lastDesktop = { id: track.id, playing: this.state.playing };
|
||||||
|
desktop.setMedia({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artists.map((a) => a.name).join(', '),
|
||||||
|
album: track.album.title,
|
||||||
|
coverUrl: track.album.cover_url,
|
||||||
|
playing: this.state.playing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private bindMediaSession() {
|
private bindMediaSession() {
|
||||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
if (!ms) return;
|
if (!ms) return;
|
||||||
@@ -775,6 +827,7 @@ export class Player {
|
|||||||
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||||
*/
|
*/
|
||||||
async restore() {
|
async restore() {
|
||||||
|
const version = ++this.restoreVersion;
|
||||||
let local: Saved | null = null;
|
let local: Saved | null = null;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(LOCAL_KEY);
|
const raw = localStorage.getItem(LOCAL_KEY);
|
||||||
@@ -804,8 +857,9 @@ export class Player {
|
|||||||
} catch {
|
} catch {
|
||||||
// офлайн — остаёмся с локальной копией
|
// офлайн — остаёмся с локальной копией
|
||||||
}
|
}
|
||||||
if (!saved?.queue.length) return;
|
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||||
|
if (version !== this.restoreVersion) return;
|
||||||
const wanted = saved.queue[saved.index];
|
const wanted = saved.queue[saved.index];
|
||||||
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
@@ -819,14 +873,24 @@ export class Player {
|
|||||||
this.syncQueue();
|
this.syncQueue();
|
||||||
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||||
this.updateMediaMetadata(tracks[idx]!);
|
this.updateMediaMetadata(tracks[idx]!);
|
||||||
|
// Prepare at the saved position without creating a listening attempt.
|
||||||
|
await this.load(false, pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
|
this.restoreVersion++;
|
||||||
|
this.fading = false;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
this.audio.removeAttribute('src');
|
this.audio.removeAttribute('src');
|
||||||
|
this.audio.load();
|
||||||
|
this.overlap.pause();
|
||||||
|
this.overlap.removeAttribute('src');
|
||||||
|
this.overlap.load();
|
||||||
for (const t of this.timers) clearInterval(t);
|
for (const t of this.timers) clearInterval(t);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
|
this.desktopUnlisten?.();
|
||||||
|
this.desktopUnlisten = null;
|
||||||
void this.events.flush();
|
void this.events.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { AuthSession, Track } from '@pipu/core';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { Player } from './player';
|
||||||
|
|
||||||
|
const track: Track = {
|
||||||
|
id: 'a', title: 'A', duration_ms: 200_000, artists: [],
|
||||||
|
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||||
|
genres: [], disc_no: null, track_no: null, available: true,
|
||||||
|
stream_url: '/s/a', reaction: null, features: null,
|
||||||
|
};
|
||||||
|
const ok = (data: unknown) => ({ data, response: new Response() });
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
localStorage.removeItem('pipu.player');
|
||||||
|
const client = {
|
||||||
|
GET: vi.fn().mockResolvedValue(ok({
|
||||||
|
track_id: 'a', queue: ['a'], queue_index: 0, position_ms: 42_000,
|
||||||
|
volume: 0.8, shuffle: false, repeat_mode: 'off', updated_at: '2026-10-21',
|
||||||
|
})),
|
||||||
|
POST: vi.fn().mockResolvedValue(ok({ items: [track] })),
|
||||||
|
PUT: vi.fn().mockResolvedValue(ok({})),
|
||||||
|
};
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
const player = new Player({ client, state: { status: 'signed_out' } } as unknown as AuthSession, audio);
|
||||||
|
return { player, audio, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
|
||||||
|
|
||||||
|
describe('restored audio preparation', () => {
|
||||||
|
it('buffers at the saved position without playback or listening events, then reuses the source', async () => {
|
||||||
|
const { player, audio, client } = fixture();
|
||||||
|
const play = vi.spyOn(audio, 'play');
|
||||||
|
const source = vi.spyOn(audio, 'src', 'set');
|
||||||
|
await player.restore();
|
||||||
|
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||||
|
expect(audio.currentTime).toBe(42);
|
||||||
|
expect(audio.preload).toBe('auto');
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(client.PUT).not.toHaveBeenCalled();
|
||||||
|
expect(player.getState()).toMatchObject({ playing: false, loading: false, positionMs: 42_000 });
|
||||||
|
player.onHide();
|
||||||
|
expect(client.POST).toHaveBeenCalledTimes(1); // Only the track lookup, no listening events.
|
||||||
|
player.play();
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source).toHaveBeenCalledTimes(1);
|
||||||
|
expect(player.getState().playing).toBe(true);
|
||||||
|
player.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not autoplay or skip after a preload failure; Play retries the source', async () => {
|
||||||
|
const { player, audio } = fixture();
|
||||||
|
const play = vi.spyOn(audio, 'play');
|
||||||
|
await player.restore();
|
||||||
|
audio.dispatchEvent(new Event('error'));
|
||||||
|
audio.dispatchEvent(new Event('ended'));
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(player.getState()).toMatchObject({ track: { id: 'a' }, positionMs: 42_000, playing: false });
|
||||||
|
player.play();
|
||||||
|
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
player.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['selection', 'dispose'])('ignores a restore lookup completed after %s', async (action) => {
|
||||||
|
const { player, client, audio } = fixture();
|
||||||
|
let resolve!: (value: unknown) => void;
|
||||||
|
client.POST.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||||
|
const restoring = player.restore();
|
||||||
|
await vi.waitFor(() => expect(client.POST).toHaveBeenCalled());
|
||||||
|
if (action === 'selection') player.playList([{ ...track, id: 'b', stream_url: '/s/b' }], 0, { type: 'search' });
|
||||||
|
else player.dispose();
|
||||||
|
resolve(ok({ items: [track] }));
|
||||||
|
await restoring;
|
||||||
|
expect(audio.getAttribute('src')).toBe(action === 'selection' ? '/s/b' : null);
|
||||||
|
if (action === 'selection') player.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { detectAppClient, otherAppClients } from './platform';
|
||||||
|
|
||||||
|
describe('detectAppClient', () => {
|
||||||
|
it('picks the native client from the user agent', () => {
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
|
||||||
|
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists the remaining platforms after the detected one', () => {
|
||||||
|
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
|
||||||
|
|
||||||
|
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
|
||||||
|
|
||||||
|
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
|
||||||
|
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
|
||||||
|
if (/Android/i.test(ua)) return 'android';
|
||||||
|
if (/iPhone|iPad|iPod/i.test(ua)) return null;
|
||||||
|
if (/Windows/i.test(ua)) return 'windows';
|
||||||
|
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
|
||||||
|
if (/Linux|X11/i.test(ua)) return 'linux';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function otherAppClients(primary: AppClient | null): AppClient[] {
|
||||||
|
return ORDER.filter((c) => c !== primary);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { shareTrack } from './share';
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
describe('shareTrack', () => {
|
||||||
|
it('copies on desktop even when native sharing exists', async () => {
|
||||||
|
const share = vi.fn();
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Windows');
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Windows', share, clipboard: { writeText } });
|
||||||
|
try {
|
||||||
|
expect(await shareTrack('id', 'Title')).toBe('copied');
|
||||||
|
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/track/id`);
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
} finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens Android sharing with a page URL', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||||
|
try {
|
||||||
|
expect(await shareTrack('id', 'Title')).toBe('shared');
|
||||||
|
expect(share).toHaveBeenCalledWith({ title: 'Title', url: `${window.location.origin}/track/id` });
|
||||||
|
} finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not copy or report an error when the share sheet is dismissed', async () => {
|
||||||
|
const share = vi.fn().mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||||
|
try { expect(await shareTrack('id', 'Title')).toBe('cancelled'); }
|
||||||
|
finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports clipboard failures instead of pretending the link was copied', async () => {
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Denied')) } });
|
||||||
|
try { await expect(shareTrack('id', 'Title')).rejects.toThrow('Denied'); }
|
||||||
|
finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { trackShareUrl } from '@pipu/core';
|
||||||
|
|
||||||
|
/** Desktop always copies; mobile browsers can open their native share sheet. */
|
||||||
|
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||||
|
const url = trackShareUrl(window.location.origin, id);
|
||||||
|
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title, url });
|
||||||
|
return 'shared';
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError') return 'cancelled';
|
||||||
|
// If sharing is unavailable, still allow copying the link.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
return 'copied';
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
|
|||||||
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
|
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
|
||||||
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
|
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
|
||||||
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
|
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
|
||||||
|
export { default as shareIcon } from '@material-symbols/svg-400/rounded/share.svg';
|
||||||
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
|
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
|
||||||
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
|
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
|
||||||
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
|
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
let token = 0;
|
let token = 0;
|
||||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||||
const reactions = new Map<string, 'like' | 'dislike'>();
|
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||||
|
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||||
|
|
||||||
const me = (u: FakeUser): Me => ({
|
const me = (u: FakeUser): Me => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
@@ -115,6 +116,28 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
min_android_version_code: 0,
|
min_android_version_code: 0,
|
||||||
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
|
||||||
});
|
});
|
||||||
|
case 'GET /api/v1/app/clients':
|
||||||
|
return json({
|
||||||
|
version: '1.3.0',
|
||||||
|
version_code: 1_030_099,
|
||||||
|
notes_md: '',
|
||||||
|
clients: [
|
||||||
|
{ client: 'windows', filename: 'pipu-music_1.3.0_x64-setup.exe', size: 20_000_000, download_url: '/api/v1/app/download/windows/1.3.0' },
|
||||||
|
{ client: 'linux', filename: 'pipu-music_1.3.0_amd64.AppImage', size: 18_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
|
||||||
|
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
|
||||||
|
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
case 'GET /api/v1/app/android/latest':
|
||||||
|
return json({
|
||||||
|
version: '1.3.0',
|
||||||
|
version_code: 1_030_099,
|
||||||
|
notes_md: '',
|
||||||
|
size: 12_000_000,
|
||||||
|
sha256: 'ab'.repeat(32),
|
||||||
|
download_url: '/api/v1/app/android/download/1.3.0',
|
||||||
|
min_supported: 0,
|
||||||
|
});
|
||||||
case 'POST /api/v1/auth/login': {
|
case 'POST /api/v1/auth/login': {
|
||||||
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
|
||||||
if (!u) return problem(401, 'auth.invalid_credentials');
|
if (!u) return problem(401, 'auth.invalid_credentials');
|
||||||
@@ -127,6 +150,23 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
|
|
||||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||||
|
|
||||||
|
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
|
||||||
|
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
|
||||||
|
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
||||||
|
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
||||||
|
const old = playlists.get(id);
|
||||||
|
if (playlistPath && !old) return problem(404, 'playlist.not_found');
|
||||||
|
if (req.method === 'GET') return json(old);
|
||||||
|
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
||||||
|
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
|
||||||
|
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
|
||||||
|
const item = { id, name: body?.name as string, description: body?.description as string, tracks,
|
||||||
|
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
|
||||||
|
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||||
|
playlists.set(id, item);
|
||||||
|
return json(item, old ? 200 : 201);
|
||||||
|
}
|
||||||
|
|
||||||
switch (route) {
|
switch (route) {
|
||||||
case 'POST /api/v1/auth/logout':
|
case 'POST /api/v1/auth/logout':
|
||||||
case 'POST /api/v1/auth/logout-all':
|
case 'POST /api/v1/auth/logout-all':
|
||||||
@@ -193,11 +233,14 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
|
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
|
||||||
const q = url.searchParams.get('q') ?? '';
|
const q = url.searchParams.get('q') ?? '';
|
||||||
const hit = q === 'кино' || q === 'rbyj';
|
const hit = q === 'кино' || q === 'rbyj';
|
||||||
|
const tracks = path === '/api/v1/search' && hit
|
||||||
|
? albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null }))
|
||||||
|
: [];
|
||||||
return json({
|
return json({
|
||||||
artists: hit ? [kino] : [],
|
artists: hit ? [kino] : [],
|
||||||
albums: hit ? [albumSummary] : [],
|
albums: hit ? [albumSummary] : [],
|
||||||
tracks: [],
|
tracks,
|
||||||
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
|
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: tracks.length },
|
||||||
corrected_query: q === 'rbyj' ? 'кино' : null,
|
corrected_query: q === 'rbyj' ? 'кино' : null,
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
});
|
});
|
||||||
@@ -260,6 +303,11 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
return json(admin(target));
|
return json(admin(target));
|
||||||
}
|
}
|
||||||
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
||||||
|
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
|
||||||
|
if (trackPath && req.method === 'GET') {
|
||||||
|
const track = albumTracks.find(t => t.id === trackPath[1]);
|
||||||
|
return track ? json({ ...track, reaction: reactions.get(track.id) ?? null }) : problem(404, 'common.not_found');
|
||||||
|
}
|
||||||
if (reactionPath) {
|
if (reactionPath) {
|
||||||
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
|
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
|
||||||
else reactions.delete(reactionPath[1]!);
|
else reactions.delete(reactionPath[1]!);
|
||||||
@@ -268,5 +316,5 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
return problem(404, 'common.not_found');
|
return problem(404, 'common.not_found');
|
||||||
};
|
};
|
||||||
|
|
||||||
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
|
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { queueIcon } from '../shared/ui/icons';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||||
@@ -5,6 +6,7 @@ import { Suspense, useCallback, useState, type ReactNode } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useAuthState, useSession, useUser } from '../app/session';
|
import { useAuthState, useSession, useUser } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useCollectionCounts } from '../entities/collection';
|
import { useCollectionCounts } from '../entities/collection';
|
||||||
import {
|
import {
|
||||||
moreIcon,
|
moreIcon,
|
||||||
@@ -24,7 +26,7 @@ import {
|
|||||||
dislikeFillIcon,
|
dislikeFillIcon,
|
||||||
dislikeIcon,
|
dislikeIcon,
|
||||||
settingsIcon,
|
settingsIcon,
|
||||||
androidIcon,
|
downloadIcon,
|
||||||
chevronRightIcon,
|
chevronRightIcon,
|
||||||
storageFillIcon,
|
storageFillIcon,
|
||||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||||
@@ -46,6 +48,7 @@ type NavPath =
|
|||||||
| '/uploads/new'
|
| '/uploads/new'
|
||||||
| '/uploads/mine'
|
| '/uploads/mine'
|
||||||
| '/admin/settings'
|
| '/admin/settings'
|
||||||
|
| '/collection/playlists'
|
||||||
| '/collection/history'
|
| '/collection/history'
|
||||||
| '/collection/likes'
|
| '/collection/likes'
|
||||||
| '/collection/dislikes';
|
| '/collection/dislikes';
|
||||||
@@ -153,6 +156,7 @@ function Shell() {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const user = useUser();
|
const user = useUser();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const name = user.display_name ?? user.username;
|
const name = user.display_name ?? user.username;
|
||||||
const staff = user.role !== 'user';
|
const staff = user.role !== 'user';
|
||||||
@@ -169,6 +173,7 @@ function Shell() {
|
|||||||
<>
|
<>
|
||||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||||
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
||||||
|
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
|
||||||
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||||
<NavLink
|
<NavLink
|
||||||
@@ -249,7 +254,9 @@ function Shell() {
|
|||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||||
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
...(!desktop
|
||||||
|
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
|
||||||
|
: []),
|
||||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.bar {
|
.bar {
|
||||||
|
position: relative;
|
||||||
grid-area: player;
|
grid-area: player;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
||||||
@@ -310,7 +311,7 @@ a.source:hover {
|
|||||||
.controls { gap: 2px; }
|
.controls { gap: 2px; }
|
||||||
.play, .icon { width: 44px; height: 44px; }
|
.play, .icon { width: 44px; height: 44px; }
|
||||||
.right { display: flex; gap: 0; }
|
.right { display: flex; gap: 0; }
|
||||||
.right > :not(.queueButton) { display: none; }
|
.right > :not(.queueButton):not(.shareButton) { display: none; }
|
||||||
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
||||||
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
||||||
}
|
}
|
||||||
@@ -318,3 +319,16 @@ a.source:hover {
|
|||||||
@media (max-width: 359px) {
|
@media (max-width: 359px) {
|
||||||
.now > :first-child { display: none; }
|
.now > :first-child { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shareStatus {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: 8px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { shareTrack } from '../shared/lib/share';
|
||||||
import {
|
import {
|
||||||
albumIcon,
|
albumIcon,
|
||||||
closeIcon,
|
closeIcon,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
repeatIcon,
|
repeatIcon,
|
||||||
repeatOneIcon,
|
repeatOneIcon,
|
||||||
shuffleIcon,
|
shuffleIcon,
|
||||||
|
shareIcon,
|
||||||
volumeIcon,
|
volumeIcon,
|
||||||
volumeOffIcon,
|
volumeOffIcon,
|
||||||
waveIcon,
|
waveIcon,
|
||||||
@@ -35,6 +37,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setShareStatus(null);
|
||||||
|
}, [st.track?.id]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shareStatus) return;
|
||||||
|
const timer = setTimeout(() => setShareStatus(null), 4000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [shareStatus]);
|
||||||
const queueButton = useRef<HTMLButtonElement>(null);
|
const queueButton = useRef<HTMLButtonElement>(null);
|
||||||
const queueId = useId();
|
const queueId = useId();
|
||||||
const closeQueue = useCallback(() => {
|
const closeQueue = useCallback(() => {
|
||||||
@@ -57,6 +69,18 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const share = async () => {
|
||||||
|
setSharing(true);
|
||||||
|
setShareStatus(null);
|
||||||
|
try {
|
||||||
|
const result = await shareTrack(track.id, track.title);
|
||||||
|
if (result === 'copied') setShareStatus('copied');
|
||||||
|
} catch {
|
||||||
|
setShareStatus('error');
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||||
const ctx = st.context;
|
const ctx = st.context;
|
||||||
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
|
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
|
||||||
@@ -67,7 +91,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
? t('player.source_wave_seed', { label: ctx.label })
|
? t('player.source_wave_seed', { label: ctx.label })
|
||||||
: t('player.source_wave')
|
: t('player.source_wave')
|
||||||
: ctx.type === 'collection'
|
: ctx.type === 'collection'
|
||||||
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
|
? ctx.label ?? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
|
||||||
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
|
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,6 +178,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.right}>
|
<div className={s.right}>
|
||||||
|
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||||
|
<Icon src={shareIcon} size={20} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(s.icon, st.shuffle && s.accent)}
|
className={cx(s.icon, st.shuffle && s.accent)}
|
||||||
@@ -204,6 +231,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||||
|
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@
|
|||||||
## Суть
|
## Суть
|
||||||
|
|
||||||
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
||||||
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||||
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
||||||
|
|
||||||
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
## Доступ и учётные записи
|
## Доступ и учётные записи
|
||||||
|
|
||||||
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
||||||
В мобильном приложении на этом экране также указывается **адрес сервера**.
|
В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
|
||||||
- **Регистрации нет.** Учётные записи создаёт администрация.
|
- **Регистрации нет.** Учётные записи создаёт администрация.
|
||||||
- Роли:
|
- Роли:
|
||||||
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
||||||
@@ -57,19 +57,19 @@
|
|||||||
### Левая панель навигации
|
### Левая панель навигации
|
||||||
- **Главная**
|
- **Главная**
|
||||||
- **Коллекция** — с подкатегориями:
|
- **Коллекция** — с подкатегориями:
|
||||||
|
- **Плейлисты** — личные подборки треков;
|
||||||
- **История** — прослушанные треки;
|
- **История** — прослушанные треки;
|
||||||
- **Лайки** — понравившиеся треки;
|
- **Лайки** — понравившиеся треки;
|
||||||
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
|
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
|
||||||
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
|
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
|
||||||
- **Админка** — видна только администраторам и модераторам.
|
- **Админка** — видна только администраторам и модераторам.
|
||||||
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
|
|
||||||
|
|
||||||
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
|
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
|
||||||
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
|
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
|
||||||
|
|
||||||
### Плеер (всегда внизу)
|
### Плеер (всегда внизу)
|
||||||
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
|
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
|
||||||
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь.
|
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь.
|
||||||
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
|
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
|
||||||
и даже на другом устройстве можно продолжить с того же места.
|
и даже на другом устройстве можно продолжить с того же места.
|
||||||
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
|
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
|||||||
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
|
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
|
||||||
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
|
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
|
||||||
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
|
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
|
||||||
|
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
|
||||||
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
||||||
| [web.md](web.md) | Веб-клиент |
|
| [web.md](web.md) | Веб-клиент |
|
||||||
|
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
|
||||||
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
||||||
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
||||||
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
||||||
|
|||||||
+4
-2
@@ -102,8 +102,10 @@
|
|||||||
прочитайте заметки к релизу на странице релизов Gitea.
|
прочитайте заметки к релизу на странице релизов Gitea.
|
||||||
|
|
||||||
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||||
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
|
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
||||||
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
|
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
|
||||||
|
APK, Linux (AppImage, deb) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
|
||||||
|
и в меню профиля.
|
||||||
|
|
||||||
## 7. Наблюдение
|
## 7. Наблюдение
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,8 @@
|
|||||||
| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` |
|
| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` |
|
||||||
| DELETE | `/me/reactions/tracks/{id}` | Снять реакцию |
|
| DELETE | `/me/reactions/tracks/{id}` | Снять реакцию |
|
||||||
| PUT/DELETE | `/me/reactions/artists/{id}` | Реакция на исполнителя |
|
| PUT/DELETE | `/me/reactions/artists/{id}` | Реакция на исполнителя |
|
||||||
|
| GET/POST | `/me/playlists` | Сводки личных плейлистов; создание `{name, description, track_ids}` |
|
||||||
|
| GET/PUT/DELETE | `/me/playlists/{id}` | Метаданные и треки; атомарная замена с `revision`; удаление. Подробно — [playlists.md](playlists.md) |
|
||||||
|
|
||||||
### Моя волна — [recommendations.md](recommendations.md)
|
### Моя волна — [recommendations.md](recommendations.md)
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
@@ -104,6 +106,8 @@
|
|||||||
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
|
### Мобильное обновление — [mobile.md](mobile.md#самообновление)
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| GET | `/app/clients` | `{version, version_code, notes_md, clients:[{client, filename, size, sha256?, download_url}]}` — Android, Windows, Linux, macOS |
|
||||||
|
| GET | `/app/download/{client}/{version}` | файл клиента (прокси / кэш релиза Gitea) |
|
||||||
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
|
| GET | `/app/android/latest` | `{version, version_code, notes_md, size, sha256, download_url, min_supported}` |
|
||||||
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
|
| GET | `/app/android/download/{version}` | APK (прокси / кэш релиза Gitea) |
|
||||||
|
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
- Всё разворачивается на одном сервере через Docker Compose.
|
- Всё разворачивается на одном сервере через Docker Compose.
|
||||||
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
||||||
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
||||||
- Клиенты: веб (SPA) и Android-приложение.
|
- Клиенты: веб (SPA), десктоп (Tauri) и Android-приложение.
|
||||||
|
|
||||||
## 2. Компоненты
|
## 2. Компоненты
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┐ ┌────────────────┐
|
┌──────────────┐ ┌────────────────┐
|
||||||
Web SPA ─────┐ │ │ │ │
|
Web SPA ─────┐ │ │ │ │
|
||||||
├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
Desktop ─────┼──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
||||||
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
||||||
│ X-Accel) │ │ │──► Meilisearch
|
│ X-Accel) │ │ │──► Meilisearch
|
||||||
└──────┬───────┘ └───┬────────┬───┘
|
└──────┬───────┘ └───┬────────┬───┘
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
pipu-music/
|
pipu-music/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── web/ # React + Vite SPA
|
│ ├── web/ # React + Vite SPA
|
||||||
|
│ ├── desktop/ # Tauri 2: то же SPA в нативном окне
|
||||||
│ └── mobile/ # React Native (Expo) Android
|
│ └── mobile/ # React Native (Expo) Android
|
||||||
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
||||||
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
||||||
|
|||||||
+21
-2
@@ -41,7 +41,7 @@ CREATE TABLE refresh_tokens (
|
|||||||
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
|
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
|
||||||
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
|
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
|
||||||
device_name text, -- "Chrome / Windows", "Pixel 8"
|
device_name text, -- "Chrome / Windows", "Pixel 8"
|
||||||
client_type text NOT NULL, -- 'web' | 'android'
|
client_type text NOT NULL, -- 'web' | 'android' | 'desktop'
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
expires_at timestamptz NOT NULL,
|
expires_at timestamptz NOT NULL,
|
||||||
used_at timestamptz, -- ротирован
|
used_at timestamptz, -- ротирован
|
||||||
@@ -303,6 +303,25 @@ CREATE TABLE track_reactions ( -- Коллекция → Лайки
|
|||||||
);
|
);
|
||||||
CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC);
|
CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
|
||||||
|
description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
||||||
|
revision integer NOT NULL DEFAULT 1, -- оптимистичная блокировка при PUT
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX playlists_owner ON playlists(user_id, updated_at DESC, id);
|
||||||
|
|
||||||
|
CREATE TABLE playlist_tracks (
|
||||||
|
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
position integer NOT NULL CHECK (position >= 0),
|
||||||
|
PRIMARY KEY (playlist_id, track_id),
|
||||||
|
UNIQUE (playlist_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п.
|
CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п.
|
||||||
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||||
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
|
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
@@ -324,7 +343,7 @@ CREATE TABLE play_events (
|
|||||||
context_type text, -- wave | album | artist | search | collection | history | queue
|
context_type text, -- wave | album | artist | search | collection | history | queue
|
||||||
context_id text, -- id альбома / wave_session и т.п.
|
context_id text, -- id альбома / wave_session и т.п.
|
||||||
wave_session_id uuid,
|
wave_session_id uuid,
|
||||||
client_type text NOT NULL, -- web | android
|
client_type text NOT NULL, -- web | android | desktop
|
||||||
offline boolean NOT NULL DEFAULT false,
|
offline boolean NOT NULL DEFAULT false,
|
||||||
PRIMARY KEY (id, occurred_at)
|
PRIMARY KEY (id, occurred_at)
|
||||||
) PARTITION BY RANGE (occurred_at);
|
) PARTITION BY RANGE (occurred_at);
|
||||||
|
|||||||
+1
-1
@@ -76,7 +76,7 @@
|
|||||||
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
|
||||||
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
|
||||||
| `TRANSCODE_CONCURRENCY` | `2` | |
|
| `TRANSCODE_CONCURRENCY` | `2` | |
|
||||||
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
|
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/download` |
|
||||||
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
|
||||||
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
|
||||||
| `LOG_LEVEL` | `info` | |
|
| `LOG_LEVEL` | `info` | |
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Десктоп (`apps/desktop`)
|
||||||
|
|
||||||
|
Windows, Linux и macOS: тот же веб-клиент в окне [Tauri 2](https://tauri.app/). Запросы к API идут через локальный прокси на `127.0.0.1`, чтобы плеер не открывал чужой origin. Refresh-токен хранится в системной связке ключей. Тип клиента — `desktop`.
|
||||||
|
|
||||||
|
При первом запуске нужно указать адрес сервера (как в Android). Дальше вход тот же, что в вебе. Кнопка «Другой сервер» в настройках забывает адрес и токен. Трея: левый клик открывает окно; меню — открыть и выход. Клавиши медиаплеера ОС управляют воспроизведением.
|
||||||
|
|
||||||
|
Плейлисты, ссылка на трек и остальной интерфейс совпадают с вебом. Сборка подставляет версию из git-тега.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm --filter @pipu/desktop icons
|
||||||
|
pnpm --filter @pipu/desktop dev
|
||||||
|
pnpm --filter @pipu/desktop bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
На Windows получается NSIS-установщик, на Linux — AppImage, deb и rpm. Зависимости Linux: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
|
||||||
|
|
||||||
|
Релиз по тегу прикладывает Linux AppImage/`.deb` (`pipu-music-<версия>-linux.*`) и Windows NSIS (`pipu-music-<версия>-windows-setup.exe`). Windows-job в `release.yml` ждёт act_runner с меткой `windows-latest` (MSVC + WebView2). Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
|
||||||
@@ -246,7 +246,7 @@ curl -s https://music.example.com/readyz
|
|||||||
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
|
||||||
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
|
||||||
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
|
||||||
ссылка `https://<ваш-домен>/android`.
|
ссылка `https://<ваш-домен>/download`.
|
||||||
|
|
||||||
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
|
||||||
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
|
||||||
|
|||||||
+3
-3
@@ -42,12 +42,12 @@
|
|||||||
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
|
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
|
||||||
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
|
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
|
||||||
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
|
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
|
||||||
прогресс, лайк / дизлайк, скачать, очередь (нижний лист).
|
прогресс, лайк / дизлайк, скачать, поделиться ссылкой на `/track/{id}`, очередь (нижний лист).
|
||||||
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
|
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
|
||||||
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
|
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
|
||||||
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
|
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
|
||||||
выбор занятия / настроения.
|
выбор занятия / настроения.
|
||||||
- **Коллекция**: История, Лайки, Дизлайки, **Скачанное**.
|
- **Коллекция**: кнопка **Плейлисты**, затем История, Лайки, Дизлайки, **Скачанное**. Плейлисты — [playlists.md](playlists.md).
|
||||||
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
|
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
|
||||||
очередь загрузок с пометкой «по Wi-Fi».
|
очередь загрузок с пометкой «по Wi-Fi».
|
||||||
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
|
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
|
|
||||||
## Самообновление
|
## Самообновление
|
||||||
|
|
||||||
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
|
||||||
|
|
||||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Личные плейлисты
|
||||||
|
|
||||||
|
Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
|
||||||
|
|
||||||
|
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
- `GET /api/v1/me/playlists` — сводки личных плейлистов, последние изменённые первыми.
|
||||||
|
- `POST /api/v1/me/playlists` — создание: `name`, `description`, `track_ids`.
|
||||||
|
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
|
||||||
|
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
|
||||||
|
- `DELETE /api/v1/me/playlists/{id}` — удаление.
|
||||||
|
|
||||||
|
Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками.
|
||||||
|
|
||||||
|
Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы.
|
||||||
|
|
||||||
|
Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
|
||||||
|
|
||||||
|
## Проверки
|
||||||
|
|
||||||
|
- `pnpm --filter @pipu/web test src/App.test.tsx -t playlists`
|
||||||
|
- `pnpm --filter @pipu/web test:e2e e2e/playlists.spec.ts`
|
||||||
|
- `pnpm --filter @pipu/web build`
|
||||||
|
- `pnpm --filter @pipu/mobile typecheck`
|
||||||
|
- В `services/api`: `go test ./internal/playback ./internal/server`
|
||||||
|
- С отдельной тестовой PostgreSQL/pgvector: `TEST_DATABASE_URL=… go test -tags integration ./internal/playback -run TestPlaylistPersistenceAndIsolation -v`. Проверяет сохранение порядка, изоляцию владельцев, откат при несуществующем треке, конфликт конкурентных записей и каскадное удаление членства.
|
||||||
+5
-4
@@ -22,8 +22,8 @@
|
|||||||
```
|
```
|
||||||
apps/web/src/
|
apps/web/src/
|
||||||
├── app/ # провайдеры, роутер, глобальные стили, гарды
|
├── app/ # провайдеры, роутер, глобальные стили, гарды
|
||||||
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
|
├── pages/ # login, setup-admin, setup-password, download, home, search, artist, album,
|
||||||
│ # collection/{history,likes,dislikes}, settings,
|
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
|
||||||
│ # admin/{users,sources,library,upload,upload-queue,settings}
|
│ # admin/{users,sources,library,upload,upload-queue,settings}
|
||||||
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
|
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
|
||||||
├── features/ # like-track, change-password, mood-picker, create-user, pattern-editor, ...
|
├── features/ # like-track, change-password, mood-picker, create-user, pattern-editor, ...
|
||||||
@@ -37,8 +37,8 @@ apps/web/src/
|
|||||||
|
|
||||||
### App shell
|
### App shell
|
||||||
Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
|
Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
|
||||||
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
|
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → Плейлисты / История / Лайки / Дизлайки,
|
||||||
Админка по роли) / контент / плеер.
|
Админка по роли) / контент / плеер. Личные плейлисты — [playlists.md](playlists.md).
|
||||||
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
|
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
|
||||||
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
|
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
|
||||||
- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
|
- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
|
||||||
@@ -55,6 +55,7 @@ apps/web/src/
|
|||||||
- Два элемента `<audio>` для предзагрузки следующего трека и бесшовного перехода (опционально — кроссфейд 0–6 с в настройках).
|
- Два элемента `<audio>` для предзагрузки следующего трека и бесшовного перехода (опционально — кроссфейд 0–6 с в настройках).
|
||||||
- Media Session API: метаданные, обложка, кнопки ОС / наушников.
|
- Media Session API: метаданные, обложка, кнопки ОС / наушников.
|
||||||
- Горячие клавиши: пробел — пауза, ←/→ — перемотка 10 с, Shift+←/→ — трек, L — лайк.
|
- Горячие клавиши: пробел — пауза, ←/→ — перемотка 10 с, Shift+←/→ — трек, L — лайк.
|
||||||
|
- «Поделиться» копирует адрес страницы трека (`/track/{id}`) без токенов стрима; на телефоне в браузере открывается системный лист.
|
||||||
- Логика очереди, событий, сохранения позиции — из `packages/core` (общая с мобильным); веб реализует
|
- Логика очереди, событий, сохранения позиции — из `packages/core` (общая с мобильным); веб реализует
|
||||||
только адаптер `AudioEngine`.
|
только адаптер `AudioEngine`.
|
||||||
|
|
||||||
|
|||||||
@@ -1293,6 +1293,44 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/app/clients": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Клиенты последнего релиза (Android и десктоп)
|
||||||
|
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
|
||||||
|
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
|
||||||
|
*/
|
||||||
|
get: operations["getAppClients"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/app/download/{client}/{version}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Файл клиента (кэшируется на сервере, поддерживает Range) */
|
||||||
|
get: operations["downloadAppClient"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/app/android/latest": {
|
"/api/v1/app/android/latest": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1331,10 +1369,72 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/me/playlists": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listPlaylists"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createPlaylist"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/me/playlists/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getPlaylist"];
|
||||||
|
put: operations["updatePlaylist"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deletePlaylist"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
PlaylistInput: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
track_ids: string[];
|
||||||
|
};
|
||||||
|
PlaylistUpdate: components["schemas"]["PlaylistInput"] & {
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
PlaylistSummary: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
revision: number;
|
||||||
|
track_count: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
duration_ms: number;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
Playlist: components["schemas"]["PlaylistSummary"] & {
|
||||||
|
tracks: components["schemas"]["Track"][];
|
||||||
|
};
|
||||||
|
PlaylistList: {
|
||||||
|
items: components["schemas"]["PlaylistSummary"][];
|
||||||
|
};
|
||||||
Onboarding: {
|
Onboarding: {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
artists: {
|
artists: {
|
||||||
@@ -1596,7 +1696,7 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
Role: "admin" | "moderator" | "user";
|
Role: "admin" | "moderator" | "user";
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
ClientType: "web" | "android";
|
ClientType: "web" | "android" | "desktop";
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -1605,7 +1705,7 @@ export interface components {
|
|||||||
device_name?: string;
|
device_name?: string;
|
||||||
};
|
};
|
||||||
RefreshRequest: {
|
RefreshRequest: {
|
||||||
/** @description Только Android; веб передаёт токен в cookie */
|
/** @description Android и desktop; веб передаёт токен в cookie */
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
};
|
};
|
||||||
AuthTokens: {
|
AuthTokens: {
|
||||||
@@ -1614,7 +1714,7 @@ export interface components {
|
|||||||
token_type: "Bearer";
|
token_type: "Bearer";
|
||||||
/** @description Срок жизни access-токена, секунды */
|
/** @description Срок жизни access-токена, секунды */
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
/** @description Только для `client_type: android` */
|
/** @description Для `client_type: android` и `client_type: desktop` */
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
user: components["schemas"]["Me"];
|
user: components["schemas"]["Me"];
|
||||||
};
|
};
|
||||||
@@ -2187,6 +2287,22 @@ export interface components {
|
|||||||
early_skip_rate: number | null;
|
early_skip_rate: number | null;
|
||||||
like_rate: number | null;
|
like_rate: number | null;
|
||||||
};
|
};
|
||||||
|
/** @enum {string} */
|
||||||
|
AppClient: "android" | "windows" | "linux" | "macos";
|
||||||
|
AppClientFile: {
|
||||||
|
client: components["schemas"]["AppClient"];
|
||||||
|
filename: string;
|
||||||
|
/** Format: int64 */
|
||||||
|
size: number;
|
||||||
|
sha256?: string;
|
||||||
|
download_url: string;
|
||||||
|
};
|
||||||
|
AppClients: {
|
||||||
|
version: string;
|
||||||
|
version_code: number;
|
||||||
|
notes_md: string;
|
||||||
|
clients: components["schemas"]["AppClientFile"][];
|
||||||
|
};
|
||||||
AndroidRelease: {
|
AndroidRelease: {
|
||||||
/** @example 0.6.1 */
|
/** @example 0.6.1 */
|
||||||
version: string;
|
version: string;
|
||||||
@@ -4127,6 +4243,51 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getAppClients: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Доступные сборки */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["AppClients"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
downloadAppClient: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
client: components["schemas"]["AppClient"];
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Установщик или APK */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/octet-stream": string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getAndroidLatest: {
|
getAndroidLatest: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4171,4 +4332,121 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listPlaylists: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlaylistList"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createPlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlaylistInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Playlist"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getPlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Playlist"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updatePlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlaylistUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Playlist"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deletePlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Deleted */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -93,8 +93,8 @@ export class AuthSession {
|
|||||||
private readonly listeners = new Set<(s: AuthState) => void>();
|
private readonly listeners = new Set<(s: AuthState) => void>();
|
||||||
|
|
||||||
constructor(opts: AuthSessionOptions) {
|
constructor(opts: AuthSessionOptions) {
|
||||||
if (opts.clientType === 'android' && !opts.refreshStore) {
|
if (opts.clientType !== 'web' && !opts.refreshStore) {
|
||||||
throw new Error('AuthSession: android client needs a refreshStore');
|
throw new Error(`AuthSession: ${opts.clientType} client needs a refreshStore`);
|
||||||
}
|
}
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ export {
|
|||||||
type Track,
|
type Track,
|
||||||
} from './player';
|
} from './player';
|
||||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||||
|
export { trackShareUrl } from './share';
|
||||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { trackShareUrl } from './share';
|
||||||
|
|
||||||
|
describe('trackShareUrl', () => {
|
||||||
|
it('builds a page URL without stream credentials', () => {
|
||||||
|
expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/** Public page URL: never includes playback credentials or stream URLs. */
|
||||||
|
export function trackShareUrl(server: string, trackId: string): string {
|
||||||
|
return new URL(`/track/${encodeURIComponent(trackId)}`, server).href;
|
||||||
|
}
|
||||||
@@ -22,17 +22,41 @@
|
|||||||
"logout": "Sign out"
|
"logout": "Sign out"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"link": "Android app",
|
"link": "Download apps",
|
||||||
"title": "pipu for Android",
|
"badge": "Apps",
|
||||||
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
|
"title": "pipu on your devices",
|
||||||
"download": "Download APK",
|
"subtitle": "Clients for this server: phone, desktop and the web. The build for your system comes first.",
|
||||||
|
"platform": {
|
||||||
|
"android": "Android",
|
||||||
|
"windows": "Windows",
|
||||||
|
"linux": "Linux",
|
||||||
|
"macos": "macOS"
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"android": "Download APK",
|
||||||
|
"windows": "Download for Windows",
|
||||||
|
"linux": "Download AppImage",
|
||||||
|
"macos": "Download for macOS"
|
||||||
|
},
|
||||||
"version": "version {{version}}",
|
"version": "version {{version}}",
|
||||||
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
|
"other": "Other platforms",
|
||||||
|
"step_install": {
|
||||||
|
"android": "Open the downloaded file. If Android asks, allow installs from this source.",
|
||||||
|
"windows": "Run the installer and follow the steps. pipu will appear in the Start menu.",
|
||||||
|
"linux": "Make the file executable and open it. For a .deb, install the package.",
|
||||||
|
"macos": "Open the disk image and drag pipu into Applications."
|
||||||
|
},
|
||||||
"step_server": "Launch pipu and enter the server address:",
|
"step_server": "Launch pipu and enter the server address:",
|
||||||
"copy_server": "Copy the server address",
|
"copy_server": "Copy the server address",
|
||||||
"step_updates": "Sign in with your account. The app offers new versions by itself.",
|
"step_updates": {
|
||||||
|
"android": "Sign in with your account. The app offers new versions by itself.",
|
||||||
|
"windows": "Sign in with your account. Get a new version from this page.",
|
||||||
|
"linux": "Sign in with your account. Get a new version from this page.",
|
||||||
|
"macos": "Sign in with your account. Get a new version from this page."
|
||||||
|
},
|
||||||
"checksum": "SHA-256 checksum",
|
"checksum": "SHA-256 checksum",
|
||||||
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
||||||
|
"unavailable_platform": "The latest release does not include a build for {{platform}} yet.",
|
||||||
"to_login": "Sign in to the web app"
|
"to_login": "Sign in to the web app"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"menu": "Profile menu",
|
"menu": "Profile menu",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
"android_app": "Android app"
|
"download_apps": "Download apps"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "Administrator",
|
"admin": "Administrator",
|
||||||
@@ -54,6 +54,10 @@
|
|||||||
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"share": "Share track",
|
||||||
|
"link_copied": "Track link copied",
|
||||||
|
"share_error": "Could not share the link. Please try again.",
|
||||||
|
"track_page": "Track",
|
||||||
"empty": "Nothing is playing",
|
"empty": "Nothing is playing",
|
||||||
"label": "Player",
|
"label": "Player",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
@@ -302,5 +306,27 @@
|
|||||||
"pick_more_few": "Pick {{count}} more artists",
|
"pick_more_few": "Pick {{count}} more artists",
|
||||||
"pick_more_many": "Pick {{count}} more artists",
|
"pick_more_many": "Pick {{count}} more artists",
|
||||||
"pick_more_other": "Pick {{count}} more artists"
|
"pick_more_other": "Pick {{count}} more artists"
|
||||||
|
},
|
||||||
|
"playlists": {
|
||||||
|
"name": "Name",
|
||||||
|
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
|
||||||
|
"manage": "Manage tracks",
|
||||||
|
"title": "Playlists",
|
||||||
|
"move_down": "Move {{name}} down",
|
||||||
|
"edit": "Edit playlist",
|
||||||
|
"description": "Description",
|
||||||
|
"add": "Add to playlist",
|
||||||
|
"search": "Search playlists",
|
||||||
|
"create": "Create playlist",
|
||||||
|
"find_tracks": "Find music",
|
||||||
|
"remove_track": "Remove {{name}} from playlist",
|
||||||
|
"save": "Save",
|
||||||
|
"private": "Private playlist",
|
||||||
|
"empty": "Your playlists will appear here. Create your first and add your favourite music.",
|
||||||
|
"empty_tracks": "No tracks yet. Use Add to playlist in search, albums or your collection.",
|
||||||
|
"loading": "Loading playlists…",
|
||||||
|
"move_up": "Move {{name}} up",
|
||||||
|
"delete": "Delete playlist",
|
||||||
|
"no_results": "No playlists found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,5 +93,12 @@
|
|||||||
"size_mismatch": "The file size does not match the declared one",
|
"size_mismatch": "The file size does not match the declared one",
|
||||||
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
||||||
"import_failed": "Could not write the file to the storage"
|
"import_failed": "Could not write the file to the storage"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"not_found": "Playlist not found",
|
||||||
|
"invalid_name": "Enter a name between 1 and 120 characters",
|
||||||
|
"invalid_tracks": "Tracks must be unique and exist in the library",
|
||||||
|
"too_large": "Maximum 2000 tracks and 2000 description characters",
|
||||||
|
"conflict": "This playlist changed on another device. Close the editor and try again with the refreshed data."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,17 +22,41 @@
|
|||||||
"logout": "Выйти"
|
"logout": "Выйти"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"link": "Приложение для Android",
|
"link": "Скачать приложения",
|
||||||
"title": "pipu для Android",
|
"badge": "Приложения",
|
||||||
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
|
"title": "pipu на ваших устройствах",
|
||||||
"download": "Скачать APK",
|
"subtitle": "Клиенты для этого сервера: телефон, компьютер и веб. Сначала предлагается сборка под вашу систему.",
|
||||||
|
"platform": {
|
||||||
|
"android": "Android",
|
||||||
|
"windows": "Windows",
|
||||||
|
"linux": "Linux",
|
||||||
|
"macos": "macOS"
|
||||||
|
},
|
||||||
|
"download": {
|
||||||
|
"android": "Скачать APK",
|
||||||
|
"windows": "Скачать для Windows",
|
||||||
|
"linux": "Скачать AppImage",
|
||||||
|
"macos": "Скачать для macOS"
|
||||||
|
},
|
||||||
"version": "версия {{version}}",
|
"version": "версия {{version}}",
|
||||||
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
"other": "Другие платформы",
|
||||||
|
"step_install": {
|
||||||
|
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||||
|
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
|
||||||
|
"linux": "Сделайте файл исполняемым и откройте его. Для .deb — установите пакет.",
|
||||||
|
"macos": "Откройте образ и перетащите pipu в Программы."
|
||||||
|
},
|
||||||
"step_server": "Запустите pipu и введите адрес сервера:",
|
"step_server": "Запустите pipu и введите адрес сервера:",
|
||||||
"copy_server": "Скопировать адрес сервера",
|
"copy_server": "Скопировать адрес сервера",
|
||||||
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
"step_updates": {
|
||||||
|
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
||||||
|
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||||
|
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
|
||||||
|
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
|
||||||
|
},
|
||||||
"checksum": "Контрольная сумма SHA-256",
|
"checksum": "Контрольная сумма SHA-256",
|
||||||
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
||||||
|
"unavailable_platform": "В последнем релизе пока нет сборки для {{platform}}.",
|
||||||
"to_login": "Войти в веб-версию"
|
"to_login": "Войти в веб-версию"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"menu": "Меню профиля",
|
"menu": "Меню профиля",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"logout": "Выйти",
|
"logout": "Выйти",
|
||||||
"android_app": "Приложение для Android"
|
"download_apps": "Скачать приложения"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "Администратор",
|
"admin": "Администратор",
|
||||||
@@ -54,6 +54,10 @@
|
|||||||
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"share": "Поделиться треком",
|
||||||
|
"link_copied": "Ссылка на трек скопирована",
|
||||||
|
"share_error": "Не удалось поделиться ссылкой. Попробуйте ещё раз.",
|
||||||
|
"track_page": "Трек",
|
||||||
"empty": "Ничего не играет",
|
"empty": "Ничего не играет",
|
||||||
"label": "Плеер",
|
"label": "Плеер",
|
||||||
"play": "Слушать",
|
"play": "Слушать",
|
||||||
@@ -302,5 +306,27 @@
|
|||||||
"pick_more_few": "Выберите ещё {{count}} исполнителя",
|
"pick_more_few": "Выберите ещё {{count}} исполнителя",
|
||||||
"pick_more_many": "Выберите ещё {{count}} исполнителей",
|
"pick_more_many": "Выберите ещё {{count}} исполнителей",
|
||||||
"pick_more_other": "Выберите ещё {{count}} исполнителя"
|
"pick_more_other": "Выберите ещё {{count}} исполнителя"
|
||||||
|
},
|
||||||
|
"playlists": {
|
||||||
|
"name": "Название",
|
||||||
|
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
|
||||||
|
"manage": "Управлять треками",
|
||||||
|
"title": "Плейлисты",
|
||||||
|
"move_down": "Переместить «{{name}}» ниже",
|
||||||
|
"edit": "Изменить плейлист",
|
||||||
|
"description": "Описание",
|
||||||
|
"add": "Добавить в плейлист",
|
||||||
|
"search": "Поиск плейлистов",
|
||||||
|
"create": "Создать плейлист",
|
||||||
|
"find_tracks": "Найти музыку",
|
||||||
|
"remove_track": "Убрать «{{name}}» из плейлиста",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"private": "Личный плейлист",
|
||||||
|
"empty": "Здесь будут ваши плейлисты. Создайте первый и добавьте любимую музыку.",
|
||||||
|
"empty_tracks": "В плейлисте пока нет треков. Добавляйте их кнопкой «Добавить в плейлист» в поиске, альбомах или коллекции.",
|
||||||
|
"loading": "Загрузка плейлистов…",
|
||||||
|
"move_up": "Переместить «{{name}}» выше",
|
||||||
|
"delete": "Удалить плейлист",
|
||||||
|
"no_results": "Плейлисты не найдены"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,5 +93,12 @@
|
|||||||
"size_mismatch": "Размер файла не совпал с заявленным",
|
"size_mismatch": "Размер файла не совпал с заявленным",
|
||||||
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
||||||
"import_failed": "Не удалось записать файл в хранилище"
|
"import_failed": "Не удалось записать файл в хранилище"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"not_found": "Плейлист не найден",
|
||||||
|
"invalid_name": "Введите название от 1 до 120 символов",
|
||||||
|
"invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке",
|
||||||
|
"too_large": "Максимум 2000 треков и 2000 символов описания",
|
||||||
|
"conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+133
-4
@@ -18,6 +18,12 @@ importers:
|
|||||||
specifier: 5.8.3
|
specifier: 5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
|
|
||||||
|
apps/desktop:
|
||||||
|
devDependencies:
|
||||||
|
'@tauri-apps/cli':
|
||||||
|
specifier: ^2.9.0
|
||||||
|
version: 2.12.3
|
||||||
|
|
||||||
apps/mobile:
|
apps/mobile:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@expo/vector-icons':
|
'@expo/vector-icons':
|
||||||
@@ -116,7 +122,7 @@ importers:
|
|||||||
version: 19.0.14
|
version: 19.0.14
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.7
|
specifier: ^3.2.7
|
||||||
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
|
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1458,6 +1464,82 @@ packages:
|
|||||||
'@tanstack/virtual-core@3.19.6':
|
'@tanstack/virtual-core@3.19.6':
|
||||||
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.12.3':
|
||||||
|
resolution: {integrity: sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.12.3':
|
||||||
|
resolution: {integrity: sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
|
||||||
|
resolution: {integrity: sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
|
||||||
|
resolution: {integrity: sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.12.3':
|
||||||
|
resolution: {integrity: sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.12.3':
|
||||||
|
resolution: {integrity: sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@testing-library/dom@10.4.2':
|
'@testing-library/dom@10.4.2':
|
||||||
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5560,6 +5642,53 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/virtual-core@3.19.6': {}
|
'@tanstack/virtual-core@3.19.6': {}
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.12.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@tauri-apps/cli-darwin-arm64': 2.12.3
|
||||||
|
'@tauri-apps/cli-darwin-x64': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-x64-musl': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc': 2.12.3
|
||||||
|
|
||||||
'@testing-library/dom@10.4.2':
|
'@testing-library/dom@10.4.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
@@ -8118,7 +8247,7 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2):
|
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3(supports-color@10.2.2)
|
debug: 4.4.3(supports-color@10.2.2)
|
||||||
@@ -8188,7 +8317,7 @@ snapshots:
|
|||||||
lightningcss: 1.27.0
|
lightningcss: 1.27.0
|
||||||
terser: 5.51.2
|
terser: 5.51.2
|
||||||
|
|
||||||
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2):
|
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.7
|
'@vitest/expect': 3.2.7
|
||||||
@@ -8211,7 +8340,7 @@ snapshots:
|
|||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||||
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.20.5
|
'@types/node': 22.20.5
|
||||||
|
|||||||
+100
-15
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
import { basename, resolve } from 'node:path';
|
import { basename, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
@@ -29,6 +29,12 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
|
|||||||
|
|
||||||
export const STATUS_START = '<!-- android-status -->';
|
export const STATUS_START = '<!-- android-status -->';
|
||||||
export const STATUS_END = '<!-- /android-status -->';
|
export const STATUS_END = '<!-- /android-status -->';
|
||||||
|
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
|
||||||
|
|
||||||
|
export function desktopStatusMarks(platform) {
|
||||||
|
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
|
||||||
|
return { start: `<!-- desktop-${platform}-status -->`, end: `<!-- /desktop-${platform}-status -->` };
|
||||||
|
}
|
||||||
|
|
||||||
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
|
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
|
||||||
export function groupCommits(commits) {
|
export function groupCommits(commits) {
|
||||||
@@ -76,16 +82,62 @@ export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing
|
|||||||
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
|
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
|
/** Блок статуса сборки десктопа в тексте релиза (отдельный для linux и windows). */
|
||||||
export function replaceAndroidStatus(body, block) {
|
export function renderDesktopStatus({ platform, status, runUrl, files }) {
|
||||||
const start = body.indexOf(STATUS_START);
|
const label = platform === 'windows' ? 'Windows' : 'Linux';
|
||||||
const end = body.indexOf(STATUS_END);
|
const pending = platform === 'windows' ? 'NSIS-установщик' : 'AppImage и .deb';
|
||||||
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
const lines =
|
||||||
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
|
status === 'success'
|
||||||
|
? [
|
||||||
|
`✅ **Десктоп (${label}):** собрано — ${files.map((f) => `\`${f.name}\` (${formatSize(f.size)})`).join(', ')}.`,
|
||||||
|
]
|
||||||
|
: status === 'cancelled'
|
||||||
|
? [
|
||||||
|
`⏹ **Десктоп (${label}):** сборка отменена — [лог сборки](${runUrl}).`,
|
||||||
|
'Файлы появятся здесь после перезапуска сборки.',
|
||||||
|
]
|
||||||
|
: status === 'failure'
|
||||||
|
? [
|
||||||
|
`❌ **Десктоп (${label}):** сборка не удалась — [лог сборки](${runUrl}).`,
|
||||||
|
'Остальные клиенты от этого не зависят; установщик появится после перезапуска сборки.',
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`⏳ **Десктоп (${label}):** собирается — [ход сборки](${runUrl}).`,
|
||||||
|
`${pending} появится в этом релизе автоматически.`,
|
||||||
|
];
|
||||||
|
const { start, end } = desktopStatusMarks(platform);
|
||||||
|
return [start, ...lines.map((l) => `> ${l}`), end].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
|
function replaceBlock(body, startMark, endMark, block) {
|
||||||
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
|
const start = body.indexOf(startMark);
|
||||||
|
const end = body.indexOf(endMark);
|
||||||
|
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
|
||||||
|
return body.slice(0, start) + block + body.slice(end + endMark.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Заменяет блок статуса Android в тексте релиза (или дописывает его в начало, если блока нет). */
|
||||||
|
export function replaceAndroidStatus(body, block) {
|
||||||
|
return replaceBlock(body, STATUS_START, STATUS_END, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceDesktopStatus(body, platform, block) {
|
||||||
|
const { start, end } = desktopStatusMarks(platform);
|
||||||
|
return replaceBlock(body, start, end, block);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Текст релиза: статусы сборок, изменения, обновление сервера, установка клиентов. */
|
||||||
|
export function renderNotes({
|
||||||
|
version,
|
||||||
|
tag,
|
||||||
|
prevTag,
|
||||||
|
prerelease,
|
||||||
|
repoUrl,
|
||||||
|
groups,
|
||||||
|
androidStatus,
|
||||||
|
desktopLinuxStatus,
|
||||||
|
desktopWindowsStatus,
|
||||||
|
}) {
|
||||||
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
|
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
|
||||||
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
|
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
|
||||||
const out = [];
|
const out = [];
|
||||||
@@ -93,7 +145,10 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
|
|||||||
if (prerelease) {
|
if (prerelease) {
|
||||||
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
|
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
|
||||||
}
|
}
|
||||||
out.push(androidStatus, '', '## Что изменилось', '');
|
out.push(androidStatus, '');
|
||||||
|
if (desktopLinuxStatus) out.push(desktopLinuxStatus, '');
|
||||||
|
if (desktopWindowsStatus) out.push(desktopWindowsStatus, '');
|
||||||
|
out.push('## Что изменилось', '');
|
||||||
if (groups.breaking.length) {
|
if (groups.breaking.length) {
|
||||||
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
|
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
|
||||||
}
|
}
|
||||||
@@ -124,11 +179,14 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
|
|||||||
'',
|
'',
|
||||||
`Ожидается \`"version":"${version}"\`.`,
|
`Ожидается \`"version":"${version}"\`.`,
|
||||||
'',
|
'',
|
||||||
'## Android',
|
'## Клиенты',
|
||||||
'',
|
'',
|
||||||
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
|
'Скачайте с этой страницы или с `/download` на сервере (подскажет сборку под вашу систему).',
|
||||||
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
|
'',
|
||||||
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
`- **Android:** \`pipu-music-${version}.apk\` — откройте на телефоне; Android попросит разрешить установку из этого источника.`,
|
||||||
|
`- **Windows:** \`pipu-music-${version}-windows-setup.exe\`.`,
|
||||||
|
`- **Linux:** \`pipu-music-${version}-linux.AppImage\` (сделайте исполняемым) или \`.deb\`.`,
|
||||||
|
`- **Проверка APK:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
|
||||||
'',
|
'',
|
||||||
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
|
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
|
||||||
'',
|
'',
|
||||||
@@ -202,6 +260,8 @@ async function create() {
|
|||||||
repoUrl,
|
repoUrl,
|
||||||
groups: groupCommits(commitsBetween(prevTag, tag)),
|
groups: groupCommits(commitsBetween(prevTag, tag)),
|
||||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
|
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
|
||||||
|
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl }),
|
||||||
|
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl }),
|
||||||
});
|
});
|
||||||
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
|
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
|
||||||
const saved = release
|
const saved = release
|
||||||
@@ -242,13 +302,38 @@ async function android(status, apkPath) {
|
|||||||
console.log(`статус Android: ${status}`);
|
console.log(`статус Android: ${status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function desktop(platform, status, paths) {
|
||||||
|
const { release, runUrl } = await context();
|
||||||
|
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
|
||||||
|
const files = [];
|
||||||
|
if (status === 'success') {
|
||||||
|
if (!paths.length) throw new Error('desktop success: укажите файлы');
|
||||||
|
for (const file of paths) {
|
||||||
|
const data = readFileSync(file);
|
||||||
|
files.push({ name: basename(file), size: data.length });
|
||||||
|
const sum = `${file}.sha256`;
|
||||||
|
if (!existsSync(sum)) writeFileSync(sum, `${createHash('sha256').update(data).digest('hex')}\n`);
|
||||||
|
await upload(release, file);
|
||||||
|
if (existsSync(sum)) await upload(release, sum);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const block = renderDesktopStatus({ platform, status, runUrl, files });
|
||||||
|
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceDesktopStatus(release.body ?? '', platform, block) } });
|
||||||
|
console.log(`статус десктопа (${platform}): ${status}`);
|
||||||
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const [cmd, ...args] = process.argv.slice(2);
|
const [cmd, ...args] = process.argv.slice(2);
|
||||||
if (cmd === 'create') return create();
|
if (cmd === 'create') return create();
|
||||||
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
|
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
|
||||||
return android(args[0], args[1]);
|
return android(args[0], args[1]);
|
||||||
}
|
}
|
||||||
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>');
|
if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
|
||||||
|
return desktop(args[0], args[1], args.slice(2));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...>',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||||
|
|||||||
@@ -4,8 +4,11 @@ import { test } from 'node:test';
|
|||||||
import {
|
import {
|
||||||
groupCommits,
|
groupCommits,
|
||||||
renderAndroidStatus,
|
renderAndroidStatus,
|
||||||
|
renderDesktopStatus,
|
||||||
renderNotes,
|
renderNotes,
|
||||||
replaceAndroidStatus,
|
replaceAndroidStatus,
|
||||||
|
replaceDesktopStatus,
|
||||||
|
desktopStatusMarks,
|
||||||
STATUS_END,
|
STATUS_END,
|
||||||
STATUS_START,
|
STATUS_START,
|
||||||
} from './release.mjs';
|
} from './release.mjs';
|
||||||
@@ -77,6 +80,34 @@ test('adds a status block when the body has none', () => {
|
|||||||
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
|
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('desktop status blocks', () => {
|
||||||
|
const linuxMarks = desktopStatusMarks('linux');
|
||||||
|
const winMarks = desktopStatusMarks('windows');
|
||||||
|
const building = renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' });
|
||||||
|
assert.ok(building.startsWith(linuxMarks.start) && building.endsWith(linuxMarks.end));
|
||||||
|
assert.match(building, /Десктоп \(Linux\):.*собирается — \[ход сборки\]\(https:\/\/run\)/);
|
||||||
|
const ok = renderDesktopStatus({
|
||||||
|
platform: 'linux',
|
||||||
|
status: 'success',
|
||||||
|
files: [{ name: 'pipu-music-1.4.0-linux.AppImage', size: 40 * 1024 * 1024 }],
|
||||||
|
});
|
||||||
|
assert.match(ok, /`pipu-music-1\.4\.0-linux\.AppImage` \(40,0 МБ\)/);
|
||||||
|
assert.equal(replaceDesktopStatus(`a\n\n${building}\n\nb`, 'linux', ok).includes(building), false);
|
||||||
|
|
||||||
|
const winBuilding = renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' });
|
||||||
|
assert.ok(winBuilding.startsWith(winMarks.start) && winBuilding.endsWith(winMarks.end));
|
||||||
|
const winOk = renderDesktopStatus({
|
||||||
|
platform: 'windows',
|
||||||
|
status: 'success',
|
||||||
|
files: [{ name: 'pipu-music-1.4.0-windows-setup.exe', size: 12 * 1024 * 1024 }],
|
||||||
|
});
|
||||||
|
const both = `${building}\n\n${winBuilding}`;
|
||||||
|
const patched = replaceDesktopStatus(both, 'windows', winOk);
|
||||||
|
assert.match(patched, /Linux/);
|
||||||
|
assert.match(patched, /windows-setup\.exe/);
|
||||||
|
assert.doesNotMatch(patched, /Десктоп \(Windows\):.*собирается/);
|
||||||
|
});
|
||||||
|
|
||||||
test('release notes', () => {
|
test('release notes', () => {
|
||||||
const notes = renderNotes({
|
const notes = renderNotes({
|
||||||
version: '0.2.0',
|
version: '0.2.0',
|
||||||
@@ -86,12 +117,16 @@ test('release notes', () => {
|
|||||||
repoUrl,
|
repoUrl,
|
||||||
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
|
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
|
||||||
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
|
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
|
||||||
|
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }),
|
||||||
|
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' }),
|
||||||
});
|
});
|
||||||
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
|
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
|
||||||
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
|
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
|
||||||
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
|
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
|
||||||
assert.match(notes, /"version":"0\.2\.0"/);
|
assert.match(notes, /"version":"0\.2\.0"/);
|
||||||
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
|
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
|
||||||
|
assert.match(notes, /pipu-music-0\.2\.0-windows-setup\.exe/);
|
||||||
|
assert.match(notes, /pipu-music-0\.2\.0-linux\.AppImage/);
|
||||||
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
|
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
|
||||||
assert.doesNotMatch(notes, /Предварительная версия/);
|
assert.doesNotMatch(notes, /Предварительная версия/);
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,5 @@
|
|||||||
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление):
|
// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
|
||||||
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
|
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
|
||||||
package appupdate
|
package appupdate
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -25,7 +25,7 @@ import (
|
|||||||
"pipu/api/internal/platform/httpx"
|
"pipu/api/internal/platform/httpx"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CodeUnavailable — обновления не настроены или релиза с APK нет.
|
// CodeUnavailable — обновления не настроены или релиза с клиентами нет.
|
||||||
const CodeUnavailable = "app.update_unavailable"
|
const CodeUnavailable = "app.update_unavailable"
|
||||||
|
|
||||||
const cacheTTL = 10 * time.Minute
|
const cacheTTL = 10 * time.Minute
|
||||||
@@ -73,14 +73,22 @@ type release struct {
|
|||||||
Assets []asset `json:"assets"`
|
Assets []asset `json:"assets"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Latest — сведения о последней версии.
|
type clientFile struct {
|
||||||
|
Kind, Name, URL, ChecksumURL, SHA256 string
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Latest — сведения о последней версии и файлах клиентов.
|
||||||
type Latest struct {
|
type Latest struct {
|
||||||
Version string
|
Version string
|
||||||
VersionCode int
|
VersionCode int
|
||||||
Notes string
|
Notes string
|
||||||
Size int64
|
Files map[string]clientFile
|
||||||
SHA256 string
|
}
|
||||||
apkURL string
|
|
||||||
|
func (l *Latest) android() (clientFile, bool) {
|
||||||
|
f, ok := l.Files["android"]
|
||||||
|
return f, ok && f.SHA256 != ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler реализует app-часть gen.StrictServerInterface.
|
// Handler реализует app-часть gen.StrictServerInterface.
|
||||||
@@ -92,7 +100,7 @@ type Handler struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cached *Latest
|
cached *Latest
|
||||||
cachedAt time.Time
|
cachedAt time.Time
|
||||||
dl sync.Mutex // одна загрузка APK за раз
|
dl sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler создаёт обработчик.
|
// NewHandler создаёт обработчик.
|
||||||
@@ -100,7 +108,6 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
|
|||||||
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
|
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
|
|
||||||
func (h *Handler) configured() bool {
|
func (h *Handler) configured() bool {
|
||||||
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
|
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
|
||||||
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
|
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
|
||||||
@@ -125,7 +132,70 @@ func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
|
|||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// latest — последний релиз с APK (кэш на 10 минут).
|
func classifyAsset(name string) string {
|
||||||
|
n := strings.ToLower(name)
|
||||||
|
if strings.HasSuffix(n, ".sha256") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(n, ".apk"):
|
||||||
|
return "android"
|
||||||
|
case strings.HasSuffix(n, ".exe"), strings.HasSuffix(n, ".msi"):
|
||||||
|
return "windows"
|
||||||
|
case strings.HasSuffix(n, ".appimage"), strings.HasSuffix(n, ".deb"), strings.HasSuffix(n, ".rpm"):
|
||||||
|
return "linux"
|
||||||
|
case strings.HasSuffix(n, ".dmg"), strings.Contains(n, ".app.tar"):
|
||||||
|
return "macos"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assetRank(kind, name string) int {
|
||||||
|
n := strings.ToLower(name)
|
||||||
|
switch kind {
|
||||||
|
case "windows":
|
||||||
|
if strings.Contains(n, "setup") {
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
case "linux":
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(n, ".appimage"):
|
||||||
|
return 3
|
||||||
|
case strings.HasSuffix(n, ".deb"):
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickClientFiles(assets []asset) map[string]clientFile {
|
||||||
|
sums := map[string]string{}
|
||||||
|
for _, a := range assets {
|
||||||
|
if strings.HasSuffix(strings.ToLower(a.Name), ".sha256") {
|
||||||
|
sums[strings.TrimSuffix(a.Name, ".sha256")] = a.URL
|
||||||
|
sums[strings.TrimSuffix(a.Name, ".SHA256")] = a.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := map[string]clientFile{}
|
||||||
|
for _, a := range assets {
|
||||||
|
kind := classifyAsset(a.Name)
|
||||||
|
if kind == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cur, ok := out[kind]
|
||||||
|
if ok && assetRank(kind, a.Name) <= assetRank(kind, cur.Name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[kind] = clientFile{Kind: kind, Name: a.Name, URL: a.URL, Size: a.Size, ChecksumURL: sums[a.Name]}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
||||||
if !h.configured() {
|
if !h.configured() {
|
||||||
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
|
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
|
||||||
@@ -142,7 +212,7 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
|
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
|
||||||
if h.cached != nil {
|
if h.cached != nil {
|
||||||
return h.cached, nil // устаревший ответ лучше, чем никакого
|
return h.cached, nil
|
||||||
}
|
}
|
||||||
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
|
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
|
||||||
}
|
}
|
||||||
@@ -156,35 +226,77 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperr.NotFound(CodeUnavailable, err.Error())
|
return nil, apperr.NotFound(CodeUnavailable, err.Error())
|
||||||
}
|
}
|
||||||
name := "pipu-music-" + version + ".apk"
|
files := pickClientFiles(rel.Assets)
|
||||||
var apk, sum *asset
|
if len(files) == 0 {
|
||||||
for i := range rel.Assets {
|
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
|
||||||
switch rel.Assets[i].Name {
|
}
|
||||||
case name:
|
if apk, ok := files["android"]; ok {
|
||||||
apk = &rel.Assets[i]
|
if apk.ChecksumURL == "" {
|
||||||
case name + ".sha256":
|
delete(files, "android")
|
||||||
sum = &rel.Assets[i]
|
} else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
|
||||||
|
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err)
|
||||||
|
delete(files, "android")
|
||||||
|
} else {
|
||||||
|
apk.SHA256 = digest
|
||||||
|
files["android"] = apk
|
||||||
|
}
|
||||||
|
if len(files) == 0 {
|
||||||
|
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if apk == nil || sum == nil {
|
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
|
||||||
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
h.cachedAt = time.Now()
|
||||||
}
|
return h.cached, nil
|
||||||
sres, err := h.get(ctx, sum.URL)
|
}
|
||||||
|
|
||||||
|
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
|
||||||
|
sres, err := h.get(ctx, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
|
return "", err
|
||||||
}
|
}
|
||||||
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
|
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
|
||||||
_ = sres.Body.Close()
|
_ = sres.Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return "", err
|
||||||
}
|
}
|
||||||
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
|
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
|
||||||
if len(digest) != 64 {
|
if len(digest) != 64 {
|
||||||
return nil, apperr.NotFound(CodeUnavailable, "malformed checksum")
|
return "", fmt.Errorf("malformed checksum")
|
||||||
}
|
}
|
||||||
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL}
|
return digest, nil
|
||||||
h.cachedAt = time.Now()
|
}
|
||||||
return h.cached, nil
|
|
||||||
|
func (h *Handler) fileURL(kind, version string) string {
|
||||||
|
return "/api/v1/app/download/" + kind + "/" + version
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppClients реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
|
||||||
|
l, err := h.latest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
order := []string{"windows", "macos", "linux", "android"}
|
||||||
|
clients := make([]gen.AppClientFile, 0, len(l.Files))
|
||||||
|
for _, kind := range order {
|
||||||
|
f, ok := l.Files[kind]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := gen.AppClientFile{
|
||||||
|
Client: gen.AppClient(kind),
|
||||||
|
Filename: f.Name,
|
||||||
|
Size: f.Size,
|
||||||
|
DownloadUrl: h.fileURL(kind, l.Version),
|
||||||
|
}
|
||||||
|
if f.SHA256 != "" {
|
||||||
|
item.Sha256 = &f.SHA256
|
||||||
|
}
|
||||||
|
clients = append(clients, item)
|
||||||
|
}
|
||||||
|
return gen.GetAppClients200JSONResponse{
|
||||||
|
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAndroidLatest реализует gen.StrictServerInterface.
|
// GetAndroidLatest реализует gen.StrictServerInterface.
|
||||||
@@ -193,15 +305,18 @@ func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRe
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
f, ok := l.android()
|
||||||
|
if !ok {
|
||||||
|
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
|
||||||
|
}
|
||||||
return gen.GetAndroidLatest200JSONResponse{
|
return gen.GetAndroidLatest200JSONResponse{
|
||||||
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256,
|
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: f.Size, Sha256: f.SHA256,
|
||||||
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
|
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза.
|
func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
|
||||||
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
dst := filepath.Join(h.cfg.Dir, f.Name)
|
||||||
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
|
|
||||||
if _, err := os.Stat(dst); err == nil {
|
if _, err := os.Stat(dst); err == nil {
|
||||||
return dst, nil
|
return dst, nil
|
||||||
}
|
}
|
||||||
@@ -213,12 +328,12 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
|||||||
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
|
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
res, err := h.get(ctx, l.apkURL)
|
res, err := h.get(ctx, f.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer func() { _ = res.Body.Close() }()
|
defer func() { _ = res.Body.Close() }()
|
||||||
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*")
|
tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -231,26 +346,53 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
|
|||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 {
|
if f.SHA256 != "" {
|
||||||
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256)
|
if got := hex.EncodeToString(hash.Sum(nil)); got != f.SHA256 {
|
||||||
|
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, f.SHA256)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err := os.Rename(tmp.Name(), dst); err != nil {
|
if err := os.Rename(tmp.Name(), dst); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
h.cleanup(dst)
|
h.cleanup(f.Name)
|
||||||
return dst, nil
|
return dst, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup оставляет в кэше только текущий APK.
|
func (h *Handler) cleanup(keepName string) {
|
||||||
func (h *Handler) cleanup(keep string) {
|
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
|
||||||
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk"))
|
ver := versionFromName(keepName)
|
||||||
for _, m := range matches {
|
for _, m := range matches {
|
||||||
if m != keep {
|
base := filepath.Base(m)
|
||||||
|
if ver != "" && strings.Contains(base, ver) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if base != keepName {
|
||||||
_ = os.Remove(m)
|
_ = os.Remove(m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func versionFromName(name string) string {
|
||||||
|
m := regexp.MustCompile(`(\d+\.\d+\.\d+(?:-rc\.\d+)?)`).FindStringSubmatch(name)
|
||||||
|
if m == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return m[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
|
||||||
|
f, ok := l.Files[kind]
|
||||||
|
if !ok || (kind == "android" && f.SHA256 == "") {
|
||||||
|
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
|
||||||
|
}
|
||||||
|
path, err := h.ensure(ctx, f)
|
||||||
|
if err != nil {
|
||||||
|
h.log.ErrorContext(ctx, "app update: download failed", "err", err, "client", kind)
|
||||||
|
return fileResponse{}, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the file")
|
||||||
|
}
|
||||||
|
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
|
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
|
||||||
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
|
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
|
||||||
l, err := h.latest(ctx)
|
l, err := h.latest(ctx)
|
||||||
@@ -260,22 +402,36 @@ func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroi
|
|||||||
if req.Version != l.Version {
|
if req.Version != l.Version {
|
||||||
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||||
}
|
}
|
||||||
path, err := h.ensure(ctx, l)
|
return h.serveFile(ctx, l, "android")
|
||||||
if err != nil {
|
|
||||||
h.log.ErrorContext(ctx, "app update: apk download failed", "err", err)
|
|
||||||
return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK")
|
|
||||||
}
|
|
||||||
return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type apkResponse struct {
|
// DownloadAppClient реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) {
|
||||||
|
l, err := h.latest(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Version != l.Version {
|
||||||
|
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
|
||||||
|
}
|
||||||
|
return h.serveFile(ctx, l, string(req.Client))
|
||||||
|
}
|
||||||
|
|
||||||
|
type fileResponse struct {
|
||||||
path string
|
path string
|
||||||
name string
|
name string
|
||||||
req *http.Request
|
req *http.Request
|
||||||
}
|
}
|
||||||
|
|
||||||
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки).
|
func (a fileResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
||||||
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
|
return a.write(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a fileResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
|
||||||
|
return a.write(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a fileResponse) write(w http.ResponseWriter) error {
|
||||||
f, err := os.Open(a.path)
|
f, err := os.Open(a.path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -288,7 +444,11 @@ func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) erro
|
|||||||
if a.req == nil {
|
if a.req == nil {
|
||||||
return errors.New("appupdate: request missing in context")
|
return errors.New("appupdate: request missing in context")
|
||||||
}
|
}
|
||||||
w.Header().Set("Content-Type", "application/vnd.android.package-archive")
|
ctype := "application/octet-stream"
|
||||||
|
if strings.HasSuffix(strings.ToLower(a.name), ".apk") {
|
||||||
|
ctype = "application/vnd.android.package-archive"
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", ctype)
|
||||||
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
|
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
|
||||||
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
|
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestPickClientFiles(t *testing.T) {
|
||||||
|
files := pickClientFiles([]asset{
|
||||||
|
{Name: "pipu-music-1.3.0.apk", Size: 10},
|
||||||
|
{Name: "pipu-music-1.3.0.apk.sha256", Size: 65, URL: "https://gitea/sum"},
|
||||||
|
{Name: "pipu-music_1.3.0_x64-setup.exe", Size: 20},
|
||||||
|
{Name: "pipu-music_1.3.0_amd64.deb", Size: 11},
|
||||||
|
{Name: "pipu-music_1.3.0_amd64.AppImage", Size: 30},
|
||||||
|
{Name: "pipu-music_1.3.0_aarch64.dmg", Size: 40},
|
||||||
|
})
|
||||||
|
assert.Equal(t, "pipu-music-1.3.0.apk", files["android"].Name)
|
||||||
|
assert.Equal(t, "https://gitea/sum", files["android"].ChecksumURL)
|
||||||
|
assert.Equal(t, "pipu-music_1.3.0_x64-setup.exe", files["windows"].Name)
|
||||||
|
assert.Equal(t, "pipu-music_1.3.0_amd64.AppImage", files["linux"].Name)
|
||||||
|
assert.Equal(t, "pipu-music_1.3.0_aarch64.dmg", files["macos"].Name)
|
||||||
|
}
|
||||||
|
|
||||||
func TestVersionCode(t *testing.T) {
|
func TestVersionCode(t *testing.T) {
|
||||||
cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099}
|
cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099}
|
||||||
for tag, want := range cases {
|
for tag, want := range cases {
|
||||||
@@ -60,17 +76,18 @@ func TestLatestAndDownload(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "0.6.1", l.Version)
|
assert.Equal(t, "0.6.1", l.Version)
|
||||||
assert.Equal(t, 60_199, l.VersionCode)
|
assert.Equal(t, 60_199, l.VersionCode)
|
||||||
assert.Equal(t, hex.EncodeToString(sum[:]), l.SHA256)
|
android := l.Files["android"]
|
||||||
|
assert.Equal(t, hex.EncodeToString(sum[:]), android.SHA256)
|
||||||
|
|
||||||
path, err := h.ensure(context.Background(), l)
|
path, err := h.ensure(context.Background(), android)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
got, _ := os.ReadFile(path)
|
got, _ := os.ReadFile(path)
|
||||||
assert.Equal(t, apk, got)
|
assert.Equal(t, apk, got)
|
||||||
|
|
||||||
// Подменённый файл не пройдёт сверку.
|
// Подменённый файл не пройдёт сверку.
|
||||||
require.NoError(t, os.Remove(path))
|
require.NoError(t, os.Remove(path))
|
||||||
l.SHA256 = "00" + l.SHA256[2:]
|
android.SHA256 = "00" + android.SHA256[2:]
|
||||||
_, err = h.ensure(context.Background(), l)
|
_, err = h.ensure(context.Background(), android)
|
||||||
assert.ErrorContains(t, err, "checksum mismatch")
|
assert.ErrorContains(t, err, "checksum mismatch")
|
||||||
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
|
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
|
||||||
assert.True(t, os.IsNotExist(statErr))
|
assert.True(t, os.IsNotExist(statErr))
|
||||||
|
|||||||
@@ -566,5 +566,6 @@ func TestPublicRoutes(t *testing.T) {
|
|||||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
|
||||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
|
||||||
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
|
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
|
||||||
|
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/clients", nil).Code, "download page is public")
|
||||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
|
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
|
||||||
}
|
}
|
||||||
@@ -55,8 +55,9 @@ var publicRoutes = map[string]bool{
|
|||||||
"GET /api/v1/server-info": true,
|
"GET /api/v1/server-info": true,
|
||||||
"POST /api/v1/auth/login": true,
|
"POST /api/v1/auth/login": true,
|
||||||
"POST /api/v1/auth/refresh": true,
|
"POST /api/v1/auth/refresh": true,
|
||||||
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
|
// Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
|
||||||
"GET /api/v1/app/android/latest": true,
|
"GET /api/v1/app/android/latest": true,
|
||||||
|
"GET /api/v1/app/clients": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
|
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
|
||||||
@@ -93,7 +94,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
|
|||||||
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
||||||
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
|
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
|
||||||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
|
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
|
||||||
|
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
|
||||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ type ClientType string
|
|||||||
const (
|
const (
|
||||||
ClientWeb ClientType = "web"
|
ClientWeb ClientType = "web"
|
||||||
ClientAndroid ClientType = "android"
|
ClientAndroid ClientType = "android"
|
||||||
|
ClientDesktop ClientType = "desktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Valid сообщает, известен ли тип клиента.
|
// Valid сообщает, известен ли тип клиента.
|
||||||
func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid }
|
func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid || c == ClientDesktop }
|
||||||
|
|
||||||
// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента.
|
// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента.
|
||||||
type UIPrefs struct {
|
type UIPrefs struct {
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validatePlaylist(name, description string, ids []uuid.UUID) error {
|
||||||
|
if n := utf8.RuneCountInString(strings.TrimSpace(name)); n < 1 || n > 120 {
|
||||||
|
return apperr.Validation("name", "playlist.invalid_name", "name must contain 1–120 characters", nil)
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(description) > 2000 || len(ids) > 2000 {
|
||||||
|
return apperr.Validation("playlist", "playlist.too_large", "maximum 2000 description characters and tracks", nil)
|
||||||
|
}
|
||||||
|
seen := map[uuid.UUID]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == uuid.Nil || seen[id] {
|
||||||
|
return apperr.Validation("track_ids", "playlist.invalid_tracks", "track IDs must be unique and nonzero", nil)
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistSelect = `SELECT p.id, p.name, p.description, p.revision, p.created_at, p.updated_at,
|
||||||
|
(SELECT count(*) FROM playlist_tracks pt WHERE pt.playlist_id=p.id),
|
||||||
|
COALESCE((SELECT sum(t.duration_ms) FROM playlist_tracks pt JOIN tracks t ON t.id=pt.track_id WHERE pt.playlist_id=p.id),0)
|
||||||
|
FROM playlists p`
|
||||||
|
|
||||||
|
func scanPlaylist(row pgx.Row) (gen.PlaylistSummary, error) {
|
||||||
|
var p gen.PlaylistSummary
|
||||||
|
err := row.Scan(&p.Id, &p.Name, &p.Description, &p.Revision, &p.CreatedAt, &p.UpdatedAt, &p.TrackCount, &p.DurationMs)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return p, apperr.NotFound("playlist.not_found", "playlist not found")
|
||||||
|
}
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListPlaylists(ctx context.Context, _ gen.ListPlaylistsRequestObject) (gen.ListPlaylistsResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := h.store.pool.Query(ctx, playlistSelect+` WHERE p.user_id=$1 ORDER BY p.updated_at DESC, p.id`, user.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := gen.PlaylistList{Items: []gen.PlaylistSummary{}}
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanPlaylist(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, p)
|
||||||
|
}
|
||||||
|
return gen.ListPlaylists200JSONResponse(out), rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlist, error) {
|
||||||
|
var out gen.Playlist
|
||||||
|
tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+` WHERE p.user_id=$1 AND p.id=$2`, user, id))
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, id)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(ctx); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
tracks, err := h.catalog.TracksByIDs(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
dtos, err := h.dto().Tracks(ctx, tracks)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if dtos == nil {
|
||||||
|
dtos = []gen.Track{}
|
||||||
|
}
|
||||||
|
out = gen.Playlist{Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos}
|
||||||
|
for _, t := range dtos {
|
||||||
|
out.DurationMs += int64(t.DurationMs)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetPlaylist(ctx context.Context, req gen.GetPlaylistRequestObject) (gen.GetPlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := h.playlist(ctx, user.UserID, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetPlaylist200JSONResponse(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row lock plus revision check prevents stale clients from overwriting each other.
|
||||||
|
// Replacing ordered membership is atomic, including validation of all foreign keys.
|
||||||
|
func (st *Store) savePlaylist(ctx context.Context, user, id uuid.UUID, name, description string, ids []uuid.UUID, revision int, create bool) error {
|
||||||
|
if err := validatePlaylist(name, description, ids); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := st.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
if create {
|
||||||
|
_, err = tx.Exec(ctx, `INSERT INTO playlists(id,user_id,name,description) VALUES($1,$2,$3,$4)`, id, user, strings.TrimSpace(name), description)
|
||||||
|
} else {
|
||||||
|
var current int
|
||||||
|
err = tx.QueryRow(ctx, `SELECT revision FROM playlists WHERE id=$1 AND user_id=$2 FOR UPDATE`, id, user).Scan(¤t)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return apperr.NotFound("playlist.not_found", "playlist not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current != revision {
|
||||||
|
return apperr.Conflict("playlist.conflict", "playlist changed; reload before editing")
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `UPDATE playlists SET name=$3, description=$4, revision=revision+1, updated_at=now() WHERE id=$1 AND user_id=$2`, id, user, strings.TrimSpace(name), description)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(ctx, `DELETE FROM playlist_tracks WHERE playlist_id=$1`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
_, err = tx.Exec(ctx, `INSERT INTO playlist_tracks(playlist_id,track_id,position) SELECT $1, id, ord-1 FROM unnest($2::uuid[]) WITH ORDINALITY AS x(id,ord)`, id, ids)
|
||||||
|
if err != nil {
|
||||||
|
var pgerr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgerr) && pgerr.Code == "23503" {
|
||||||
|
return apperr.Validation("track_ids", "playlist.invalid_tracks", "track no longer exists", nil)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePlaylist(ctx context.Context, req gen.CreatePlaylistRequestObject) (gen.CreatePlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Body == nil {
|
||||||
|
return nil, apperr.Validation("body", "playlist.invalid_name", "body required", nil)
|
||||||
|
}
|
||||||
|
id := uuid.New()
|
||||||
|
b := req.Body
|
||||||
|
if err = h.store.savePlaylist(ctx, user.UserID, id, b.Name, b.Description, b.TrackIds, 0, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := h.playlist(ctx, user.UserID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.CreatePlaylist201JSONResponse(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdatePlaylist(ctx context.Context, req gen.UpdatePlaylistRequestObject) (gen.UpdatePlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Body == nil {
|
||||||
|
return nil, apperr.Validation("body", "playlist.invalid_name", "body required", nil)
|
||||||
|
}
|
||||||
|
b := req.Body
|
||||||
|
if err = h.store.savePlaylist(ctx, user.UserID, req.Id, b.Name, b.Description, b.TrackIds, b.Revision, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := h.playlist(ctx, user.UserID, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.UpdatePlaylist200JSONResponse(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeletePlaylist(ctx context.Context, req gen.DeletePlaylistRequestObject) (gen.DeletePlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := h.store.pool.Exec(ctx, `DELETE FROM playlists WHERE id=$1 AND user_id=$2`, req.Id, user.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return nil, apperr.NotFound("playlist.not_found", "playlist not found")
|
||||||
|
}
|
||||||
|
return gen.DeletePlaylist204Response{}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/auth"
|
||||||
|
"pipu/api/internal/library"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
"pipu/api/internal/platform/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.Connect(ctx, url)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Close()
|
||||||
|
require.NoError(t, db.Migrate(ctx, pool))
|
||||||
|
owner, other, artist, album, first, second, id := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||||
|
_, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','user'),($3,$4,'x','user')`, owner, owner.String(), other, other.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1)`, []uuid.UUID{owner, other})
|
||||||
|
_, err = pool.Exec(ctx, `INSERT INTO artists(id,name,sort_name,name_norm) VALUES($1,'Artist','artist',$2)`, artist, artist.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM artists WHERE id=$1`, artist)
|
||||||
|
_, err = pool.Exec(ctx, `INSERT INTO albums(id,title,title_norm,album_artist_id) VALUES($1,'Album','album',$2)`, album, artist)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM albums WHERE id=$1`, album)
|
||||||
|
_, err = pool.Exec(ctx, `INSERT INTO tracks(id,title,title_norm,album_id,duration_ms) VALUES($1,'First','first',$3,1000),($2,'Second','second',$3,2000)`, first, second, album)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second})
|
||||||
|
st := NewStore(pool)
|
||||||
|
h := &Handler{store: st, catalog: library.NewStore(pool), dto: func() library.DTO { return library.DTO{} }}
|
||||||
|
require.NoError(t, st.savePlaylist(ctx, owner, id, " Mix ", "Description", []uuid.UUID{first, second}, 0, true))
|
||||||
|
got, err := h.playlist(ctx, owner, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Mix", got.Name)
|
||||||
|
require.Equal(t, int64(3000), got.DurationMs)
|
||||||
|
require.Equal(t, first, got.Tracks[0].Id)
|
||||||
|
_, err = h.playlist(ctx, other, id)
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
require.ErrorIs(t, st.savePlaylist(ctx, other, id, "stolen", "", nil, 1, false), apperr.NotFound("playlist.not_found", ""))
|
||||||
|
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id})
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
require.NoError(t, st.savePlaylist(ctx, owner, id, "Mix", "", []uuid.UUID{second, first}, 1, false))
|
||||||
|
require.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", ""))
|
||||||
|
// A missing track rolls back both metadata and membership.
|
||||||
|
require.Error(t, st.savePlaylist(ctx, owner, id, "broken", "", []uuid.UUID{uuid.New()}, 2, false))
|
||||||
|
got, err = h.playlist(ctx, owner, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, got.Revision)
|
||||||
|
require.Equal(t, "Mix", got.Name)
|
||||||
|
require.Equal(t, second, got.Tracks[0].Id)
|
||||||
|
// Exactly one concurrent write may consume a revision.
|
||||||
|
results := make(chan error, 2)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 2 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
results <- st.savePlaylist(ctx, owner, id, "Updated", "", []uuid.UUID{first}, 2, false)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
successes := 0
|
||||||
|
for e := range results {
|
||||||
|
if e == nil {
|
||||||
|
successes++
|
||||||
|
} else {
|
||||||
|
require.ErrorIs(t, e, apperr.Conflict("playlist.conflict", ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, successes)
|
||||||
|
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var remaining int
|
||||||
|
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining))
|
||||||
|
require.Zero(t, remaining)
|
||||||
|
// Deleting a playlist never deletes catalog tracks.
|
||||||
|
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second}).Scan(&remaining))
|
||||||
|
require.Equal(t, 2, remaining)
|
||||||
|
rows, err := pool.Query(ctx, `SELECT id FROM playlists WHERE id=$1`, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, ids)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidatePlaylist(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
require.NoError(t, validatePlaylist(" В дорогу ", "", []uuid.UUID{id}))
|
||||||
|
require.NoError(t, validatePlaylist(strings.Repeat("я", 120), strings.Repeat("я", 2000), nil))
|
||||||
|
for _, name := range []string{"", " \t\n", strings.Repeat("я", 121)} {
|
||||||
|
require.Error(t, validatePlaylist(name, "", nil))
|
||||||
|
}
|
||||||
|
require.Error(t, validatePlaylist("Mix", strings.Repeat("я", 2001), nil))
|
||||||
|
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{id, id}))
|
||||||
|
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{uuid.Nil}))
|
||||||
|
require.Error(t, validatePlaylist("Mix", "", make([]uuid.UUID, 2001)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
|
||||||
|
description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
||||||
|
revision integer NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX playlists_owner ON playlists(user_id, updated_at DESC, id);
|
||||||
|
CREATE TABLE playlist_tracks (
|
||||||
|
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
position integer NOT NULL CHECK (position >= 0),
|
||||||
|
PRIMARY KEY (playlist_id, track_id),
|
||||||
|
UNIQUE (playlist_id, position)
|
||||||
|
);
|
||||||
|
CREATE INDEX playlist_tracks_track ON playlist_tracks(track_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE playlist_tracks;
|
||||||
|
DROP TABLE playlists;
|
||||||
Reference in new issue
Block a user