Compare commits

...
12 Commits
Author SHA1 Message Date
mrleo1nidandCursor 83b604437c perf(desktop): ship a .deb instead of an 80MB AppImage
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m13s
ci / ts / ts (packages + web) (push) Canceled after 2m4s
ci / desktop / rust (desktop proxy) (push) Successful in 5m33s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Failing after 4m6s
release / desktop (Linux и Windows в релиз) (push) Successful in 17m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:26:50 +03:00
mrleo1nidandCursor 73ad61e7a5 feat(genius): load song metadata, lyrics, and artwork
ci / ts / ts (packages + web) (push) Canceled after 1m0s
ci / api / api (push) Successful in 46s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
ci / db / db (push) Successful in 1m32s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m31s
Search hits omit album covers and artist images. The worker now reads the full song and artist, stores the description, and leaves Genius image URLs unchanged on mobile.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 08:23:54 +03:00
mrleo1nid fb20258489 feat(player): add current track to playlists 2026-10-22 07:54:57 +03:00
mrleo1nid 78c006184b feat(branding): add unified Pipu Music app icons
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 2m30s
ci / ts / ts (packages + web) (push) Successful in 3m29s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m10s
ci / desktop / rust (desktop proxy) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 56s
2026-10-22 07:48:00 +03:00
mrleo1nidandCursor d3116aba96 perf(desktop): stop packing GStreamer into the AppImage
ci / ts / ts (packages + web) (push) Successful in 2m54s
release / release (версия и заметки) (push) Successful in 9s
release / android (APK в релиз) (push) Successful in 7m28s
ci / desktop / rust (desktop proxy) (push) Successful in 7m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m56s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 01:30:01 +03:00
mrleo1nidandCursor b7518aa450 fix(web): keep the track title readable in the 320px player
ci / ts / ts (packages + web) (push) Successful in 3m20s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:50:34 +03:00
mrleo1nidandCursor eb8031b126 ci(release): cross-compile Windows NSIS on the Linux runner
ci / ts / ts (packages + web) (push) Failing after 1m41s
release / release (версия и заметки) (push) Successful in 7s
release / android (APK в релиз) (push) Successful in 6m0s
ci / desktop / rust (desktop proxy) (push) Successful in 10m12s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m8s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:43:16 +03:00
mrleo1nidandCursor e4b841a8d8 ci(release): attach Linux and Windows desktop installers
ci / ts / ts (packages + web) (push) Failing after 1m42s
release / android (APK в релиз) (push) Successful in 7m49s
release / release (версия и заметки) (push) Successful in 8s
ci / desktop / rust (desktop proxy) (push) Successful in 7m35s
release / desktop (Linux в релиз) (push) Canceled after 2m48s
release / desktop (Windows в релиз) (push) Canceled after 0s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:32:33 +03:00
mrleo1nidandCursor 597067689a feat: add a platform-aware apps download page
ci / api / api (push) Successful in 48s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m10s
ci / db / db (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / desktop / rust (desktop proxy) (push) Successful in 5m37s
ci / ts / ts (packages + web) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 11s
release / android (APK в релиз) (push) Successful in 4m22s
Serve desktop and Android builds from the latest Gitea release on /download, and fix the desktop compile and CI flakes that blocked v1.3.0.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-22 00:10:03 +03:00
mrleo1nidandCursor efe78d7e07 feat(desktop): wrap the web client in a Tauri app
ci / api / api (push) Successful in 43s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m19s
ci / desktop / rust (desktop proxy) (push) Failing after 3m34s
ci / ts / ts (packages + web) (push) Failing after 2m49s
release / release (версия и заметки) (push) Successful in 5s
ci / db / db (push) Successful in 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m12s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m28s
release / android (APK в релиз) (push) Failing after 2m0s
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:45:03 +03:00
mrleo1nidandCursor fa79394869 feat: add personal playlists and shareable track links
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m8s
EOF

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:37:53 +03:00
mrleo1nid 3a097c15c8 perf(web): preload restored audio before playback
ci / ts / ts (packages + web) (push) Successful in 2m20s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m43s
2026-10-21 22:49:10 +03:00
130 changed files with 6731 additions and 501 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux .deb и Windows NSIS через cargo-xwin на ubuntu-latest).
name: ci / desktop
on:
push:
branches: [main]
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
pull_request:
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
name: rust (desktop proxy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
+5 -1
View File
@@ -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
+130 -5
View File
@@ -1,6 +1,7 @@
# Релиз по тегу 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).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
# #
# Секреты (Settings → Actions → Secrets): # Секреты (Settings → Actions → Secrets):
@@ -132,9 +133,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 +198,126 @@ jobs:
- name: Статус в релизе — сборка отменена - name: Статус в релизе — сборка отменена
if: cancelled() if: cancelled()
run: node scripts/release.mjs android cancelled run: node scripts/release.mjs android cancelled
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
desktop:
name: desktop (Linux и Windows в релиз)
needs: [release, android]
if: always() && needs.release.result == 'success'
timeout-minutes: 90
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_BUILD_JOBS: "2"
XWIN_CACHE_DIR: /opt/ci-cache/xwin
steps:
- uses: actions/checkout@v4
- name: Статус в релизе — собирается
run: |
node scripts/release.mjs desktop linux building
node scripts/release.mjs desktop windows building
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libdbus-1-dev libsecret-1-dev patchelf \
nsis lld llvm clang
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-xwin
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-
- run: pnpm install --frozen-lockfile
- name: Сборка deb
id: linux
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:linux
bundle=src-tauri/target/release/bundle
shopt -s nullglob
for f in "$bundle"/deb/*.deb; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
done
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
rm -rf src-tauri/target/release
- name: Linux в релиз
id: linux-upload
run: |
set -euo pipefail
f="pipu-music-${PIPU_VERSION}-linux.deb"
if [ ! -f "$f" ]; then
echo "::error::нет $f после сборки"
exit 1
fi
node scripts/release.mjs desktop linux success "$f"
- name: Windows toolchain
if: ${{ !cancelled() }}
run: |
set -euo pipefail
rustup target add x86_64-pc-windows-msvc
if ! command -v cargo-xwin >/dev/null; then
cargo install --locked cargo-xwin
fi
if [ ! -d /opt/ci-cache ]; then
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
mkdir -p "$HOME/.cache/xwin"
else
mkdir -p /opt/ci-cache/xwin
fi
- name: Сборка NSIS
id: win
if: ${{ !cancelled() }}
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:windows:cross
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
shopt -s nullglob
copied=
for f in "$bundle"/*-setup.exe; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
copied=1
done
if [ -z "$copied" ]; then
echo "::error::нет NSIS setup.exe после сборки в $bundle"
ls -la "$bundle" || true
exit 1
fi
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Windows в релиз
id: win-upload
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Статус Linux — сборка не удалась
if: failure() && steps.linux-upload.outcome != 'success'
run: node scripts/release.mjs desktop linux failure
- name: Статус Windows — сборка не удалась
if: failure() && steps.win-upload.outcome != 'success'
run: node scripts/release.mjs desktop windows failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: |
node scripts/release.mjs desktop linux cancelled
node scripts/release.mjs desktop windows cancelled
+7
View File
@@ -37,3 +37,10 @@ Thumbs.db
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.cache/ .cache/
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/
# Intermediate platform icon renders (final assets live under apps/).
/artifacts/icon-build/
+2 -1
View File
@@ -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, скрипты установки и обновления |
+291 -4
View File
@@ -22,6 +22,51 @@ tags:
security: security:
- bearerAuth: [] - bearerAuth: []
paths: paths:
/api/v1/admin/genius:
get:
operationId: getGeniusStats
tags: [admin]
responses:
"200":
description: Persistent enrichment statistics (admin only)
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
post:
operationId: controlGenius
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action:
type: string
enum: [start, pause, retry]
responses:
"200":
description: Updated statistics
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusStats"
/api/v1/tracks/{id}/genius:
parameters:
- $ref: "#/components/parameters/EntityId"
get:
operationId: getTrackGenius
tags: [catalog]
responses:
"200":
description: Cached lyrics and annotations; never makes an upstream request
content:
application/json:
schema:
$ref: "#/components/schemas/GeniusContent"
/healthz: /healthz:
get: get:
operationId: getHealthz operationId: getHealthz
@@ -1497,11 +1542,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, .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 +1629,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 +1765,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 +2314,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 +2334,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 +2349,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:
@@ -2847,10 +3061,51 @@ components:
description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости description: Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости
allOf: allOf:
- $ref: "#/components/schemas/TrackFeatures" - $ref: "#/components/schemas/TrackFeatures"
GeniusStats:
type: object
required: [configured, paused, running, total, pending, matched, lyrics, annotations, images, unmatched, errors, last_error, cooldown_until]
properties:
configured: { type: boolean }
paused: { type: boolean }
running: { type: boolean }
total: { type: integer }
pending: { type: integer }
matched: { type: integer }
lyrics: { type: integer }
annotations: { type: integer }
images: { type: integer }
unmatched: { type: integer }
errors: { type: integer }
last_error: { type: string }
cooldown_until: { type: string, format: date-time }
GeniusContent:
type: object
required: [status, lyrics, source_url, song_image_url, artist_image_url, description, annotations]
properties:
status: { type: string }
lyrics: { type: string }
source_url: { type: string }
song_image_url: { type: string }
artist_image_url: { type: string }
description: { type: string }
annotations:
type: array
items:
$ref: "#/components/schemas/GeniusAnnotation"
GeniusAnnotation:
type: object
required: [fragment, text, url, verified, authors]
properties:
fragment: { type: string }
text: { type: string }
url: { type: string }
verified: { type: boolean }
authors: { type: string }
Artist: Artist:
type: object type: object
required: [id, name, album_count, track_count] required: [id, name, album_count, track_count]
properties: properties:
image_url: { type: string }
id: id:
type: string type: string
format: uuid format: uuid
@@ -3323,6 +3578,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]
+19
View File
@@ -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}`);
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@pipu/desktop",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles deb",
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
"tauri": "tauri",
"icons": "node ../../scripts/generate-icons.mjs"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "pipu-desktop"
version = "0.0.0"
edition = "2021"
[lib]
name = "pipu_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "pipu-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
uuid = { version = "1", features = ["v4"] }
url = "2"
souvlaki = "0.8.3"
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "s"
panic = "abort"
+3
View File
@@ -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: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+157
View File
@@ -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");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
pipu_desktop_lib::run();
}
+62
View File
@@ -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:?}"))
}
}
+170
View File
@@ -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");
}
}
+80
View File
@@ -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()),
},
}
}
+31
View File
@@ -0,0 +1,31 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Pipu Music",
"version": "0.0.0",
"identifier": "app.pipu.music.desktop",
"build": {
"beforeDevCommand": "pnpm --filter @pipu/web dev",
"devUrl": "http://127.0.0.1:5173",
"beforeBuildCommand": "pnpm --filter @pipu/web build",
"frontendDist": "../../web/dist"
},
"app": {
"withGlobalTauri": true,
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
}
},
"bundle": {
"active": true,
"targets": ["nsis", "appimage", "deb", "rpm"],
"category": "Music",
"shortDescription": "Self-hosted music player",
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
"linux": {
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
"appimage": { "bundleMediaFramework": false }
},
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
}
}
+6
View File
@@ -9,6 +9,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
name: 'Pipu Music', name: 'Pipu Music',
slug: 'pipu-music', slug: 'pipu-music',
scheme: 'pipu', scheme: 'pipu',
icon: './assets/icon.png',
version, version,
orientation: 'portrait', orientation: 'portrait',
userInterfaceStyle: 'automatic', userInterfaceStyle: 'automatic',
@@ -17,6 +18,11 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
newArchEnabled: false, newArchEnabled: false,
backgroundColor: '#05070D', backgroundColor: '#05070D',
android: { android: {
adaptiveIcon: {
foregroundImage: './assets/adaptive-icon.png',
monochromeImage: './assets/monochrome-icon.png',
backgroundColor: '#05070D',
},
package: 'app.pipu.music', package: 'app.pipu.music',
versionCode, versionCode,
edgeToEdgeEnabled: true, edgeToEdgeEnabled: true,
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -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} />;
+1
View File
@@ -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>
+28 -3
View File
@@ -1,9 +1,10 @@
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native'; import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
import { trackShareUrl } from '@pipu/core';
import { useUser } from '../lib/session'; import { useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller'; import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks'; import { usePlayerState } from '../player/hooks';
import { useColors } from '../theme'; import { useColors } from '../theme';
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
const router = useRouter(); const router = useRouter();
const st = usePlayerState(); const st = usePlayerState();
const prefs = useUser().ui_prefs; const prefs = useUser().ui_prefs;
const { server } = useSessionCtx();
const [sharing, setSharing] = useState(false);
const [barWidth, setBarWidth] = useState(1); const [barWidth, setBarWidth] = useState(1);
const track = st.track; const track = st.track;
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
const duration = st.durationMs || track.duration_ms; const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0; const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined); const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
if (!server || sharing) return;
setSharing(true);
try {
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
} catch {
Alert.alert(t('player.share'), t('player.share_error'));
} finally {
setSharing(false);
}
};
return ( return (
<Screen> <Screen>
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
<Icon name="keyboard-arrow-down" size={32} color={colors.text} /> <Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable> </Pressable>
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text> <Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
<DownloadButton track={track} /> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
<Icon name="share" size={24} color={colors.text} />
</Pressable>
<DownloadButton track={track} />
</View>
</View> </View>
<View style={{ alignItems: 'center' }}> <View style={{ alignItems: 'center' }}>
@@ -72,6 +91,12 @@ export default function PlayerScreen() {
<Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}> <Text numberOfLines={1} style={{ color: colors.text2, fontSize: 15 }}>
{track.artists.map((a) => a.name).join(', ')} {track.artists.map((a) => a.name).join(', ')}
</Text> </Text>
<Pressable onPress={() => router.push({ pathname: '/playlists', params: { track: track.id } })}
accessibilityRole="button" accessibilityLabel={t('playlists.add')}
style={{ flexDirection: 'row', alignItems: 'center', gap: 8, minHeight: 44, alignSelf: 'flex-start' }}>
<Icon name="playlist-add" size={24} color={colors.text2} />
<Text style={{ color: colors.text2 }}>{t('playlists.add')}</Text>
</Pressable>
</View> </View>
<View style={{ gap: 6 }}> <View style={{ gap: 6 }}>
+129
View File
@@ -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>;
}
+6 -1
View File
@@ -272,7 +272,7 @@ class Downloads {
if (track.album.cover_url) { if (track.album.cover_url) {
const cover = this.cover(track.album.id); const cover = this.cover(track.album.id);
const info = await FileSystem.getInfoAsync(cover); const info = await FileSystem.getInfoAsync(cover);
if (!info.exists) await FileSystem.downloadAsync(`${this.abs(track.album.cover_url)}&size=300`, cover).catch(() => undefined); if (!info.exists) await FileSystem.downloadAsync(sizedCover(this.abs(track.album.cover_url), 300), cover).catch(() => undefined);
} }
const info = await FileSystem.getInfoAsync(this.file(id)); const info = await FileSystem.getInfoAsync(this.file(id));
await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track); await status('done', { progress: 1, bytes: info.exists ? info.size : 0 }, track);
@@ -286,4 +286,9 @@ class Downloads {
} }
} }
/** Размер нужен только обложкам Pipu. Прямые ссылки Genius параметр size не принимают. */
export function sizedCover(url: string, size: number): string {
return url.includes('/api/v1/covers/') ? `${url}&size=${size}` : url;
}
export const downloads = new Downloads(); export const downloads = new Downloads();
+13 -10
View File
@@ -17,7 +17,7 @@ import TrackPlayer, {
} from 'react-native-track-player'; } from 'react-native-track-player';
import { playerCache, qualityPrefs, type Quality } from '../lib/prefs'; import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
import { downloads } from '../offline/downloads'; import { downloads, sizedCover } from '../offline/downloads';
import { PersistentEvents } from '../offline/events'; import { PersistentEvents } from '../offline/events';
import { offlineWave } from './offlineWave'; import { offlineWave } from './offlineWave';
import { eventId } from './eventId'; import { eventId } from './eventId';
@@ -224,15 +224,18 @@ class Controller {
private async toRntp(tracks: Track[]): Promise<RntpTrack[]> { private async toRntp(tracks: Track[]): Promise<RntpTrack[]> {
const q = await quality(); const q = await quality();
// Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети. // Скачанный трек играется из файла (и без сети), остальные — стримом по качеству сети.
return tracks.map((t) => ({ return tracks.map((t) => {
id: t.id, const cover = absolute(this.server, t.album.cover_url);
url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`, return {
title: t.title, id: t.id,
artist: t.artists.map((a) => a.name).join(', '), url: downloads.localTrack(t.id) ?? `${absolute(this.server, t.stream_url)}&q=${q}`,
album: t.album.title, title: t.title,
artwork: downloads.localCover(t) ?? (t.album.cover_url ? `${absolute(this.server, t.album.cover_url)}&size=300` : undefined), artist: t.artists.map((a) => a.name).join(', '),
duration: t.duration_ms / 1000, album: t.album.title,
})); artwork: downloads.localCover(t) ?? (cover ? sizedCover(cover, 300) : undefined),
duration: t.duration_ms / 1000,
};
});
} }
async playList(tracks: Track[], index: number, context: PlayContext) { async playList(tracks: Track[], index: number, context: PlayContext) {
+8 -2
View File
@@ -1,4 +1,5 @@
import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { useRouter } from 'expo-router';
import { ApiError, type Track } from '@pipu/core'; import { ApiError, type Track } from '@pipu/core';
import type { ComponentProps, ReactNode } from 'react'; import type { ComponentProps, ReactNode } from 'react';
import { useState, useSyncExternalStore } from 'react'; import { useState, useSyncExternalStore } from 'react';
@@ -19,7 +20,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useSessionCtx } from '../lib/session'; import { useSessionCtx } from '../lib/session';
import { downloads } from '../offline/downloads'; import { downloads, sizedCover } from '../offline/downloads';
import { absolute } from '../player/controller'; import { absolute } from '../player/controller';
import { radius, useColors, type Colors } from '../theme'; import { radius, useColors, type Colors } from '../theme';
@@ -160,7 +161,7 @@ export function Cover({
const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200; const px = size * 2 <= 64 ? 64 : size * 2 <= 300 ? 300 : 1200;
// У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети. // У скачанных альбомов обложка 300 px лежит на устройстве — работает и без сети.
const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined)); const local = useSyncExternalStore(downloads.subscribe, () => (px <= 300 ? downloads.localAlbumCover(album.id) : undefined));
const uri = local ?? (remote ? `${remote}&size=${px}` : undefined); const uri = local ?? (remote ? sizedCover(remote, px) : undefined);
const bg = album.cover_colors?.[0] ?? colors.surface3; const bg = album.cover_colors?.[0] ?? colors.surface3;
return ( return (
<View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}> <View style={{ width: size, height: size, borderRadius: round ? size / 2 : 8, backgroundColor: bg, overflow: 'hidden', alignItems: 'center', justifyContent: 'center' }}>
@@ -199,6 +200,7 @@ export function TrackRow({
const colors = useColors(); const colors = useColors();
const { t } = useTranslation(); const { t } = useTranslation();
const liked = track.reaction === 'like'; const liked = track.reaction === 'like';
const router = useRouter();
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id)); const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
return ( return (
<Pressable <Pressable
@@ -233,6 +235,10 @@ export function TrackRow({
</Pressable> </Pressable>
)} )}
{trailing} {trailing}
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
<Icon name="playlist-add" size={22} />
</Pressable>
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />} {saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text> <Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
</Pressable> </Pressable>
+36
View File
@@ -0,0 +1,36 @@
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('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await player.getByRole('button', { name: 'Добавить в плейлист', exact: true }).click();
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('playlist.png') });
});
}
+2
View File
@@ -5,6 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon-32.png" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180" />
<title>pipu</title> <title>pipu</title>
</head> </head>
<body> <body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+17 -1
View File
@@ -1 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><radialGradient id="g" cx="35%" cy="30%" r="75%"><stop offset="0" stop-color="#9CC6FF"/><stop offset=".55" stop-color="#2F80FF"/><stop offset="1" stop-color="#3A1FA8"/></radialGradient></defs><circle cx="16" cy="16" r="14" fill="url(#g)"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="orb" cx="28%" cy="18%" r="95%">
<stop stop-color="#9CC6FF"/>
<stop offset=".42" stop-color="#2F80FF"/>
<stop offset=".78" stop-color="#6354E8"/>
<stop offset="1" stop-color="#3A1FA8"/>
</radialGradient>
<linearGradient id="note" x1="0" y1="0" x2=".3" y2="1">
<stop stop-color="#FFFFFF"/>
<stop offset="1" stop-color="#DFEBFF"/>
</linearGradient>
</defs>
<circle cx="256" cy="256" r="240" fill="url(#orb)"/>
<path d="M208 348V164H270C356 164 356 280 270 280H208" fill="none" stroke="url(#note)" stroke-width="44" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="181" cy="348" rx="49" ry="36" fill="url(#note)" transform="rotate(-18 181 348)"/>
</svg>

Before

Width:  |  Height:  |  Size: 312 B

After

Width:  |  Height:  |  Size: 840 B

+96
View File
@@ -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,75 @@ describe('safeRedirect', () => {
expect(safeRedirect(input)).toBe(expected); expect(safeRedirect(input)).toBe(expected);
}); });
}); });
describe('playlists', () => {
it('adds the playing track from the player, keeping the selected track when playback advances', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('listitem')[0]!);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Из плеера');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(api.playlists.size).toBe(1));
expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови');
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' }));
// Simulate automatic advancement while the picker is open.
await user.click(within(bar).getByRole('button', { name: 'Следующий' }));
await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' }));
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']);
});
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'В дорогу');
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'В дорогу' });
const id = [...api.playlists.keys()][0]!;
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
await router.navigate({ to: '/playlist/$id', params: { id } });
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
await user.clear(screen.getByLabelText('Название'));
await user.type(screen.getByLabelText('Название'), 'Вечер');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Вечер' });
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
await screen.findByRole('heading', { name: 'Плейлисты' });
expect(api.playlists.size).toBe(0);
});
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Первый');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Первый' });
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
const item = [...api.playlists.values()][0]!;
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
await user.type(screen.getByLabelText('Название'), ' изменён');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
});
});
+48 -9
View File
@@ -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;
+28
View File
@@ -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__;
});
});
+94
View File
@@ -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;
}
+1
View File
@@ -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; }
} }
+8 -3
View File
@@ -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';
@@ -27,6 +28,7 @@ function fallbackColors(id: string): string[] {
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */ /** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string { export function coverSrc(url: string, cssPx: number): string {
if (!url.includes('/api/v1/covers/')) return url;
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1); const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200; const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`; return `${url}&size=${size}`;
@@ -115,6 +117,7 @@ export function TrackList({
rowMeta, rowMeta,
lead, lead,
disliked, disliked,
actions,
}: { }: {
tracks: Track[]; tracks: Track[];
showAlbum?: boolean; showAlbum?: boolean;
@@ -126,6 +129,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 +158,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 +185,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"
+98
View File
@@ -0,0 +1,98 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { playlistAddIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
export type PlaylistInput = components['schemas']['PlaylistInput'];
export function usePlaylists(enabled = true) {
const session = useSession();
return useQuery({ queryKey: ['playlists'], enabled,
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
}
export function usePlaylist(id: string) {
const session = useSession();
return useQuery({ queryKey: ['playlists', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
}
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
}
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
}) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const [original] = useState(playlist);
const [name, setName] = useState(playlist?.name ?? '');
const [description, setDescription] = useState(playlist?.description ?? '');
const save = useMutation({ mutationFn: async () => {
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
return original
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
}, onSuccess: p => {
cache.setQueryData(['playlists', p.id], p);
void cache.invalidateQueries({ queryKey: ['playlists'] });
onSaved(p);
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
</form>
</Dialog>;
}
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
const PlaylistPicker = memo(function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const list = usePlaylists();
const [create, setCreate] = useState(false);
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = playlistInput(p);
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
<div style={{ display: 'grid', gap: 8 }}>
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
});
export function AddToPlaylist({ trackIds, compact = false, className }: { trackIds: string[]; compact?: boolean; className?: string }) {
const { t } = useTranslation();
const [selectedIds, setSelectedIds] = useState<string[] | null>(null);
const close = useCallback(() => setSelectedIds(null), []);
return <>
{compact ? <button type="button" className={className ?? s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setSelectedIds([...trackIds])}><Icon src={playlistAddIcon} size={20} /></button>
: <Button icon={playlistAddIcon} disabled={trackIds.length === 0} onClick={() => setSelectedIds([...trackIds])}>{t('playlists.add')}</Button>}
{selectedIds && <PlaylistPicker trackIds={selectedIds} onClose={close} />}
</>;
}
+12 -3
View File
@@ -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>,
); );
+2
View File
@@ -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>
+67 -32
View File
@@ -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>
);
}
+1 -1
View File
@@ -39,7 +39,7 @@ export function ArtistPage({ id }: { id: string }) {
return ( return (
<div className={s.page}> <div className={s.page}>
<header className={c.hero}> <header className={c.hero}>
<Cover album={{ id }} size={168} round /> <Cover album={{ id, cover_url: a?.image_url }} size={168} round />
<div className={c.heroBody}> <div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span> <span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1> <h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
+55
View File
@@ -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>
);
}
+9 -1
View File
@@ -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
} }
+109
View File
@@ -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>}
</>;
}
+17
View File
@@ -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>
+36
View File
@@ -0,0 +1,36 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { FormError } from '../shared/ui/ui';
import s from './page.module.css';
import g from './genius.module.css';
export function TrackGenius({ id }: { id: string }) {
const session = useSession();
const { t } = useTranslation();
const errorText = useErrorText();
const query = useQuery({
queryKey: ['track-genius', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}/genius', { params: { path: { id } } })),
refetchInterval: (q) => ['pending', 'processing'].includes(q.state.data?.status ?? '') ? 10000 : false,
});
const data = query.data;
if (data?.status === 'disabled') return null;
return <section className={s.section}>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <p>{t('genius.loading')}</p>}
{data?.description && <><h2>{t('genius.about')}</h2><p className={g.lyrics}>{data.description}</p></>}
<div className={s.head}><h2>{t('genius.lyrics')}</h2>{data?.source_url && <a href={data.source_url} target="_blank" rel="noopener noreferrer">Genius ↗</a>}</div>
{data && (data.lyrics ? <div className={g.lyrics}>{data.lyrics}</div> : <p className={s.subtitle}>{['pending', 'processing'].includes(data.status) ? t('genius.pending_text') : t('genius.no_lyrics')}</p>)}
{!!data?.annotations.length && <>
<h2>{t('genius.explanations')}</h2>
{data.annotations.map((a, i) => <details className={g.annotation} key={`${a.url}-${i}`}>
<summary>{a.fragment}{a.verified && <span className={g.badge}>{t('genius.verified')}</span>}</summary>
<p className={g.lyrics}>{a.text}</p>
<p className={s.subtitle}>{a.authors}{a.url && <> · <a href={a.url} target="_blank" rel="noopener noreferrer">Genius ↗</a></>}</p>
</details>)}
</>}
</section>;
}
+40
View File
@@ -0,0 +1,40 @@
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';
import { TrackGenius } from './TrackGenius';
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' }} />}
{tr && <TrackGenius id={id} />}
</div>
);
}
+46
View File
@@ -0,0 +1,46 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import g from '../genius.module.css';
export function GeniusPanel() {
const { t } = useTranslation();
const session = useSession();
const queries = useQueryClient();
const errorText = useErrorText();
const stats = useQuery({
queryKey: ['genius-stats'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/genius')),
refetchInterval: 5000,
});
const control = useMutation({
mutationFn: async (action: 'start' | 'pause' | 'retry') =>
unwrap(await session.client.POST('/api/v1/admin/genius', { body: { action } })),
onSuccess: (data) => { queries.setQueryData(['genius-stats'], data); },
});
const data = stats.data;
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting');
return <section className={s.section} aria-label="Genius">
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
<p className={s.subtitle}>{t('genius.description')}</p>
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
{data && <>
{!data.configured && <p>{t('genius.configure')}</p>}
<dl className={g.stats}>
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
</dl>
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
<div className={g.actions}>
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
</div>
</>}
</section>;
}
+4 -1
View File
@@ -5,7 +5,8 @@ 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 } from '../../app/session'; import { useSession, useUser } from '../../app/session';
import { GeniusPanel } from './GeniusPanel';
import { useErrorText, useFieldError } from '../../shared/lib/errors'; import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons'; import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui'; import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
@@ -19,6 +20,7 @@ type TestResult = components['schemas']['SourceTestResult'];
const SCAN_POLL_MS = 2000; const SCAN_POLL_MS = 2000;
export function SourcesPage() { export function SourcesPage() {
const user = useUser();
const { t } = useTranslation(['admin', 'common']); const { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
const errorText = useErrorText(); const errorText = useErrorText();
@@ -43,6 +45,7 @@ export function SourcesPage() {
</Button> </Button>
</div> </div>
<FormError>{sources.error ? errorText(sources.error) : null}</FormError> <FormError>{sources.error ? errorText(sources.error) : null}</FormError>
{user.role === 'admin' && <GeniusPanel />}
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>} {sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
<div className={l.list}> <div className={l.list}>
{sources.data?.map((src) => ( {sources.data?.map((src) => (
+35 -1
View File
@@ -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;
} }
+8
View File
@@ -0,0 +1,8 @@
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 16px; margin: 0; }
.stats dt { color: var(--text2); font-size: 13px; }
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
.annotation summary { cursor: pointer; line-height: 1.6; }
.badge { display: inline-block; margin-left: 12px; font-size: 12px; color: var(--text2); }
+14
View File
@@ -0,0 +1,14 @@
.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; }
.hero { overflow-wrap: anywhere; }
@media (max-width: 600px) { .hero { flex-direction: column; align-items: flex-start; gap: 16px; } .hero > div { width: 100%; } }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
+76 -12
View File
@@ -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();
} }
} }
+79
View File
@@ -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();
});
});
+17
View File
@@ -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']);
});
});
+17
View File
@@ -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);
}
+40
View File
@@ -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(); }
});
});
+17
View File
@@ -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';
}
+2
View File
@@ -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';
@@ -69,4 +70,5 @@ export { default as waveIcon } from '@material-symbols/svg-400/rounded/graphic_e
export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg'; export { default as tuneIcon } from '@material-symbols/svg-400/rounded/tune.svg';
export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg'; export { default as tuneFillIcon } from '@material-symbols/svg-400/rounded/tune-fill.svg';
export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg'; export { default as queueIcon } from '@material-symbols/svg-400/rounded/queue_music.svg';
export { default as playlistAddIcon } from '@material-symbols/svg-400/rounded/playlist_add.svg';
export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg'; export { default as undoIcon } from '@material-symbols/svg-400/rounded/undo.svg';
+3 -2
View File
@@ -21,9 +21,10 @@
} }
.logoDot { .logoDot {
display: inline-block;
flex: none;
border-radius: 50%; border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8); background: url('/favicon.svg') center / contain no-repeat;
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
} }
.button { .button {
+54 -3
View File
@@ -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.deb', size: 4_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,
}); });
@@ -259,7 +302,15 @@ export function fakeApi(initial: FakeUser[]) {
users.set(target.id, target); users.set(target.id, target);
return json(admin(target)); return json(admin(target));
} }
if (req.method === 'GET' && /^\/api\/v1\/tracks\/[^/]+\/genius$/.test(path)) {
return json({ status: 'disabled', lyrics: '', source_url: '', song_image_url: '', artist_image_url: '', description: '', annotations: [] });
}
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 +319,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) };
} }
+9 -2
View File
@@ -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() },
]} ]}
/> />
+19 -1
View File
@@ -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,11 +311,28 @@ 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):not(.playlistButton) { display: none; }
.error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
.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)); }
} }
@media (max-width: 359px) { @media (max-width: 359px) {
.now > :first-child { display: none; } .now > :first-child { display: none; }
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
.shareButton { 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;
} }
+31 -1
View File
@@ -4,8 +4,10 @@ import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog'; import { ArtistLinks, Cover } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
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 +24,7 @@ import {
repeatIcon, repeatIcon,
repeatOneIcon, repeatOneIcon,
shuffleIcon, shuffleIcon,
shareIcon,
volumeIcon, volumeIcon,
volumeOffIcon, volumeOffIcon,
waveIcon, waveIcon,
@@ -35,6 +38,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 +70,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 +92,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 +179,10 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
</div> </div>
<div className={s.right}> <div className={s.right}>
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
<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 +233,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
View File
@@ -44,6 +44,10 @@ LOG_LEVEL=info
SUPPORTED_LOCALES=ru,en SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0 MIN_ANDROID_VERSION_CODE=0
# Genius: empty token disables enrichment. Queue and controls: Admin → Sources.
GENIUS_ACCESS_TOKEN=
GENIUS_REQUEST_INTERVAL=2s
# --- Самообновление Android (M5) --- # --- Самообновление Android (M5) ---
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music). # Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000 # Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
+4 -4
View File
@@ -5,7 +5,7 @@
## Суть ## Суть
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера. Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие. музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше. Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
@@ -16,7 +16,7 @@
## Доступ и учётные записи ## Доступ и учётные записи
- При первом открытии приложение показывает **экран входа** (логин + пароль). - При первом открытии приложение показывает **экран входа** (логин + пароль).
В мобильном приложении на этом экране также указывается **адрес сервера**. В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
- **Регистрации нет.** Учётные записи создаёт администрация. - **Регистрации нет.** Учётные записи создаёт администрация.
- Роли: - Роли:
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов - **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
@@ -57,19 +57,19 @@
### Левая панель навигации ### Левая панель навигации
- **Главная** - **Главная**
- **Коллекция** — с подкатегориями: - **Коллекция** — с подкатегориями:
- **Плейлисты** — личные подборки треков;
- **История** — прослушанные треки; - **История** — прослушанные треки;
- **Лайки** — понравившиеся треки; - **Лайки** — понравившиеся треки;
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк); - **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна. - **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
- **Админка** — видна только администраторам и модераторам. - **Админка** — видна только администраторам и модераторам.
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль), На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер. плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
### Плеер (всегда внизу) ### Плеер (всегда внизу)
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд, - Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь. прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь.
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки - **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
и даже на другом устройстве можно продолжить с того же места. и даже на другом устройстве можно продолжить с того же места.
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие). - Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
+3
View File
@@ -15,8 +15,11 @@ 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) | Стриминг, транскодинг, состояние плеера, офлайн |
| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда |
| [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
View File
@@ -102,8 +102,10 @@
прочитайте заметки к релизу на странице релизов Gitea. прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK. Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза, Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля. сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
и в меню профиля.
## 7. Наблюдение ## 7. Наблюдение
+4
View File
@@ -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) |
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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` | |
+7 -1
View File
@@ -43,7 +43,13 @@
| `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов | | `overlay` | `rgba(3,5,10,.66)` | `rgba(20,30,60,.32)` | Подложка диалогов |
| `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` | | `glass` | `rgba(12,16,28,.78)` | `rgba(255,255,255,.84)` | Стеклянные карточки (вход, диалоги) + `backdrop-filter: blur(28px) saturate(1.3)` |
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — радиальный градиент `#9CC6FF → #2F80FF → #3A1FA8`. Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
и монохромный слои с отступами для масок лаунчера; фон — `#05070D`.
После изменения значков установленные приложения нужно пересобрать и обновить.
### Типографика ### Типографика
+17
View File
@@ -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-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
+83
View File
@@ -0,0 +1,83 @@
# Genius: поиск метаданных и получение текста
Реализован Go-провайдер `services/api/internal/genius` и отдельная команда
`services/api/cmd/genius`. Поиск через API возвращает кандидатов, ссылки на изображения
и исполнителя. Текст выбранной песни извлекается из HTML страницы: из контейнеров
`data-lyrics-container` либо из `songPage.lyricsData.body.html` во встроенном состоянии.
JavaScript не исполняется. Используется HTML-парсер `golang.org/x/net/html`.
Это проверяемый первый этап: команда работает отдельно от сервера. Автоматическое
дополнение каталога, хранение текстов в БД, аннотации и отображение в плеере пока
не подключены. Поэтому переменная ниже пока нужна именно процессу команды,
а не Docker Compose или веб-клиенту.
## Почему Go
Основной API Pipu написан на Go. Небольшой провайдер можно подключить к нему без
отдельного Python-сервиса. LyricsGenius удобен для Python, но собственный адаптер
позволяет явно контролировать выбор версии песни, сетевые ограничения и ошибки.
HTML Genius не является стабильным API: при изменении разметки парсер потребует обновления.
## Получение токена
1. Войти или зарегистрироваться на https://genius.com.
2. Открыть https://genius.com/api-clients и создать новый API Client.
3. Указать название, например `Pipu Music`, и URL своего приложения. Если форма
требует Redirect URI, указать URL под своим контролем; для чтения с Client Access
Token OAuth-переходы и обработчик callback в Pipu не нужны.
4. В созданном клиенте нажать **Generate Access Token**.
5. Сохранить **Client Access Token** в переменной `GENIUS_ACCESS_TOKEN` процесса.
Client ID и Client Secret вместо него не подходят.
Токен нужен для поиска через `api.genius.com`. Получение HTML по уже известному
URL не требует токена и не отправляет заголовок Authorization. Не помещать токен
в git, клиентские переменные `VITE_*`, логи или переписку.
Источник: https://lyricsgenius.readthedocs.io/en/master/setup.html.
## Проверка в PowerShell 7
Из корня репозитория:
```powershell
Set-Location services/api
$env:GENIUS_ACCESS_TOKEN = Read-Host 'Genius Client Access Token' -MaskInput
go run ./cmd/genius -search 'Исполнитель Название песни'
```
Выбрать нужного исполнителя и версию песни из результатов. Команда намеренно
не считает первый результат правильным. Затем подставить возвращённый `url`:
```powershell
go run ./cmd/genius -url 'https://genius.com/Artist-song-lyrics'
Remove-Item Env:GENIUS_ACCESS_TOKEN
```
URL во втором примере — шаблон. Результат — JSON с `text`, `source_url` и временем
получения `fetched_at`. Это обычный текст без временной синхронизации.
## Ограничения и дальнейшее подключение
- HTTP 401, 403, 404, 429 и другие ошибки возвращаются явно; `HTTPError` сохраняет
`Retry-After`. Автоматических повторов и обхода блокировок нет.
- Таймаут запроса — 15 секунд, предел ответа — 8 MiB. Разрешён только HTTPS
на Genius; перенаправления на другой хост запрещены.
- Отсутствие распознаваемого текста возвращает `ErrLyricsUnavailable`; HTML
блокировки или навигация не становятся текстом песни.
- Перед интеграцией в каталог нужны кэш результатов, лимит частоты запросов,
сохранение выбранного Genius ID и обработка временных ошибок без потери старого текста.
- Токен API не предоставляет лицензию на тексты. Документация LyricsGenius
предупреждает, что парсинг противоречит условиям Genius; отображление и хранение
в публичном сервисе требуют отдельной проверки разрешений:
https://lyricsgenius.readthedocs.io/en/latest/how_it_works.html.
## Тесты
```powershell
go test ./internal/genius ./cmd/genius
```
Тесты используют синтетические тексты и подмену HTTP: переносы, HTML-сущности,
служебные элементы, встроенное состояние, ошибки HTTP, проверка URL и
отсутствие токена при запросе страницы. Реальный доступ к Genius проверяется
отдельно из сети сервера с собственным токеном.
+1 -1
View File
@@ -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
View File
@@ -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).
+28
View File
@@ -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
View File
@@ -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`.
+1
View File
@@ -6,6 +6,7 @@
"node": ">=22" "node": ">=22"
}, },
"scripts": { "scripts": {
"icons": "node scripts/generate-icons.mjs",
"build": "turbo run build", "build": "turbo run build",
"lint": "turbo run lint", "lint": "turbo run lint",
"typecheck": "turbo run typecheck", "typecheck": "turbo run typecheck",
+417 -3
View File
@@ -4,6 +4,40 @@
*/ */
export interface paths { export interface paths {
"/api/v1/admin/genius": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getGeniusStats"];
put?: never;
post: operations["controlGenius"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/tracks/{id}/genius": {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
get: operations["getTrackGenius"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/healthz": { "/healthz": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1293,6 +1327,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, .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 +1403,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 +1730,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 +1739,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 +1748,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"];
}; };
@@ -1968,7 +2102,40 @@ export interface components {
/** @description Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости */ /** @description Аудиопризнаки (после анализа) — для анимации волны и выравнивания громкости */
features: components["schemas"]["TrackFeatures"] | null; features: components["schemas"]["TrackFeatures"] | null;
}; };
GeniusStats: {
configured: boolean;
paused: boolean;
running: boolean;
total: number;
pending: number;
matched: number;
lyrics: number;
annotations: number;
images: number;
unmatched: number;
errors: number;
last_error: string;
/** Format: date-time */
cooldown_until: string;
};
GeniusContent: {
status: string;
lyrics: string;
source_url: string;
song_image_url: string;
artist_image_url: string;
description: string;
annotations: components["schemas"]["GeniusAnnotation"][];
};
GeniusAnnotation: {
fragment: string;
text: string;
url: string;
verified: boolean;
authors: string;
};
Artist: { Artist: {
image_url?: string;
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
name: string; name: string;
@@ -2187,6 +2354,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;
@@ -2264,6 +2447,75 @@ export interface components {
} }
export type $defs = Record<string, never>; export type $defs = Record<string, never>;
export interface operations { export interface operations {
getGeniusStats: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Persistent enrichment statistics (admin only) */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GeniusStats"];
};
};
};
};
controlGenius: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @enum {string} */
action: "start" | "pause" | "retry";
};
};
};
responses: {
/** @description Updated statistics */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GeniusStats"];
};
};
};
};
getTrackGenius: {
parameters: {
query?: never;
header?: never;
path: {
id: components["parameters"]["EntityId"];
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Cached lyrics and annotations; never makes an upstream request */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GeniusContent"];
};
};
};
};
getHealthz: { getHealthz: {
parameters: { parameters: {
query?: never; query?: never;
@@ -4127,6 +4379,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 +4468,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"];
};
};
} }
+2 -2
View File
@@ -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));
+1
View File
@@ -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';
+9
View File
@@ -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');
});
});
+4
View File
@@ -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;
}
+31 -7
View File
@@ -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 .deb",
"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": "Install the package: sudo apt install ./pipu-music-….deb. Needs WebKitGTK 4.1 and GStreamer.",
"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"
} }
} }
+35 -1
View File
@@ -1,4 +1,12 @@
{ {
"genius": {
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
"loading": "Loading…", "disabled": "Not configured", "paused": "Paused", "cooldown": "Waiting after Genius rate limit", "running": "Processing", "waiting": "Waiting for new tracks",
"configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.",
"total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data",
"last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than",
"start": "Resume", "pause": "Pause", "retry": "Retry errors and unmatched", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
},
"app": { "app": {
"name": "pipu", "name": "pipu",
"tagline": "Your music from your own server" "tagline": "Your music from your own server"
@@ -41,7 +49,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 +62,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 +314,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"
} }
} }
+7
View File
@@ -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."
} }
} }
+31 -7
View File
@@ -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": "Скачать .deb",
"macos": "Скачать для macOS"
},
"version": "версия {{version}}", "version": "версия {{version}}",
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.", "other": "Другие платформы",
"step_install": {
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
"linux": "Установите пакет: sudo apt install ./pipu-music-….deb. Нужны WebKitGTK 4.1 и GStreamer.",
"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": "Войти в веб-версию"
} }
} }
+35 -1
View File
@@ -1,4 +1,12 @@
{ {
"genius": {
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Приостановлено", "cooldown": "Ожидание после ограничения Genius", "running": "Обработка", "waiting": "Ожидание новых треков",
"configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.",
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
"last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше",
"start": "Продолжить", "pause": "Приостановить", "retry": "Повторить ошибки и ненайденные", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius"
},
"app": { "app": {
"name": "pipu", "name": "pipu",
"tagline": "Ваша музыка с вашего сервера" "tagline": "Ваша музыка с вашего сервера"
@@ -41,7 +49,7 @@
"menu": "Меню профиля", "menu": "Меню профиля",
"settings": "Настройки", "settings": "Настройки",
"logout": "Выйти", "logout": "Выйти",
"android_app": "Приложение для Android" "download_apps": "Скачать приложения"
}, },
"roles": { "roles": {
"admin": "Администратор", "admin": "Администратор",
@@ -54,6 +62,10 @@
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку." "wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
}, },
"player": { "player": {
"share": "Поделиться треком",
"link_copied": "Ссылка на трек скопирована",
"share_error": "Не удалось поделиться ссылкой. Попробуйте ещё раз.",
"track_page": "Трек",
"empty": "Ничего не играет", "empty": "Ничего не играет",
"label": "Плеер", "label": "Плеер",
"play": "Слушать", "play": "Слушать",
@@ -302,5 +314,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": "Плейлисты не найдены"
} }
} }
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff. Show more