Compare commits

...
5 Commits
Author SHA1 Message Date
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
94 changed files with 4672 additions and 466 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux на ubuntu-latest, Windows NSIS на windows-latest).
name: ci / desktop
on:
push:
branches: [main]
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
pull_request:
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
name: rust (desktop proxy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
+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
+144 -5
View File
@@ -1,6 +1,6 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK. # Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы # Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs). # и переписывают статус: собран / не удалась (scripts/release.mjs).
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh). # Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
# #
# Секреты (Settings → Actions → Secrets): # Секреты (Settings → Actions → Secrets):
@@ -132,9 +132,10 @@ jobs:
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace # Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки. # metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново" echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
./gradlew --stop || true ./gradlew --stop || true
find ~/.gradle -name "*.lock" -delete || true
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-* rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
"${gradle[@]}" assembleRelease -x lintVitalRelease "${gradle[@]}" assembleRelease -x lintVitalRelease
else else
@@ -196,3 +197,141 @@ jobs:
- name: Статус в релизе — сборка отменена - name: Статус в релизе — сборка отменена
if: cancelled() if: cancelled()
run: node scripts/release.mjs android cancelled run: node scripts/release.mjs android cancelled
desktop-linux:
name: desktop (Linux в релиз)
needs: release
timeout-minutes: 60
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
APPIMAGE_EXTRACT_AND_RUN: "1"
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
steps:
- uses: actions/checkout@v4
- name: Статус в релизе — собирается
run: node scripts/release.mjs desktop linux building
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libdbus-1-dev libsecret-1-dev patchelf libfuse2
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-
- run: pnpm install --frozen-lockfile
- name: Сборка AppImage и deb
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:linux
bundle=src-tauri/target/release/bundle
shopt -s nullglob
for f in "$bundle"/appimage/*.AppImage; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.AppImage"
done
for f in "$bundle"/deb/*.deb; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-linux.deb"
done
ls -lh "$GITHUB_WORKSPACE"/pipu-music-${PIPU_VERSION}-linux.*
- name: Файлы в релиз
run: |
set -euo pipefail
files=()
for f in pipu-music-${PIPU_VERSION}-linux.AppImage pipu-music-${PIPU_VERSION}-linux.deb; do
[ -f "$f" ] && files+=("$f")
done
if [ ${#files[@]} -eq 0 ]; then
echo "::error::нет AppImage и deb после сборки"
exit 1
fi
node scripts/release.mjs desktop linux success "${files[@]}"
- name: Статус в релизе — сборка не удалась
if: failure()
run: node scripts/release.mjs desktop linux failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: node scripts/release.mjs desktop linux cancelled
# Нужен act_runner с меткой windows-latest (Windows + MSVC Build Tools + WebView2).
# Без такого раннера job останется в очереди; Tauri не кросс-компилирует NSIS с Linux.
desktop-windows:
name: desktop (Windows в релиз)
needs: release
timeout-minutes: 60
runs-on: windows-latest
defaults:
run:
shell: pwsh
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- name: Статус в релизе — собирается
run: node scripts/release.mjs desktop windows building
- uses: ilammy/msvc-dev-cmd@v1
- name: Rust
run: |
$init = Join-Path $env:TEMP 'rustup-init.exe'
Invoke-WebRequest -Uri 'https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe' -OutFile $init
& $init -y --default-toolchain stable --profile minimal --default-host x86_64-pc-windows-msvc --no-modify-path
Add-Content -Path $env:GITHUB_PATH -Value "$env:USERPROFILE\.cargo\bin"
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: cargo-desktop-windows-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-windows-
- run: pnpm install --frozen-lockfile
- name: Сборка NSIS
working-directory: apps/desktop
run: |
pnpm bundle:windows
$bundle = Join-Path (Get-Location) 'src-tauri/target/release/bundle/nsis'
$src = Get-ChildItem -Path $bundle -Filter '*-setup.exe' | Select-Object -First 1
if (-not $src) { throw "нет NSIS setup.exe после сборки в $bundle" }
$dest = Join-Path $env:GITHUB_WORKSPACE "pipu-music-$env:PIPU_VERSION-windows-setup.exe"
Copy-Item $src.FullName $dest
Get-Item $dest | Format-List Name, Length
- name: Файл в релиз
run: |
$file = "pipu-music-$env:PIPU_VERSION-windows-setup.exe"
if (-not (Test-Path $file)) { throw "нет $file после сборки" }
node scripts/release.mjs desktop windows success $file
- name: Статус в релизе — сборка не удалась
if: failure()
run: node scripts/release.mjs desktop windows failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: node scripts/release.mjs desktop windows cancelled
+4
View File
@@ -37,3 +37,7 @@ Thumbs.db
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.cache/ .cache/
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/
+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, скрипты установки и обновления |
+205 -4
View File
@@ -1497,11 +1497,57 @@ paths:
$ref: "#/components/schemas/WaveBatch" $ref: "#/components/schemas/WaveBatch"
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/app/clients:
get:
operationId: getAppClients
tags: [app]
security: [] # публично: страница /download без входа
summary: Клиенты последнего релиза (Android и десктоп)
description: |
Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
responses:
"200":
description: Доступные сборки
content:
application/json:
schema:
$ref: "#/components/schemas/AppClients"
default:
$ref: "#/components/responses/Problem"
/api/v1/app/download/{client}/{version}:
get:
operationId: downloadAppClient
tags: [app]
security: []
summary: Файл клиента (кэшируется на сервере, поддерживает Range)
parameters:
- name: client
in: path
required: true
schema:
$ref: "#/components/schemas/AppClient"
- name: version
in: path
required: true
schema:
type: string
pattern: '^\d+\.\d+\.\d+(-rc\.\d+)?$'
responses:
"200":
description: Установщик или APK
content:
application/octet-stream:
schema:
type: string
format: binary
default:
$ref: "#/components/responses/Problem"
/api/v1/app/android/latest: /api/v1/app/android/latest:
get: get:
operationId: getAndroidLatest operationId: getAndroidLatest
tags: [app] tags: [app]
security: [] # публично: страница /android раздаёт приложение без входа security: [] # публично: страница /download раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер) summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: | description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1538,6 +1584,82 @@ paths:
format: binary format: binary
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/me/playlists:
get:
tags: [collection]
operationId: listPlaylists
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistList'
post:
tags: [collection]
operationId: createPlaylist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistInput'
responses:
default:
$ref: '#/components/responses/Problem'
'201':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
/api/v1/me/playlists/{id}:
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
get:
tags: [collection]
operationId: getPlaylist
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
put:
tags: [collection]
operationId: updatePlaylist
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistUpdate'
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
delete:
tags: [collection]
operationId: deletePlaylist
responses:
default:
$ref: '#/components/responses/Problem'
'204':
description: Deleted
components: components:
parameters: parameters:
SourceId: SourceId:
@@ -1598,6 +1720,53 @@ components:
schema: schema:
$ref: "#/components/schemas/Problem" $ref: "#/components/schemas/Problem"
schemas: schemas:
PlaylistInput:
type: object
required: [name, description, track_ids]
properties:
name: { type: string, minLength: 1, maxLength: 120 }
description: { type: string, maxLength: 2000 }
track_ids:
type: array
maxItems: 2000
uniqueItems: true
items: { type: string, format: uuid }
PlaylistUpdate:
allOf:
- $ref: '#/components/schemas/PlaylistInput'
- type: object
required: [revision]
properties:
revision: { type: integer, minimum: 1 }
PlaylistSummary:
type: object
required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at]
properties:
id: { type: string, format: uuid }
name: { type: string }
description: { type: string }
revision: { type: integer }
track_count: { type: integer }
duration_ms: { type: integer, format: int64 }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
Playlist:
allOf:
- $ref: '#/components/schemas/PlaylistSummary'
- type: object
required: [tracks]
properties:
tracks:
type: array
items: { $ref: '#/components/schemas/Track' }
PlaylistList:
type: object
required: [items]
properties:
items:
type: array
items: { $ref: '#/components/schemas/PlaylistSummary' }
Onboarding: Onboarding:
type: object type: object
required: [required, artists, genres] required: [required, artists, genres]
@@ -2100,7 +2269,7 @@ components:
enum: [admin, moderator, user] enum: [admin, moderator, user]
ClientType: ClientType:
type: string type: string
enum: [web, android] enum: [web, android, desktop]
LoginRequest: LoginRequest:
type: object type: object
required: [username, password, client_type] required: [username, password, client_type]
@@ -2120,7 +2289,7 @@ components:
properties: properties:
refresh_token: refresh_token:
type: string type: string
description: Только Android; веб передаёт токен в cookie description: Android и desktop; веб передаёт токен в cookie
AuthTokens: AuthTokens:
type: object type: object
required: [access_token, token_type, expires_in, user] required: [access_token, token_type, expires_in, user]
@@ -2135,7 +2304,7 @@ components:
description: Срок жизни access-токена, секунды description: Срок жизни access-токена, секунды
refresh_token: refresh_token:
type: string type: string
description: "Только для `client_type: android`" description: "Для `client_type: android` и `client_type: desktop`"
user: user:
$ref: "#/components/schemas/Me" $ref: "#/components/schemas/Me"
UiPrefs: UiPrefs:
@@ -3323,6 +3492,38 @@ components:
like_rate: like_rate:
type: number type: number
nullable: true nullable: true
AppClient:
type: string
enum: [android, windows, linux, macos]
AppClientFile:
type: object
required: [client, filename, size, download_url]
properties:
client:
$ref: "#/components/schemas/AppClient"
filename:
type: string
size:
type: integer
format: int64
sha256:
type: string
download_url:
type: string
AppClients:
type: object
required: [version, version_code, notes_md, clients]
properties:
version:
type: string
version_code:
type: integer
notes_md:
type: string
clients:
type: array
items:
$ref: "#/components/schemas/AppClientFile"
AndroidRelease: AndroidRelease:
type: object type: object
required: [version, version_code, notes_md, size, sha256, download_url, min_supported] required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
+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}`);
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@pipu/desktop",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles appimage,deb",
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
"tauri": "tauri",
"icons": "tauri icon ../web/public/favicon.svg"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
}
}
+42
View File
@@ -0,0 +1,42 @@
[package]
name = "pipu-desktop"
version = "0.0.0"
edition = "2021"
[lib]
name = "pipu_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "pipu-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
uuid = { version = "1", features = ["v4"] }
url = "2"
souvlaki = "0.8.3"
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release]
strip = true
lto = "thin"
codegen-units = 1
+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: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+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": true }
},
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
}
}
@@ -1,4 +1,5 @@
import { unwrap, type Track } from '@pipu/core'; import { unwrap, type Track } from '@pipu/core';
import { useRouter } from 'expo-router';
import { useInfiniteQuery } from '@tanstack/react-query'; import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
const session = useSession(); const session = useSession();
const current = usePlayerState().track; const current = usePlayerState().track;
const [tab, setTab] = useState<Tab>('likes'); const [tab, setTab] = useState<Tab>('likes');
const router = useRouter();
const list = useInfiniteQuery({ const list = useInfiniteQuery({
queryKey: ['m-collection', tab], queryKey: ['m-collection', tab],
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const; const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
const tabs = ( const tabs = (
<View style={{ gap: 12 }}>
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
<Segmented<Tab> <Segmented<Tab>
value={tab} value={tab}
onChange={setTab} onChange={setTab}
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
{ value: 'dislikes', label: t('collection.dislikes') }, { value: 'dislikes', label: t('collection.dislikes') },
]} ]}
/> />
</View>
); );
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />; if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
+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>
+22 -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' }}>
+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
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';
@@ -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>
+34
View File
@@ -0,0 +1,34 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 1280]) {
test(`playlists at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('button', { name: 'Создать плейлист', exact: true }).click();
await page.getByLabel('Название', { exact: true }).fill('Музыка для долгой дороги');
await page.getByLabel('Описание', { exact: true }).fill('Любимые песни всегда рядом');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Музыка для долгой дороги' })).toBeVisible();
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('button', { name: 'Добавить в плейлист', exact: true }).first().click();
await page.getByRole('button', { name: /Музыка для долгой дороги · 0/ }).click();
await expect(page.getByRole('dialog')).toHaveCount(0);
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Плейлисты', exact: true }).click();
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('playlist.png') });
});
}
+76
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,55 @@ describe('safeRedirect', () => {
expect(safeRedirect(input)).toBe(expected); expect(safeRedirect(input)).toBe(expected);
}); });
}); });
describe('playlists', () => {
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
const alice = makeUser('alice', 'user');
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'В дорогу');
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'В дорогу' });
const id = [...api.playlists.keys()][0]!;
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
await screen.findByRole('heading', { name: 'Группа крови' });
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
await router.navigate({ to: '/playlist/$id', params: { id } });
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
await user.clear(screen.getByLabelText('Название'));
await user.type(screen.getByLabelText('Название'), 'Вечер');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Вечер' });
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
await screen.findByRole('heading', { name: 'Плейлисты' });
expect(api.playlists.size).toBe(0);
});
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
await user.type(screen.getByLabelText('Название'), 'Первый');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByRole('heading', { name: 'Первый' });
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
const item = [...api.playlists.values()][0]!;
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
await user.type(screen.getByLabelText('Название'), ' изменён');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
expect(screen.getByRole('dialog')).toBeInTheDocument();
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
});
});
+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; }
} }
+7 -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';
@@ -115,6 +116,7 @@ export function TrackList({
rowMeta, rowMeta,
lead, lead,
disliked, disliked,
actions,
}: { }: {
tracks: Track[]; tracks: Track[];
showAlbum?: boolean; showAlbum?: boolean;
@@ -126,6 +128,7 @@ export function TrackList({
lead?: (t: Track, i: number) => string; lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */ /** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean; disliked?: boolean;
actions?: (track: Track, index: number) => ReactNode;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
@@ -154,7 +157,7 @@ export function TrackList({
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)} className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')} title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => { onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play(); if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play(); if (e.key === 'Enter' && e.target === e.currentTarget) play();
@@ -181,7 +184,8 @@ export function TrackList({
{tr.album.title} {tr.album.title}
</Link> </Link>
)} )}
<span className={s.end}> <span className={s.end} data-playlist-actions={actions ? true : undefined}>
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
{disliked ? ( {disliked ? (
<button <button
type="button" type="button"
+97
View File
@@ -0,0 +1,97 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { queueIcon } from '../shared/ui/icons';
import s from './catalog.module.css';
export type Playlist = components['schemas']['Playlist'];
export type PlaylistInput = components['schemas']['PlaylistInput'];
export function usePlaylists(enabled = true) {
const session = useSession();
return useQuery({ queryKey: ['playlists'], enabled,
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
}
export function usePlaylist(id: string) {
const session = useSession();
return useQuery({ queryKey: ['playlists', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
}
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
}
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
}) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const [original] = useState(playlist);
const [name, setName] = useState(playlist?.name ?? '');
const [description, setDescription] = useState(playlist?.description ?? '');
const save = useMutation({ mutationFn: async () => {
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
return original
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
}, onSuccess: p => {
cache.setQueryData(['playlists', p.id], p);
void cache.invalidateQueries({ queryKey: ['playlists'] });
onSaved(p);
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
</form>
</Dialog>;
}
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
const errorText = useErrorText();
const list = usePlaylists();
const [create, setCreate] = useState(false);
const add = useMutation({ mutationFn: async (id: string) => {
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
const body = playlistInput(p);
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
<div style={{ display: 'grid', gap: 8 }}>
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
</div>
</Dialog>;
}
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return <>
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
</>;
}
+12 -3
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>
);
}
+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>
+38
View File
@@ -0,0 +1,38 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
import { formatDuration } from '../shared/lib/format';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
export function TrackPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const track = useQuery({
queryKey: ['track', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
});
if (track.isError) return <NotFound error={track.error} />;
const tr = track.data;
return (
<div className={s.page}>
<header className={c.hero}>
{tr && <Cover album={tr.album} size={200} />}
<div className={c.heroBody}>
<span className={c.kind}>{t('player.track_page')}</span>
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
{tr && <>
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
</>}
</div>
</header>
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
</div>
);
}
+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;
} }
+12
View File
@@ -0,0 +1,12 @@
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
.card strong { overflow-wrap: anywhere; }
.card span { font-size: 13px; }
.card:hover { background: #ffffff10; }
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.actions { display: flex; align-items: center; }
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
.actions button:disabled { opacity: .3; cursor: default; }
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
+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';
}
+1
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';
+51 -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.AppImage', size: 18_000_000, download_url: '/api/v1/app/download/linux/1.3.0' },
{ client: 'macos', filename: 'pipu-music_1.3.0_aarch64.dmg', size: 22_000_000, download_url: '/api/v1/app/download/macos/1.3.0' },
{ client: 'android', filename: 'pipu-music-1.3.0.apk', size: 12_000_000, sha256: 'ab'.repeat(32), download_url: '/api/v1/app/download/android/1.3.0' },
],
});
case 'GET /api/v1/app/android/latest':
return json({
version: '1.3.0',
version_code: 1_030_099,
notes_md: '',
size: 12_000_000,
sha256: 'ab'.repeat(32),
download_url: '/api/v1/app/android/download/1.3.0',
min_supported: 0,
});
case 'POST /api/v1/auth/login': { case 'POST /api/v1/auth/login': {
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password); const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
if (!u) return problem(401, 'auth.invalid_credentials'); if (!u) return problem(401, 'auth.invalid_credentials');
@@ -127,6 +150,23 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized'); if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
const old = playlists.get(id);
if (playlistPath && !old) return problem(404, 'playlist.not_found');
if (req.method === 'GET') return json(old);
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
const item = { id, name: body?.name as string, description: body?.description as string, tracks,
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
playlists.set(id, item);
return json(item, old ? 200 : 201);
}
switch (route) { switch (route) {
case 'POST /api/v1/auth/logout': case 'POST /api/v1/auth/logout':
case 'POST /api/v1/auth/logout-all': case 'POST /api/v1/auth/logout-all':
@@ -193,11 +233,14 @@ export function fakeApi(initial: FakeUser[]) {
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление. // «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
const q = url.searchParams.get('q') ?? ''; const q = url.searchParams.get('q') ?? '';
const hit = q === 'кино' || q === 'rbyj'; const hit = q === 'кино' || q === 'rbyj';
const tracks = path === '/api/v1/search' && hit
? albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null }))
: [];
return json({ return json({
artists: hit ? [kino] : [], artists: hit ? [kino] : [],
albums: hit ? [albumSummary] : [], albums: hit ? [albumSummary] : [],
tracks: [], tracks,
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 }, totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: tracks.length },
corrected_query: q === 'rbyj' ? 'кино' : null, corrected_query: q === 'rbyj' ? 'кино' : null,
next_cursor: null, next_cursor: null,
}); });
@@ -260,6 +303,11 @@ export function fakeApi(initial: FakeUser[]) {
return json(admin(target)); return json(admin(target));
} }
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path); const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
if (trackPath && req.method === 'GET') {
const track = albumTracks.find(t => t.id === trackPath[1]);
return track ? json({ ...track, reaction: reactions.get(track.id) ?? null }) : problem(404, 'common.not_found');
}
if (reactionPath) { if (reactionPath) {
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike'); if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
else reactions.delete(reactionPath[1]!); else reactions.delete(reactionPath[1]!);
@@ -268,5 +316,5 @@ export function fakeApi(initial: FakeUser[]) {
return problem(404, 'common.not_found'); return problem(404, 'common.not_found');
}; };
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) }; return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
} }
+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() },
]} ]}
/> />
+15 -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,7 +311,7 @@ a.source:hover {
.controls { gap: 2px; } .controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; } .play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; } .right { display: flex; gap: 0; }
.right > :not(.queueButton) { display: none; } .right > :not(.queueButton):not(.shareButton) { display: none; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px)); .drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); } max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
} }
@@ -318,3 +319,16 @@ a.source:hover {
@media (max-width: 359px) { @media (max-width: 359px) {
.now > :first-child { display: none; } .now > :first-child { display: none; }
} }
.shareStatus {
position: absolute;
bottom: calc(100% + 8px);
right: 8px;
max-width: calc(100% - 16px);
padding: 10px 16px;
border-radius: 12px;
background: var(--surface3);
color: var(--text);
box-shadow: var(--shadow);
font-size: 13px;
}
+29 -1
View File
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog'; import { ArtistLinks, Cover } from '../entities/catalog';
import { usePlayer, usePlayerState } from '../player/context'; import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
import { import {
albumIcon, albumIcon,
closeIcon, closeIcon,
@@ -22,6 +23,7 @@ import {
repeatIcon, repeatIcon,
repeatOneIcon, repeatOneIcon,
shuffleIcon, shuffleIcon,
shareIcon,
volumeIcon, volumeIcon,
volumeOffIcon, volumeOffIcon,
waveIcon, waveIcon,
@@ -35,6 +37,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const player = usePlayer(); const player = usePlayer();
const st = usePlayerState(); const st = usePlayerState();
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
useEffect(() => {
setShareStatus(null);
}, [st.track?.id]);
useEffect(() => {
if (!shareStatus) return;
const timer = setTimeout(() => setShareStatus(null), 4000);
return () => clearTimeout(timer);
}, [shareStatus]);
const queueButton = useRef<HTMLButtonElement>(null); const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId(); const queueId = useId();
const closeQueue = useCallback(() => { const closeQueue = useCallback(() => {
@@ -57,6 +69,18 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
} }
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined); const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
setSharing(true);
setShareStatus(null);
try {
const result = await shareTrack(track.id, track.title);
if (result === 'copied') setShareStatus('copied');
} catch {
setShareStatus('error');
} finally {
setSharing(false);
}
};
const repeatLabel = t(`player.repeat_${st.repeat}`); const repeatLabel = t(`player.repeat_${st.repeat}`);
const ctx = st.context; const ctx = st.context;
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string; const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
@@ -67,7 +91,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
? t('player.source_wave_seed', { label: ctx.label }) ? t('player.source_wave_seed', { label: ctx.label })
: t('player.source_wave') : t('player.source_wave')
: ctx.type === 'collection' : ctx.type === 'collection'
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`) ? ctx.label ?? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' }); : tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
return ( return (
@@ -154,6 +178,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
</div> </div>
<div className={s.right}> <div className={s.right}>
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button <button
type="button" type="button"
className={cx(s.icon, st.shuffle && s.accent)} className={cx(s.icon, st.shuffle && s.accent)}
@@ -204,6 +231,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
/> />
</div> </div>
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />} {queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer> </footer>
); );
} }
+4 -4
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 @@
### Левая панель навигации ### Левая панель навигации
- **Главная** - **Главная**
- **Коллекция** — с подкатегориями: - **Коллекция** — с подкатегориями:
- **Плейлисты** — личные подборки треков;
- **История** — прослушанные треки; - **История** — прослушанные треки;
- **Лайки** — понравившиеся треки; - **Лайки** — понравившиеся треки;
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк); - **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна. - **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
- **Админка** — видна только администраторам и модераторам. - **Админка** — видна только администраторам и модераторам.
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль), На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер. плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
### Плеер (всегда внизу) ### Плеер (всегда внизу)
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд, - Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь. прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь.
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки - **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
и даже на другом устройстве можно продолжить с того же места. и даже на другом устройстве можно продолжить с того же места.
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие). - Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
+2
View File
@@ -15,8 +15,10 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск | | [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище | | [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн | | [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» | | [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент | | [web.md](web.md) | Веб-клиент |
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление | | [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) | | [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android | | [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
+4 -2
View File
@@ -102,8 +102,10 @@
прочитайте заметки к релизу на странице релизов Gitea. прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK. Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза, Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля. сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
APK, Linux (AppImage, deb) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
и в меню профиля.
## 7. Наблюдение ## 7. Наблюдение
+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` | |
+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-установщик, на Linux — AppImage, deb и rpm. Зависимости Linux: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
Релиз по тегу прикладывает Linux AppImage/`.deb` (`pipu-music-<версия>-linux.*`) и Windows NSIS (`pipu-music-<версия>-windows-setup.exe`). Windows-job в `release.yml` ждёт act_runner с меткой `windows-latest` (MSVC + WebView2). Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
+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`.
+281 -3
View File
@@ -1293,6 +1293,44 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/app/clients": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Клиенты последнего релиза (Android и десктоп)
* @description Тот же релиз Gitea, что и для Android: APK, установщик Windows, AppImage/deb, DMG.
* Нет настроек Gitea или файлов клиентов — `404 app.update_unavailable`.
*/
get: operations["getAppClients"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/app/download/{client}/{version}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Файл клиента (кэшируется на сервере, поддерживает Range) */
get: operations["downloadAppClient"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/app/android/latest": { "/api/v1/app/android/latest": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1331,10 +1369,72 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/me/playlists": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["listPlaylists"];
put?: never;
post: operations["createPlaylist"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/playlists/{id}": {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
get: operations["getPlaylist"];
put: operations["updatePlaylist"];
post?: never;
delete: operations["deletePlaylist"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
PlaylistInput: {
name: string;
description: string;
track_ids: string[];
};
PlaylistUpdate: components["schemas"]["PlaylistInput"] & {
revision: number;
};
PlaylistSummary: {
/** Format: uuid */
id: string;
name: string;
description: string;
revision: number;
track_count: number;
/** Format: int64 */
duration_ms: number;
/** Format: date-time */
created_at: string;
/** Format: date-time */
updated_at: string;
};
Playlist: components["schemas"]["PlaylistSummary"] & {
tracks: components["schemas"]["Track"][];
};
PlaylistList: {
items: components["schemas"]["PlaylistSummary"][];
};
Onboarding: { Onboarding: {
required: boolean; required: boolean;
artists: { artists: {
@@ -1596,7 +1696,7 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
Role: "admin" | "moderator" | "user"; Role: "admin" | "moderator" | "user";
/** @enum {string} */ /** @enum {string} */
ClientType: "web" | "android"; ClientType: "web" | "android" | "desktop";
LoginRequest: { LoginRequest: {
username: string; username: string;
password: string; password: string;
@@ -1605,7 +1705,7 @@ export interface components {
device_name?: string; device_name?: string;
}; };
RefreshRequest: { RefreshRequest: {
/** @description Только Android; веб передаёт токен в cookie */ /** @description Android и desktop; веб передаёт токен в cookie */
refresh_token?: string; refresh_token?: string;
}; };
AuthTokens: { AuthTokens: {
@@ -1614,7 +1714,7 @@ export interface components {
token_type: "Bearer"; token_type: "Bearer";
/** @description Срок жизни access-токена, секунды */ /** @description Срок жизни access-токена, секунды */
expires_in: number; expires_in: number;
/** @description Только для `client_type: android` */ /** @description Для `client_type: android` и `client_type: desktop` */
refresh_token?: string; refresh_token?: string;
user: components["schemas"]["Me"]; user: components["schemas"]["Me"];
}; };
@@ -2187,6 +2287,22 @@ export interface components {
early_skip_rate: number | null; early_skip_rate: number | null;
like_rate: number | null; like_rate: number | null;
}; };
/** @enum {string} */
AppClient: "android" | "windows" | "linux" | "macos";
AppClientFile: {
client: components["schemas"]["AppClient"];
filename: string;
/** Format: int64 */
size: number;
sha256?: string;
download_url: string;
};
AppClients: {
version: string;
version_code: number;
notes_md: string;
clients: components["schemas"]["AppClientFile"][];
};
AndroidRelease: { AndroidRelease: {
/** @example 0.6.1 */ /** @example 0.6.1 */
version: string; version: string;
@@ -4127,6 +4243,51 @@ export interface operations {
default: components["responses"]["Problem"]; default: components["responses"]["Problem"];
}; };
}; };
getAppClients: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Доступные сборки */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AppClients"];
};
};
default: components["responses"]["Problem"];
};
};
downloadAppClient: {
parameters: {
query?: never;
header?: never;
path: {
client: components["schemas"]["AppClient"];
version: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Установщик или APK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
default: components["responses"]["Problem"];
};
};
getAndroidLatest: { getAndroidLatest: {
parameters: { parameters: {
query?: never; query?: never;
@@ -4171,4 +4332,121 @@ export interface operations {
default: components["responses"]["Problem"]; default: components["responses"]["Problem"];
}; };
}; };
listPlaylists: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlaylistList"];
};
};
default: components["responses"]["Problem"];
};
};
createPlaylist: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PlaylistInput"];
};
};
responses: {
/** @description Success */
201: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Playlist"];
};
};
default: components["responses"]["Problem"];
};
};
getPlaylist: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Playlist"];
};
};
default: components["responses"]["Problem"];
};
};
updatePlaylist: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["PlaylistUpdate"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Playlist"];
};
};
default: components["responses"]["Problem"];
};
};
deletePlaylist: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Deleted */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
} }
+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 AppImage",
"macos": "Download for macOS"
},
"version": "version {{version}}", "version": "version {{version}}",
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.", "other": "Other platforms",
"step_install": {
"android": "Open the downloaded file. If Android asks, allow installs from this source.",
"windows": "Run the installer and follow the steps. pipu will appear in the Start menu.",
"linux": "Make the file executable and open it. For a .deb, install the package.",
"macos": "Open the disk image and drag pipu into Applications."
},
"step_server": "Launch pipu and enter the server address:", "step_server": "Launch pipu and enter the server address:",
"copy_server": "Copy the server address", "copy_server": "Copy the server address",
"step_updates": "Sign in with your account. The app offers new versions by itself.", "step_updates": {
"android": "Sign in with your account. The app offers new versions by itself.",
"windows": "Sign in with your account. Get a new version from this page.",
"linux": "Sign in with your account. Get a new version from this page.",
"macos": "Sign in with your account. Get a new version from this page."
},
"checksum": "SHA-256 checksum", "checksum": "SHA-256 checksum",
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).", "unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
"unavailable_platform": "The latest release does not include a build for {{platform}} yet.",
"to_login": "Sign in to the web app" "to_login": "Sign in to the web app"
} }
} }
+27 -1
View File
@@ -41,7 +41,7 @@
"menu": "Profile menu", "menu": "Profile menu",
"settings": "Settings", "settings": "Settings",
"logout": "Sign out", "logout": "Sign out",
"android_app": "Android app" "download_apps": "Download apps"
}, },
"roles": { "roles": {
"admin": "Administrator", "admin": "Administrator",
@@ -54,6 +54,10 @@
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library." "wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
}, },
"player": { "player": {
"share": "Share track",
"link_copied": "Track link copied",
"share_error": "Could not share the link. Please try again.",
"track_page": "Track",
"empty": "Nothing is playing", "empty": "Nothing is playing",
"label": "Player", "label": "Player",
"play": "Play", "play": "Play",
@@ -302,5 +306,27 @@
"pick_more_few": "Pick {{count}} more artists", "pick_more_few": "Pick {{count}} more artists",
"pick_more_many": "Pick {{count}} more artists", "pick_more_many": "Pick {{count}} more artists",
"pick_more_other": "Pick {{count}} more artists" "pick_more_other": "Pick {{count}} more artists"
},
"playlists": {
"name": "Name",
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
"manage": "Manage tracks",
"title": "Playlists",
"move_down": "Move {{name}} down",
"edit": "Edit playlist",
"description": "Description",
"add": "Add to playlist",
"search": "Search playlists",
"create": "Create playlist",
"find_tracks": "Find music",
"remove_track": "Remove {{name}} from playlist",
"save": "Save",
"private": "Private playlist",
"empty": "Your playlists will appear here. Create your first and add your favourite music.",
"empty_tracks": "No tracks yet. Use Add to playlist in search, albums or your collection.",
"loading": "Loading playlists…",
"move_up": "Move {{name}} up",
"delete": "Delete playlist",
"no_results": "No playlists found"
} }
} }
+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": "Скачать AppImage",
"macos": "Скачать для macOS"
},
"version": "версия {{version}}", "version": "версия {{version}}",
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.", "other": "Другие платформы",
"step_install": {
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
"linux": "Сделайте файл исполняемым и откройте его. Для .deb — установите пакет.",
"macos": "Откройте образ и перетащите pipu в Программы."
},
"step_server": "Запустите pipu и введите адрес сервера:", "step_server": "Запустите pipu и введите адрес сервера:",
"copy_server": "Скопировать адрес сервера", "copy_server": "Скопировать адрес сервера",
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.", "step_updates": {
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
},
"checksum": "Контрольная сумма SHA-256", "checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).", "unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
"unavailable_platform": "В последнем релизе пока нет сборки для {{platform}}.",
"to_login": "Войти в веб-версию" "to_login": "Войти в веб-версию"
} }
} }
+27 -1
View File
@@ -41,7 +41,7 @@
"menu": "Меню профиля", "menu": "Меню профиля",
"settings": "Настройки", "settings": "Настройки",
"logout": "Выйти", "logout": "Выйти",
"android_app": "Приложение для Android" "download_apps": "Скачать приложения"
}, },
"roles": { "roles": {
"admin": "Администратор", "admin": "Администратор",
@@ -54,6 +54,10 @@
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку." "wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
}, },
"player": { "player": {
"share": "Поделиться треком",
"link_copied": "Ссылка на трек скопирована",
"share_error": "Не удалось поделиться ссылкой. Попробуйте ещё раз.",
"track_page": "Трек",
"empty": "Ничего не играет", "empty": "Ничего не играет",
"label": "Плеер", "label": "Плеер",
"play": "Слушать", "play": "Слушать",
@@ -302,5 +306,27 @@
"pick_more_few": "Выберите ещё {{count}} исполнителя", "pick_more_few": "Выберите ещё {{count}} исполнителя",
"pick_more_many": "Выберите ещё {{count}} исполнителей", "pick_more_many": "Выберите ещё {{count}} исполнителей",
"pick_more_other": "Выберите ещё {{count}} исполнителя" "pick_more_other": "Выберите ещё {{count}} исполнителя"
},
"playlists": {
"name": "Название",
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
"manage": "Управлять треками",
"title": "Плейлисты",
"move_down": "Переместить «{{name}}» ниже",
"edit": "Изменить плейлист",
"description": "Описание",
"add": "Добавить в плейлист",
"search": "Поиск плейлистов",
"create": "Создать плейлист",
"find_tracks": "Найти музыку",
"remove_track": "Убрать «{{name}}» из плейлиста",
"save": "Сохранить",
"private": "Личный плейлист",
"empty": "Здесь будут ваши плейлисты. Создайте первый и добавьте любимую музыку.",
"empty_tracks": "В плейлисте пока нет треков. Добавляйте их кнопкой «Добавить в плейлист» в поиске, альбомах или коллекции.",
"loading": "Загрузка плейлистов…",
"move_up": "Переместить «{{name}}» выше",
"delete": "Удалить плейлист",
"no_results": "Плейлисты не найдены"
} }
} }
+7
View File
@@ -93,5 +93,12 @@
"size_mismatch": "Размер файла не совпал с заявленным", "size_mismatch": "Размер файла не совпал с заявленным",
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)", "hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
"import_failed": "Не удалось записать файл в хранилище" "import_failed": "Не удалось записать файл в хранилище"
},
"playlist": {
"not_found": "Плейлист не найден",
"invalid_name": "Введите название от 1 до 120 символов",
"invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке",
"too_large": "Максимум 2000 треков и 2000 символов описания",
"conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными."
} }
} }
+133 -4
View File
@@ -18,6 +18,12 @@ importers:
specifier: 5.8.3 specifier: 5.8.3
version: 5.8.3 version: 5.8.3
apps/desktop:
devDependencies:
'@tauri-apps/cli':
specifier: ^2.9.0
version: 2.12.3
apps/mobile: apps/mobile:
dependencies: dependencies:
'@expo/vector-icons': '@expo/vector-icons':
@@ -116,7 +122,7 @@ importers:
version: 19.0.14 version: 19.0.14
vitest: vitest:
specifier: ^3.2.7 specifier: ^3.2.7
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2) version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
apps/web: apps/web:
dependencies: dependencies:
@@ -1458,6 +1464,82 @@ packages:
'@tanstack/virtual-core@3.19.6': '@tanstack/virtual-core@3.19.6':
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==} resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
'@tauri-apps/cli-darwin-arm64@2.12.3':
resolution: {integrity: sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.12.3':
resolution: {integrity: sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
resolution: {integrity: sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
resolution: {integrity: sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
resolution: {integrity: sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
resolution: {integrity: sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
resolution: {integrity: sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.12.3':
resolution: {integrity: sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
resolution: {integrity: sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
resolution: {integrity: sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
resolution: {integrity: sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.12.3':
resolution: {integrity: sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg==}
engines: {node: '>= 10'}
hasBin: true
'@testing-library/dom@10.4.2': '@testing-library/dom@10.4.2':
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==} resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -5560,6 +5642,53 @@ snapshots:
'@tanstack/virtual-core@3.19.6': {} '@tanstack/virtual-core@3.19.6': {}
'@tauri-apps/cli-darwin-arm64@2.12.3':
optional: true
'@tauri-apps/cli-darwin-x64@2.12.3':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.12.3':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
optional: true
'@tauri-apps/cli@2.12.3':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.12.3
'@tauri-apps/cli-darwin-x64': 2.12.3
'@tauri-apps/cli-linux-arm-gnueabihf': 2.12.3
'@tauri-apps/cli-linux-arm64-gnu': 2.12.3
'@tauri-apps/cli-linux-arm64-musl': 2.12.3
'@tauri-apps/cli-linux-riscv64-gnu': 2.12.3
'@tauri-apps/cli-linux-x64-gnu': 2.12.3
'@tauri-apps/cli-linux-x64-musl': 2.12.3
'@tauri-apps/cli-win32-arm64-msvc': 2.12.3
'@tauri-apps/cli-win32-ia32-msvc': 2.12.3
'@tauri-apps/cli-win32-x64-msvc': 2.12.3
'@testing-library/dom@10.4.2': '@testing-library/dom@10.4.2':
dependencies: dependencies:
'@babel/code-frame': 7.29.7 '@babel/code-frame': 7.29.7
@@ -8118,7 +8247,7 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2): vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
dependencies: dependencies:
cac: 6.7.14 cac: 6.7.14
debug: 4.4.3(supports-color@10.2.2) debug: 4.4.3(supports-color@10.2.2)
@@ -8188,7 +8317,7 @@ snapshots:
lightningcss: 1.27.0 lightningcss: 1.27.0
terser: 5.51.2 terser: 5.51.2
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2): vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
dependencies: dependencies:
'@types/chai': 5.2.3 '@types/chai': 5.2.3
'@vitest/expect': 3.2.7 '@vitest/expect': 3.2.7
@@ -8211,7 +8340,7 @@ snapshots:
tinypool: 1.1.1 tinypool: 1.1.1
tinyrainbow: 2.0.0 tinyrainbow: 2.0.0
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2) vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies: optionalDependencies:
'@types/node': 22.20.5 '@types/node': 22.20.5
+100 -15
View File
@@ -11,7 +11,7 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { basename, resolve } from 'node:path'; import { basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
@@ -29,6 +29,12 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
export const STATUS_START = '<!-- android-status -->'; export const STATUS_START = '<!-- android-status -->';
export const STATUS_END = '<!-- /android-status -->'; export const STATUS_END = '<!-- /android-status -->';
export const DESKTOP_PLATFORMS = ['linux', 'windows'];
export function desktopStatusMarks(platform) {
if (!DESKTOP_PLATFORMS.includes(platform)) throw new Error(`unknown desktop platform: ${platform}`);
return { start: `<!-- desktop-${platform}-status -->`, end: `<!-- /desktop-${platform}-status -->` };
}
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */ /** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
export function groupCommits(commits) { export function groupCommits(commits) {
@@ -76,16 +82,62 @@ export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n'); return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
} }
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */ /** Блок статуса сборки десктопа в тексте релиза (отдельный для linux и windows). */
export function replaceAndroidStatus(body, block) { export function renderDesktopStatus({ platform, status, runUrl, files }) {
const start = body.indexOf(STATUS_START); const label = platform === 'windows' ? 'Windows' : 'Linux';
const end = body.indexOf(STATUS_END); const pending = platform === 'windows' ? 'NSIS-установщик' : 'AppImage и .deb';
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`; const lines =
return body.slice(0, start) + block + body.slice(end + STATUS_END.length); status === 'success'
? [
`✅ **Десктоп (${label}):** собрано — ${files.map((f) => `\`${f.name}\` (${formatSize(f.size)})`).join(', ')}.`,
]
: status === 'cancelled'
? [
`⏹ **Десктоп (${label}):** сборка отменена — [лог сборки](${runUrl}).`,
'Файлы появятся здесь после перезапуска сборки.',
]
: status === 'failure'
? [
`❌ **Десктоп (${label}):** сборка не удалась — [лог сборки](${runUrl}).`,
'Остальные клиенты от этого не зависят; установщик появится после перезапуска сборки.',
]
: [
`⏳ **Десктоп (${label}):** собирается — [ход сборки](${runUrl}).`,
`${pending} появится в этом релизе автоматически.`,
];
const { start, end } = desktopStatusMarks(platform);
return [start, ...lines.map((l) => `> ${l}`), end].join('\n');
} }
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */ function replaceBlock(body, startMark, endMark, block) {
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) { const start = body.indexOf(startMark);
const end = body.indexOf(endMark);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + endMark.length);
}
/** Заменяет блок статуса Android в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
return replaceBlock(body, STATUS_START, STATUS_END, block);
}
export function replaceDesktopStatus(body, platform, block) {
const { start, end } = desktopStatusMarks(platform);
return replaceBlock(body, start, end, block);
}
/** Текст релиза: статусы сборок, изменения, обновление сервера, установка клиентов. */
export function renderNotes({
version,
tag,
prevTag,
prerelease,
repoUrl,
groups,
androidStatus,
desktopLinuxStatus,
desktopWindowsStatus,
}) {
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`; const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`; const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
const out = []; const out = [];
@@ -93,7 +145,10 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
if (prerelease) { if (prerelease) {
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', ''); out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
} }
out.push(androidStatus, '', '## Что изменилось', ''); out.push(androidStatus, '');
if (desktopLinuxStatus) out.push(desktopLinuxStatus, '');
if (desktopWindowsStatus) out.push(desktopWindowsStatus, '');
out.push('## Что изменилось', '');
if (groups.breaking.length) { if (groups.breaking.length) {
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), ''); out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
} }
@@ -124,11 +179,14 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
'', '',
`Ожидается \`"version":"${version}"\`.`, `Ожидается \`"version":"${version}"\`.`,
'', '',
'## Android', '## Клиенты',
'', '',
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`, 'Скачайте с этой страницы или с `/download` на сервере (подскажет сборку под вашу систему).',
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.', '',
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`, `- **Android:** \`pipu-music-${version}.apk\` — откройте на телефоне; Android попросит разрешить установку из этого источника.`,
`- **Windows:** \`pipu-music-${version}-windows-setup.exe\`.`,
`- **Linux:** \`pipu-music-${version}-linux.AppImage\` (сделайте исполняемым) или \`.deb\`.`,
`- **Проверка APK:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
'', '',
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`, `Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
'', '',
@@ -202,6 +260,8 @@ async function create() {
repoUrl, repoUrl,
groups: groupCommits(commitsBetween(prevTag, tag)), groups: groupCommits(commitsBetween(prevTag, tag)),
androidStatus: renderAndroidStatus({ status: 'building', runUrl }), androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl }),
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl }),
}); });
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false }; const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
const saved = release const saved = release
@@ -242,13 +302,38 @@ async function android(status, apkPath) {
console.log(`статус Android: ${status}`); console.log(`статус Android: ${status}`);
} }
async function desktop(platform, status, paths) {
const { release, runUrl } = await context();
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
const files = [];
if (status === 'success') {
if (!paths.length) throw new Error('desktop success: укажите файлы');
for (const file of paths) {
const data = readFileSync(file);
files.push({ name: basename(file), size: data.length });
const sum = `${file}.sha256`;
if (!existsSync(sum)) writeFileSync(sum, `${createHash('sha256').update(data).digest('hex')}\n`);
await upload(release, file);
if (existsSync(sum)) await upload(release, sum);
}
}
const block = renderDesktopStatus({ platform, status, runUrl, files });
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceDesktopStatus(release.body ?? '', platform, block) } });
console.log(`статус десктопа (${platform}): ${status}`);
}
async function main() { async function main() {
const [cmd, ...args] = process.argv.slice(2); const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'create') return create(); if (cmd === 'create') return create();
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) { if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
return android(args[0], args[1]); return android(args[0], args[1]);
} }
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>'); if (cmd === 'desktop' && DESKTOP_PLATFORMS.includes(args[0]) && ['building', 'success', 'failure', 'cancelled'].includes(args[1])) {
return desktop(args[0], args[1], args.slice(2));
}
throw new Error(
'использование: release.mjs create | android building|failure|cancelled | android success <file.apk> | desktop linux|windows building|failure|cancelled | desktop linux|windows success <file...>',
);
} }
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) { if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
+35
View File
@@ -4,8 +4,11 @@ import { test } from 'node:test';
import { import {
groupCommits, groupCommits,
renderAndroidStatus, renderAndroidStatus,
renderDesktopStatus,
renderNotes, renderNotes,
replaceAndroidStatus, replaceAndroidStatus,
replaceDesktopStatus,
desktopStatusMarks,
STATUS_END, STATUS_END,
STATUS_START, STATUS_START,
} from './release.mjs'; } from './release.mjs';
@@ -77,6 +80,34 @@ test('adds a status block when the body has none', () => {
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block)); assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
}); });
test('desktop status blocks', () => {
const linuxMarks = desktopStatusMarks('linux');
const winMarks = desktopStatusMarks('windows');
const building = renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' });
assert.ok(building.startsWith(linuxMarks.start) && building.endsWith(linuxMarks.end));
assert.match(building, /Десктоп \(Linux\):.*собирается — \[ход сборки\]\(https:\/\/run\)/);
const ok = renderDesktopStatus({
platform: 'linux',
status: 'success',
files: [{ name: 'pipu-music-1.4.0-linux.AppImage', size: 40 * 1024 * 1024 }],
});
assert.match(ok, /`pipu-music-1\.4\.0-linux\.AppImage` \(40,0 МБ\)/);
assert.equal(replaceDesktopStatus(`a\n\n${building}\n\nb`, 'linux', ok).includes(building), false);
const winBuilding = renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' });
assert.ok(winBuilding.startsWith(winMarks.start) && winBuilding.endsWith(winMarks.end));
const winOk = renderDesktopStatus({
platform: 'windows',
status: 'success',
files: [{ name: 'pipu-music-1.4.0-windows-setup.exe', size: 12 * 1024 * 1024 }],
});
const both = `${building}\n\n${winBuilding}`;
const patched = replaceDesktopStatus(both, 'windows', winOk);
assert.match(patched, /Linux/);
assert.match(patched, /windows-setup\.exe/);
assert.doesNotMatch(patched, /Десктоп \(Windows\):.*собирается/);
});
test('release notes', () => { test('release notes', () => {
const notes = renderNotes({ const notes = renderNotes({
version: '0.2.0', version: '0.2.0',
@@ -86,12 +117,16 @@ test('release notes', () => {
repoUrl, repoUrl,
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]), groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }), androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
desktopLinuxStatus: renderDesktopStatus({ platform: 'linux', status: 'building', runUrl: 'https://run' }),
desktopWindowsStatus: renderDesktopStatus({ platform: 'windows', status: 'building', runUrl: 'https://run' }),
}); });
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/); assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/); assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
assert.match(notes, /\.\/update\.sh v0\.2\.0/); assert.match(notes, /\.\/update\.sh v0\.2\.0/);
assert.match(notes, /"version":"0\.2\.0"/); assert.match(notes, /"version":"0\.2\.0"/);
assert.match(notes, /pipu-music-0\.2\.0\.apk/); assert.match(notes, /pipu-music-0\.2\.0\.apk/);
assert.match(notes, /pipu-music-0\.2\.0-windows-setup\.exe/);
assert.match(notes, /pipu-music-0\.2\.0-linux\.AppImage/);
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/); assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
assert.doesNotMatch(notes, /Предварительная версия/); assert.doesNotMatch(notes, /Предварительная версия/);
}); });
File diff suppressed because it is too large. Load diff
+212 -52
View File
@@ -1,5 +1,5 @@
// Package appupdate — самообновление Android-приложения через сервер (docs/mobile.md#самообновление): // Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK. // сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
package appupdate package appupdate
import ( import (
@@ -25,7 +25,7 @@ import (
"pipu/api/internal/platform/httpx" "pipu/api/internal/platform/httpx"
) )
// CodeUnavailable — обновления не настроены или релиза с APK нет. // CodeUnavailable — обновления не настроены или релиза с клиентами нет.
const CodeUnavailable = "app.update_unavailable" const CodeUnavailable = "app.update_unavailable"
const cacheTTL = 10 * time.Minute const cacheTTL = 10 * time.Minute
@@ -73,14 +73,22 @@ type release struct {
Assets []asset `json:"assets"` Assets []asset `json:"assets"`
} }
// Latest — сведения о последней версии. type clientFile struct {
Kind, Name, URL, ChecksumURL, SHA256 string
Size int64
}
// Latest — сведения о последней версии и файлах клиентов.
type Latest struct { type Latest struct {
Version string Version string
VersionCode int VersionCode int
Notes string Notes string
Size int64 Files map[string]clientFile
SHA256 string }
apkURL string
func (l *Latest) android() (clientFile, bool) {
f, ok := l.Files["android"]
return f, ok && f.SHA256 != ""
} }
// Handler реализует app-часть gen.StrictServerInterface. // Handler реализует app-часть gen.StrictServerInterface.
@@ -92,7 +100,7 @@ type Handler struct {
mu sync.Mutex mu sync.Mutex
cached *Latest cached *Latest
cachedAt time.Time cachedAt time.Time
dl sync.Mutex // одна загрузка APK за раз dl sync.Mutex
} }
// NewHandler создаёт обработчик. // NewHandler создаёт обработчик.
@@ -100,7 +108,6 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log} return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
} }
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
func (h *Handler) configured() bool { func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" && return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/") !strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
@@ -125,7 +132,70 @@ func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
return res, nil return res, nil
} }
// latest — последний релиз с APK (кэш на 10 минут). func classifyAsset(name string) string {
n := strings.ToLower(name)
if strings.HasSuffix(n, ".sha256") {
return ""
}
switch {
case strings.HasSuffix(n, ".apk"):
return "android"
case strings.HasSuffix(n, ".exe"), strings.HasSuffix(n, ".msi"):
return "windows"
case strings.HasSuffix(n, ".appimage"), strings.HasSuffix(n, ".deb"), strings.HasSuffix(n, ".rpm"):
return "linux"
case strings.HasSuffix(n, ".dmg"), strings.Contains(n, ".app.tar"):
return "macos"
default:
return ""
}
}
func assetRank(kind, name string) int {
n := strings.ToLower(name)
switch kind {
case "windows":
if strings.Contains(n, "setup") {
return 2
}
return 1
case "linux":
switch {
case strings.HasSuffix(n, ".appimage"):
return 3
case strings.HasSuffix(n, ".deb"):
return 2
default:
return 1
}
default:
return 1
}
}
func pickClientFiles(assets []asset) map[string]clientFile {
sums := map[string]string{}
for _, a := range assets {
if strings.HasSuffix(strings.ToLower(a.Name), ".sha256") {
sums[strings.TrimSuffix(a.Name, ".sha256")] = a.URL
sums[strings.TrimSuffix(a.Name, ".SHA256")] = a.URL
}
}
out := map[string]clientFile{}
for _, a := range assets {
kind := classifyAsset(a.Name)
if kind == "" {
continue
}
cur, ok := out[kind]
if ok && assetRank(kind, a.Name) <= assetRank(kind, cur.Name) {
continue
}
out[kind] = clientFile{Kind: kind, Name: a.Name, URL: a.URL, Size: a.Size, ChecksumURL: sums[a.Name]}
}
return out
}
func (h *Handler) latest(ctx context.Context) (*Latest, error) { func (h *Handler) latest(ctx context.Context) (*Latest, error) {
if !h.configured() { if !h.configured() {
return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured") return nil, apperr.NotFound(CodeUnavailable, "gitea is not configured")
@@ -142,7 +212,7 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
if err != nil { if err != nil {
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err) h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
if h.cached != nil { if h.cached != nil {
return h.cached, nil // устаревший ответ лучше, чем никакого return h.cached, nil
} }
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable") return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
} }
@@ -156,35 +226,77 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
if err != nil { if err != nil {
return nil, apperr.NotFound(CodeUnavailable, err.Error()) return nil, apperr.NotFound(CodeUnavailable, err.Error())
} }
name := "pipu-music-" + version + ".apk" files := pickClientFiles(rel.Assets)
var apk, sum *asset if len(files) == 0 {
for i := range rel.Assets { return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
switch rel.Assets[i].Name { }
case name: if apk, ok := files["android"]; ok {
apk = &rel.Assets[i] if apk.ChecksumURL == "" {
case name + ".sha256": delete(files, "android")
sum = &rel.Assets[i] } else if digest, err := h.readDigest(ctx, apk.ChecksumURL); err != nil {
h.log.WarnContext(ctx, "app update: apk checksum unavailable", "err", err)
delete(files, "android")
} else {
apk.SHA256 = digest
files["android"] = apk
}
if len(files) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
} }
} }
if apk == nil || sum == nil { h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet") h.cachedAt = time.Now()
} return h.cached, nil
sres, err := h.get(ctx, sum.URL) }
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
sres, err := h.get(ctx, url)
if err != nil { if err != nil {
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable") return "", err
} }
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256)) raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
_ = sres.Body.Close() _ = sres.Body.Close()
if err != nil { if err != nil {
return nil, err return "", err
} }
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0]) digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
if len(digest) != 64 { if len(digest) != 64 {
return nil, apperr.NotFound(CodeUnavailable, "malformed checksum") return "", fmt.Errorf("malformed checksum")
} }
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Size: apk.Size, SHA256: digest, apkURL: apk.URL} return digest, nil
h.cachedAt = time.Now() }
return h.cached, nil
func (h *Handler) fileURL(kind, version string) string {
return "/api/v1/app/download/" + kind + "/" + version
}
// GetAppClients реализует gen.StrictServerInterface.
func (h *Handler) GetAppClients(ctx context.Context, _ gen.GetAppClientsRequestObject) (gen.GetAppClientsResponseObject, error) {
l, err := h.latest(ctx)
if err != nil {
return nil, err
}
order := []string{"windows", "macos", "linux", "android"}
clients := make([]gen.AppClientFile, 0, len(l.Files))
for _, kind := range order {
f, ok := l.Files[kind]
if !ok {
continue
}
item := gen.AppClientFile{
Client: gen.AppClient(kind),
Filename: f.Name,
Size: f.Size,
DownloadUrl: h.fileURL(kind, l.Version),
}
if f.SHA256 != "" {
item.Sha256 = &f.SHA256
}
clients = append(clients, item)
}
return gen.GetAppClients200JSONResponse{
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Clients: clients,
}, nil
} }
// GetAndroidLatest реализует gen.StrictServerInterface. // GetAndroidLatest реализует gen.StrictServerInterface.
@@ -193,15 +305,18 @@ func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRe
if err != nil { if err != nil {
return nil, err return nil, err
} }
f, ok := l.android()
if !ok {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
return gen.GetAndroidLatest200JSONResponse{ return gen.GetAndroidLatest200JSONResponse{
Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: l.Size, Sha256: l.SHA256, Version: l.Version, VersionCode: l.VersionCode, NotesMd: l.Notes, Size: f.Size, Sha256: f.SHA256,
DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported, DownloadUrl: "/api/v1/app/android/download/" + l.Version, MinSupported: h.cfg.MinSupported,
}, nil }, nil
} }
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза. func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) { dst := filepath.Join(h.cfg.Dir, f.Name)
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
if _, err := os.Stat(dst); err == nil { if _, err := os.Stat(dst); err == nil {
return dst, nil return dst, nil
} }
@@ -213,12 +328,12 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil { if err := os.MkdirAll(h.cfg.Dir, 0o755); err != nil {
return "", err return "", err
} }
res, err := h.get(ctx, l.apkURL) res, err := h.get(ctx, f.URL)
if err != nil { if err != nil {
return "", err return "", err
} }
defer func() { _ = res.Body.Close() }() defer func() { _ = res.Body.Close() }()
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*") tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*")
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -231,26 +346,53 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
if err := tmp.Close(); err != nil { if err := tmp.Close(); err != nil {
return "", err return "", err
} }
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 { if f.SHA256 != "" {
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256) if got := hex.EncodeToString(hash.Sum(nil)); got != f.SHA256 {
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, f.SHA256)
}
} }
if err := os.Rename(tmp.Name(), dst); err != nil { if err := os.Rename(tmp.Name(), dst); err != nil {
return "", err return "", err
} }
h.cleanup(dst) h.cleanup(f.Name)
return dst, nil return dst, nil
} }
// cleanup оставляет в кэше только текущий APK. func (h *Handler) cleanup(keepName string) {
func (h *Handler) cleanup(keep string) { matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk")) ver := versionFromName(keepName)
for _, m := range matches { for _, m := range matches {
if m != keep { base := filepath.Base(m)
if ver != "" && strings.Contains(base, ver) {
continue
}
if base != keepName {
_ = os.Remove(m) _ = os.Remove(m)
} }
} }
} }
func versionFromName(name string) string {
m := regexp.MustCompile(`(\d+\.\d+\.\d+(?:-rc\.\d+)?)`).FindStringSubmatch(name)
if m == nil {
return ""
}
return m[1]
}
func (h *Handler) serveFile(ctx context.Context, l *Latest, kind string) (fileResponse, error) {
f, ok := l.Files[kind]
if !ok || (kind == "android" && f.SHA256 == "") {
return fileResponse{}, apperr.NotFound(CodeUnavailable, "client build is unavailable")
}
path, err := h.ensure(ctx, f)
if err != nil {
h.log.ErrorContext(ctx, "app update: download failed", "err", err, "client", kind)
return fileResponse{}, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the file")
}
return fileResponse{path: path, name: f.Name, req: httpx.RequestFrom(ctx)}, nil
}
// DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия. // DownloadAndroidApk реализует gen.StrictServerInterface. Отдаётся только последняя версия.
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) { func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
l, err := h.latest(ctx) l, err := h.latest(ctx)
@@ -260,22 +402,36 @@ func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroi
if req.Version != l.Version { if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available") return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
} }
path, err := h.ensure(ctx, l) return h.serveFile(ctx, l, "android")
if err != nil {
h.log.ErrorContext(ctx, "app update: apk download failed", "err", err)
return nil, apperr.New(http.StatusBadGateway, CodeUnavailable, "could not fetch the APK")
}
return apkResponse{path: path, name: filepath.Base(path), req: httpx.RequestFrom(ctx)}, nil
} }
type apkResponse struct { // DownloadAppClient реализует gen.StrictServerInterface.
func (h *Handler) DownloadAppClient(ctx context.Context, req gen.DownloadAppClientRequestObject) (gen.DownloadAppClientResponseObject, error) {
l, err := h.latest(ctx)
if err != nil {
return nil, err
}
if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
}
return h.serveFile(ctx, l, string(req.Client))
}
type fileResponse struct {
path string path string
name string name string
req *http.Request req *http.Request
} }
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки). func (a fileResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error { return a.write(w)
}
func (a fileResponse) VisitDownloadAppClientResponse(w http.ResponseWriter) error {
return a.write(w)
}
func (a fileResponse) write(w http.ResponseWriter) error {
f, err := os.Open(a.path) f, err := os.Open(a.path)
if err != nil { if err != nil {
return err return err
@@ -288,7 +444,11 @@ func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) erro
if a.req == nil { if a.req == nil {
return errors.New("appupdate: request missing in context") return errors.New("appupdate: request missing in context")
} }
w.Header().Set("Content-Type", "application/vnd.android.package-archive") ctype := "application/octet-stream"
if strings.HasSuffix(strings.ToLower(a.name), ".apk") {
ctype = "application/vnd.android.package-archive"
}
w.Header().Set("Content-Type", ctype)
w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`) w.Header().Set("Content-Disposition", `attachment; filename="`+a.name+`"`)
http.ServeContent(w, a.req, a.name, info.ModTime(), f) http.ServeContent(w, a.req, a.name, info.ModTime(), f)
return nil return nil
@@ -16,6 +16,22 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestPickClientFiles(t *testing.T) {
files := pickClientFiles([]asset{
{Name: "pipu-music-1.3.0.apk", Size: 10},
{Name: "pipu-music-1.3.0.apk.sha256", Size: 65, URL: "https://gitea/sum"},
{Name: "pipu-music_1.3.0_x64-setup.exe", Size: 20},
{Name: "pipu-music_1.3.0_amd64.deb", Size: 11},
{Name: "pipu-music_1.3.0_amd64.AppImage", Size: 30},
{Name: "pipu-music_1.3.0_aarch64.dmg", Size: 40},
})
assert.Equal(t, "pipu-music-1.3.0.apk", files["android"].Name)
assert.Equal(t, "https://gitea/sum", files["android"].ChecksumURL)
assert.Equal(t, "pipu-music_1.3.0_x64-setup.exe", files["windows"].Name)
assert.Equal(t, "pipu-music_1.3.0_amd64.AppImage", files["linux"].Name)
assert.Equal(t, "pipu-music_1.3.0_aarch64.dmg", files["macos"].Name)
}
func TestVersionCode(t *testing.T) { func TestVersionCode(t *testing.T) {
cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099} cases := map[string]int{"v0.6.1": 60_199, "1.2.0-rc.3": 1_020_003, "v10.0.0": 10_000_099}
for tag, want := range cases { for tag, want := range cases {
@@ -60,17 +76,18 @@ func TestLatestAndDownload(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "0.6.1", l.Version) assert.Equal(t, "0.6.1", l.Version)
assert.Equal(t, 60_199, l.VersionCode) assert.Equal(t, 60_199, l.VersionCode)
assert.Equal(t, hex.EncodeToString(sum[:]), l.SHA256) android := l.Files["android"]
assert.Equal(t, hex.EncodeToString(sum[:]), android.SHA256)
path, err := h.ensure(context.Background(), l) path, err := h.ensure(context.Background(), android)
require.NoError(t, err) require.NoError(t, err)
got, _ := os.ReadFile(path) got, _ := os.ReadFile(path)
assert.Equal(t, apk, got) assert.Equal(t, apk, got)
// Подменённый файл не пройдёт сверку. // Подменённый файл не пройдёт сверку.
require.NoError(t, os.Remove(path)) require.NoError(t, os.Remove(path))
l.SHA256 = "00" + l.SHA256[2:] android.SHA256 = "00" + android.SHA256[2:]
_, err = h.ensure(context.Background(), l) _, err = h.ensure(context.Background(), android)
assert.ErrorContains(t, err, "checksum mismatch") assert.ErrorContains(t, err, "checksum mismatch")
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk")) _, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
assert.True(t, os.IsNotExist(statErr)) assert.True(t, os.IsNotExist(statErr))
+1
View File
@@ -566,5 +566,6 @@ func TestPublicRoutes(t *testing.T) {
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public") assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/clients", nil).Code, "download page is public")
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
} }
+4 -2
View File
@@ -55,8 +55,9 @@ var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true, "GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true, "POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true, "POST /api/v1/auth/refresh": true,
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента. // Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
"GET /api/v1/app/android/latest": true, "GET /api/v1/app/android/latest": true,
"GET /api/v1/app/clients": true,
} }
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль. // Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
@@ -93,7 +94,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя. // Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) && cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") || (strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/")) strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover { if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
return return
+2 -1
View File
@@ -27,10 +27,11 @@ type ClientType string
const ( const (
ClientWeb ClientType = "web" ClientWeb ClientType = "web"
ClientAndroid ClientType = "android" ClientAndroid ClientType = "android"
ClientDesktop ClientType = "desktop"
) )
// Valid сообщает, известен ли тип клиента. // Valid сообщает, известен ли тип клиента.
func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid } func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid || c == ClientDesktop }
// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента. // UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента.
type UIPrefs struct { type UIPrefs struct {
+219
View File
@@ -0,0 +1,219 @@
package playback
import (
"context"
"errors"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"pipu/api/internal/api/gen"
"pipu/api/internal/platform/apperr"
)
func validatePlaylist(name, description string, ids []uuid.UUID) error {
if n := utf8.RuneCountInString(strings.TrimSpace(name)); n < 1 || n > 120 {
return apperr.Validation("name", "playlist.invalid_name", "name must contain 1–120 characters", nil)
}
if utf8.RuneCountInString(description) > 2000 || len(ids) > 2000 {
return apperr.Validation("playlist", "playlist.too_large", "maximum 2000 description characters and tracks", nil)
}
seen := map[uuid.UUID]bool{}
for _, id := range ids {
if id == uuid.Nil || seen[id] {
return apperr.Validation("track_ids", "playlist.invalid_tracks", "track IDs must be unique and nonzero", nil)
}
seen[id] = true
}
return nil
}
const playlistSelect = `SELECT p.id, p.name, p.description, p.revision, p.created_at, p.updated_at,
(SELECT count(*) FROM playlist_tracks pt WHERE pt.playlist_id=p.id),
COALESCE((SELECT sum(t.duration_ms) FROM playlist_tracks pt JOIN tracks t ON t.id=pt.track_id WHERE pt.playlist_id=p.id),0)
FROM playlists p`
func scanPlaylist(row pgx.Row) (gen.PlaylistSummary, error) {
var p gen.PlaylistSummary
err := row.Scan(&p.Id, &p.Name, &p.Description, &p.Revision, &p.CreatedAt, &p.UpdatedAt, &p.TrackCount, &p.DurationMs)
if errors.Is(err, pgx.ErrNoRows) {
return p, apperr.NotFound("playlist.not_found", "playlist not found")
}
return p, err
}
func (h *Handler) ListPlaylists(ctx context.Context, _ gen.ListPlaylistsRequestObject) (gen.ListPlaylistsResponseObject, error) {
user, err := principal(ctx)
if err != nil {
return nil, err
}
rows, err := h.store.pool.Query(ctx, playlistSelect+` WHERE p.user_id=$1 ORDER BY p.updated_at DESC, p.id`, user.UserID)
if err != nil {
return nil, err
}
defer rows.Close()
out := gen.PlaylistList{Items: []gen.PlaylistSummary{}}
for rows.Next() {
p, err := scanPlaylist(rows)
if err != nil {
return nil, err
}
out.Items = append(out.Items, p)
}
return gen.ListPlaylists200JSONResponse(out), rows.Err()
}
func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlist, error) {
var out gen.Playlist
tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
if err != nil {
return out, err
}
defer tx.Rollback(ctx)
p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+` WHERE p.user_id=$1 AND p.id=$2`, user, id))
if err != nil {
return out, err
}
rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, id)
if err != nil {
return out, err
}
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
if err != nil {
return out, err
}
if err = tx.Commit(ctx); err != nil {
return out, err
}
tracks, err := h.catalog.TracksByIDs(ctx, ids)
if err != nil {
return out, err
}
dtos, err := h.dto().Tracks(ctx, tracks)
if err != nil {
return out, err
}
if dtos == nil {
dtos = []gen.Track{}
}
out = gen.Playlist{Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos}
for _, t := range dtos {
out.DurationMs += int64(t.DurationMs)
}
return out, nil
}
func (h *Handler) GetPlaylist(ctx context.Context, req gen.GetPlaylistRequestObject) (gen.GetPlaylistResponseObject, error) {
user, err := principal(ctx)
if err != nil {
return nil, err
}
p, err := h.playlist(ctx, user.UserID, req.Id)
if err != nil {
return nil, err
}
return gen.GetPlaylist200JSONResponse(p), nil
}
// A row lock plus revision check prevents stale clients from overwriting each other.
// Replacing ordered membership is atomic, including validation of all foreign keys.
func (st *Store) savePlaylist(ctx context.Context, user, id uuid.UUID, name, description string, ids []uuid.UUID, revision int, create bool) error {
if err := validatePlaylist(name, description, ids); err != nil {
return err
}
tx, err := st.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
if create {
_, err = tx.Exec(ctx, `INSERT INTO playlists(id,user_id,name,description) VALUES($1,$2,$3,$4)`, id, user, strings.TrimSpace(name), description)
} else {
var current int
err = tx.QueryRow(ctx, `SELECT revision FROM playlists WHERE id=$1 AND user_id=$2 FOR UPDATE`, id, user).Scan(&current)
if errors.Is(err, pgx.ErrNoRows) {
return apperr.NotFound("playlist.not_found", "playlist not found")
}
if err != nil {
return err
}
if current != revision {
return apperr.Conflict("playlist.conflict", "playlist changed; reload before editing")
}
_, err = tx.Exec(ctx, `UPDATE playlists SET name=$3, description=$4, revision=revision+1, updated_at=now() WHERE id=$1 AND user_id=$2`, id, user, strings.TrimSpace(name), description)
}
if err != nil {
return err
}
if _, err = tx.Exec(ctx, `DELETE FROM playlist_tracks WHERE playlist_id=$1`, id); err != nil {
return err
}
if len(ids) > 0 {
_, err = tx.Exec(ctx, `INSERT INTO playlist_tracks(playlist_id,track_id,position) SELECT $1, id, ord-1 FROM unnest($2::uuid[]) WITH ORDINALITY AS x(id,ord)`, id, ids)
if err != nil {
var pgerr *pgconn.PgError
if errors.As(err, &pgerr) && pgerr.Code == "23503" {
return apperr.Validation("track_ids", "playlist.invalid_tracks", "track no longer exists", nil)
}
return err
}
}
return tx.Commit(ctx)
}
func (h *Handler) CreatePlaylist(ctx context.Context, req gen.CreatePlaylistRequestObject) (gen.CreatePlaylistResponseObject, error) {
user, err := principal(ctx)
if err != nil {
return nil, err
}
if req.Body == nil {
return nil, apperr.Validation("body", "playlist.invalid_name", "body required", nil)
}
id := uuid.New()
b := req.Body
if err = h.store.savePlaylist(ctx, user.UserID, id, b.Name, b.Description, b.TrackIds, 0, true); err != nil {
return nil, err
}
p, err := h.playlist(ctx, user.UserID, id)
if err != nil {
return nil, err
}
return gen.CreatePlaylist201JSONResponse(p), nil
}
func (h *Handler) UpdatePlaylist(ctx context.Context, req gen.UpdatePlaylistRequestObject) (gen.UpdatePlaylistResponseObject, error) {
user, err := principal(ctx)
if err != nil {
return nil, err
}
if req.Body == nil {
return nil, apperr.Validation("body", "playlist.invalid_name", "body required", nil)
}
b := req.Body
if err = h.store.savePlaylist(ctx, user.UserID, req.Id, b.Name, b.Description, b.TrackIds, b.Revision, false); err != nil {
return nil, err
}
p, err := h.playlist(ctx, user.UserID, req.Id)
if err != nil {
return nil, err
}
return gen.UpdatePlaylist200JSONResponse(p), nil
}
func (h *Handler) DeletePlaylist(ctx context.Context, req gen.DeletePlaylistRequestObject) (gen.DeletePlaylistResponseObject, error) {
user, err := principal(ctx)
if err != nil {
return nil, err
}
result, err := h.store.pool.Exec(ctx, `DELETE FROM playlists WHERE id=$1 AND user_id=$2`, req.Id, user.UserID)
if err != nil {
return nil, err
}
if result.RowsAffected() == 0 {
return nil, apperr.NotFound("playlist.not_found", "playlist not found")
}
return gen.DeletePlaylist204Response{}, nil
}
@@ -0,0 +1,100 @@
//go:build integration
package playback
import (
"context"
"os"
"sync"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/library"
"pipu/api/internal/platform/apperr"
"pipu/api/internal/platform/db"
)
func TestPlaylistPersistenceAndIsolation(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := db.Connect(ctx, url)
require.NoError(t, err)
defer pool.Close()
require.NoError(t, db.Migrate(ctx, pool))
owner, other, artist, album, first, second, id := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
_, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','user'),($3,$4,'x','user')`, owner, owner.String(), other, other.String())
require.NoError(t, err)
defer pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1)`, []uuid.UUID{owner, other})
_, err = pool.Exec(ctx, `INSERT INTO artists(id,name,sort_name,name_norm) VALUES($1,'Artist','artist',$2)`, artist, artist.String())
require.NoError(t, err)
defer pool.Exec(ctx, `DELETE FROM artists WHERE id=$1`, artist)
_, err = pool.Exec(ctx, `INSERT INTO albums(id,title,title_norm,album_artist_id) VALUES($1,'Album','album',$2)`, album, artist)
require.NoError(t, err)
defer pool.Exec(ctx, `DELETE FROM albums WHERE id=$1`, album)
_, err = pool.Exec(ctx, `INSERT INTO tracks(id,title,title_norm,album_id,duration_ms) VALUES($1,'First','first',$3,1000),($2,'Second','second',$3,2000)`, first, second, album)
require.NoError(t, err)
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second})
st := NewStore(pool)
h := &Handler{store: st, catalog: library.NewStore(pool), dto: func() library.DTO { return library.DTO{} }}
require.NoError(t, st.savePlaylist(ctx, owner, id, " Mix ", "Description", []uuid.UUID{first, second}, 0, true))
got, err := h.playlist(ctx, owner, id)
require.NoError(t, err)
require.Equal(t, "Mix", got.Name)
require.Equal(t, int64(3000), got.DurationMs)
require.Equal(t, first, got.Tracks[0].Id)
_, err = h.playlist(ctx, other, id)
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
require.ErrorIs(t, st.savePlaylist(ctx, other, id, "stolen", "", nil, 1, false), apperr.NotFound("playlist.not_found", ""))
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id})
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
require.NoError(t, st.savePlaylist(ctx, owner, id, "Mix", "", []uuid.UUID{second, first}, 1, false))
require.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", ""))
// A missing track rolls back both metadata and membership.
require.Error(t, st.savePlaylist(ctx, owner, id, "broken", "", []uuid.UUID{uuid.New()}, 2, false))
got, err = h.playlist(ctx, owner, id)
require.NoError(t, err)
require.Equal(t, 2, got.Revision)
require.Equal(t, "Mix", got.Name)
require.Equal(t, second, got.Tracks[0].Id)
// Exactly one concurrent write may consume a revision.
results := make(chan error, 2)
var wg sync.WaitGroup
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
results <- st.savePlaylist(ctx, owner, id, "Updated", "", []uuid.UUID{first}, 2, false)
}()
}
wg.Wait()
close(results)
successes := 0
for e := range results {
if e == nil {
successes++
} else {
require.ErrorIs(t, e, apperr.Conflict("playlist.conflict", ""))
}
}
require.Equal(t, 1, successes)
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id})
require.NoError(t, err)
var remaining int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining))
require.Zero(t, remaining)
// Deleting a playlist never deletes catalog tracks.
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second}).Scan(&remaining))
require.Equal(t, 2, remaining)
rows, err := pool.Query(ctx, `SELECT id FROM playlists WHERE id=$1`, id)
require.NoError(t, err)
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
require.NoError(t, err)
require.Empty(t, ids)
}
@@ -0,0 +1,21 @@
package playback
import (
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"strings"
"testing"
)
func TestValidatePlaylist(t *testing.T) {
id := uuid.New()
require.NoError(t, validatePlaylist(" В дорогу ", "", []uuid.UUID{id}))
require.NoError(t, validatePlaylist(strings.Repeat("я", 120), strings.Repeat("я", 2000), nil))
for _, name := range []string{"", " \t\n", strings.Repeat("я", 121)} {
require.Error(t, validatePlaylist(name, "", nil))
}
require.Error(t, validatePlaylist("Mix", strings.Repeat("я", 2001), nil))
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{id, id}))
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{uuid.Nil}))
require.Error(t, validatePlaylist("Mix", "", make([]uuid.UUID, 2001)))
}
@@ -0,0 +1,23 @@
-- +goose Up
CREATE TABLE playlists (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
revision integer NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX playlists_owner ON playlists(user_id, updated_at DESC, id);
CREATE TABLE playlist_tracks (
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
position integer NOT NULL CHECK (position >= 0),
PRIMARY KEY (playlist_id, track_id),
UNIQUE (playlist_id, position)
);
CREATE INDEX playlist_tracks_track ON playlist_tracks(track_id);
-- +goose Down
DROP TABLE playlist_tracks;
DROP TABLE playlists;