Compare commits

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-21 23:37:53 +03:00
93 changed files with 4551 additions and 454 deletions

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
# Rust-часть десктопа: прокси и проверки URL. Установщики — в release.yml
# (Linux AppImage/deb и Windows NSIS через cargo-xwin на ubuntu-latest).
name: ci / desktop
on:
push:
branches: [main]
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
pull_request:
paths:
- 'apps/desktop/**'
- '.gitea/workflows/ci-desktop.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
rust:
name: rust (desktop proxy)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
+5 -1
View File
@@ -57,5 +57,9 @@ jobs:
with:
path: ~/.cache/ms-playwright
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
+137 -5
View File
@@ -1,6 +1,7 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs).
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N]: Gitea Release с заметками → APK, Linux и Windows.
# Релиз создаётся сразу (статусы Android/десктоп — «собирается»); сборки докладывают файлы
# и переписывают статус: собран / не удалась (scripts/release.mjs).
# Один Ubuntu-раннер: сначала APK, потом десктоп (Linux native + Windows через cargo-xwin).
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
#
# Секреты (Settings → Actions → Secrets):
@@ -132,9 +133,10 @@ jobs:
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt|Timeout waiting to lock journal cache|currently in use by another Gradle instance" /tmp/gradle.log; then
echo "::warning::кэш Gradle занят или повреждён — останавливаем демоны и собираем заново"
./gradlew --stop || true
find ~/.gradle -name "*.lock" -delete || true
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
"${gradle[@]}" assembleRelease -x lintVitalRelease
else
@@ -196,3 +198,133 @@ jobs:
- name: Статус в релизе — сборка отменена
if: cancelled()
run: node scripts/release.mjs android cancelled
# После Android: на одном хосте параллельный Tauri + Gradle валит раннер.
# Windows NSIS — cargo-xwin + makensis на этом же Ubuntu (виндового раннера нет).
desktop:
name: desktop (Linux и Windows в релиз)
needs: [release, android]
if: always() && needs.release.result == 'success'
timeout-minutes: 90
runs-on: ubuntu-latest
defaults:
run:
shell: bash
env:
PIPU_VERSION: ${{ needs.release.outputs.version }}
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
APPIMAGE_EXTRACT_AND_RUN: "1"
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
CARGO_BUILD_JOBS: "2"
XWIN_CACHE_DIR: /opt/ci-cache/xwin
steps:
- uses: actions/checkout@v4
- name: Статус в релизе — собирается
run: |
node scripts/release.mjs desktop linux building
node scripts/release.mjs desktop windows building
- name: Rust
run: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev build-essential curl wget file \
libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev \
libdbus-1-dev libsecret-1-dev patchelf libfuse2 \
nsis lld llvm clang
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
~/.cargo/bin/cargo-xwin
key: cargo-desktop-${{ hashFiles('apps/desktop/src-tauri/Cargo.toml') }}
restore-keys: cargo-desktop-
- run: pnpm install --frozen-lockfile
- name: Сборка AppImage и deb
id: linux
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:linux
bundle=src-tauri/target/release/bundle
shopt -s nullglob
for f in "$bundle"/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.*
rm -rf src-tauri/target/release
- name: Linux в релиз
id: linux-upload
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: Windows toolchain
if: ${{ !cancelled() }}
run: |
set -euo pipefail
rustup target add x86_64-pc-windows-msvc
if ! command -v cargo-xwin >/dev/null; then
cargo install --locked cargo-xwin
fi
if [ ! -d /opt/ci-cache ]; then
echo "XWIN_CACHE_DIR=$HOME/.cache/xwin" >> "$GITHUB_ENV"
mkdir -p "$HOME/.cache/xwin"
else
mkdir -p /opt/ci-cache/xwin
fi
- name: Сборка NSIS
id: win
if: ${{ !cancelled() }}
working-directory: apps/desktop
run: |
set -euo pipefail
pnpm bundle:windows:cross
bundle=src-tauri/target/x86_64-pc-windows-msvc/release/bundle/nsis
shopt -s nullglob
copied=
for f in "$bundle"/*-setup.exe; do
cp "$f" "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
copied=1
done
if [ -z "$copied" ]; then
echo "::error::нет NSIS setup.exe после сборки в $bundle"
ls -la "$bundle" || true
exit 1
fi
ls -lh "$GITHUB_WORKSPACE/pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Windows в релиз
id: win-upload
if: ${{ !cancelled() && steps.win.outcome == 'success' }}
run: node scripts/release.mjs desktop windows success "pipu-music-${PIPU_VERSION}-windows-setup.exe"
- name: Статус Linux — сборка не удалась
if: failure() && steps.linux-upload.outcome != 'success'
run: node scripts/release.mjs desktop linux failure
- name: Статус Windows — сборка не удалась
if: failure() && steps.win-upload.outcome != 'success'
run: node scripts/release.mjs desktop windows failure
- name: Статус в релизе — сборка отменена
if: cancelled()
run: |
node scripts/release.mjs desktop linux cancelled
node scripts/release.mjs desktop windows cancelled
+4
View File
@@ -37,3 +37,7 @@ Thumbs.db
.vscode/*
!.vscode/extensions.json
.cache/
apps/desktop/src-tauri/target/
apps/desktop/src-tauri/gen/
apps/desktop/src-tauri/tauri.release.json
apps/desktop/release/
+2 -1
View File
@@ -1,6 +1,6 @@
# pipu-music
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
@@ -15,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
| `apps/web` | Веб-клиент (React + Vite) |
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
| `apps/mobile` | Android-приложение (React Native + Expo) |
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
+205 -4
View File
@@ -1497,11 +1497,57 @@ paths:
$ref: "#/components/schemas/WaveBatch"
default:
$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:
get:
operationId: getAndroidLatest
tags: [app]
security: [] # публично: страница /android раздаёт приложение без входа
security: [] # публично: страница /download раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1538,6 +1584,82 @@ paths:
format: binary
default:
$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:
parameters:
SourceId:
@@ -1598,6 +1720,53 @@ components:
schema:
$ref: "#/components/schemas/Problem"
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:
type: object
required: [required, artists, genres]
@@ -2100,7 +2269,7 @@ components:
enum: [admin, moderator, user]
ClientType:
type: string
enum: [web, android]
enum: [web, android, desktop]
LoginRequest:
type: object
required: [username, password, client_type]
@@ -2120,7 +2289,7 @@ components:
properties:
refresh_token:
type: string
description: Только Android; веб передаёт токен в cookie
description: Android и desktop; веб передаёт токен в cookie
AuthTokens:
type: object
required: [access_token, token_type, expires_in, user]
@@ -2135,7 +2304,7 @@ components:
description: Срок жизни access-токена, секунды
refresh_token:
type: string
description: "Только для `client_type: android`"
description: "Для `client_type: android` и `client_type: desktop`"
user:
$ref: "#/components/schemas/Me"
UiPrefs:
@@ -3323,6 +3492,38 @@ components:
like_rate:
type: number
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:
type: object
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
+19
View File
@@ -0,0 +1,19 @@
import { execFileSync } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = dirname(fileURLToPath(import.meta.url));
const conf = JSON.parse(readFileSync(resolve(root, 'src-tauri/tauri.conf.json'), 'utf8'));
let version = process.env.PIPU_VERSION ?? '0.0.0';
if (!process.env.PIPU_VERSION) {
try {
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
version = tag.replace(/^v/, '');
} catch {
// local untagged build
}
}
conf.version = version;
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
console.log(`desktop version ${version}`);
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@pipu/desktop",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tauri dev",
"bundle": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json",
"bundle:linux": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles appimage,deb",
"bundle:windows": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --bundles nsis",
"bundle:windows:cross": "node inject-version.mjs && tauri build --config src-tauri/tauri.release.json --runner cargo-xwin --target x86_64-pc-windows-msvc --bundles nsis",
"tauri": "tauri",
"icons": "tauri icon ../web/public/favicon.svg"
},
"devDependencies": {
"@tauri-apps/cli": "^2.9.0"
}
}
+44
View File
@@ -0,0 +1,44 @@
[package]
name = "pipu-desktop"
version = "0.0.0"
edition = "2021"
[lib]
name = "pipu_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[[bin]]
name = "pipu-desktop"
path = "src/main.rs"
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
uuid = { version = "1", features = ["v4"] }
url = "2"
souvlaki = "0.8.3"
[target.'cfg(target_os = "windows")'.dependencies]
keyring = { version = "3.6.3", features = ["windows-native"] }
[target.'cfg(target_os = "linux")'.dependencies]
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3.6.3", features = ["apple-native"] }
[profile.release]
strip = true
lto = true
codegen-units = 1
opt-level = "s"
panic = "abort"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,7 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "main",
"description": "Only the bundled main window may invoke desktop integration.",
"windows": ["main"],
"permissions": ["core:default", "core:event:default"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 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": false }
},
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
}
}
@@ -1,4 +1,5 @@
import { unwrap, type Track } from '@pipu/core';
import { useRouter } from 'expo-router';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
const session = useSession();
const current = usePlayerState().track;
const [tab, setTab] = useState<Tab>('likes');
const router = useRouter();
const list = useInfiniteQuery({
queryKey: ['m-collection', tab],
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
const tabs = (
<View style={{ gap: 12 }}>
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
<Segmented<Tab>
value={tab}
onChange={setTab}
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
{ value: 'dislikes', label: t('collection.dislikes') },
]}
/>
</View>
);
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
+1
View File
@@ -109,6 +109,7 @@ function Screens() {
<Stack.Screen name="(tabs)" />
<Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" />
<Stack.Screen name="playlists" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected>
</Stack>
+22 -3
View File
@@ -1,9 +1,10 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
import { trackShareUrl } from '@pipu/core';
import { useUser } from '../lib/session';
import { useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller';
import { usePlayerState } from '../player/hooks';
import { useColors } from '../theme';
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
const router = useRouter();
const st = usePlayerState();
const prefs = useUser().ui_prefs;
const { server } = useSessionCtx();
const [sharing, setSharing] = useState(false);
const [barWidth, setBarWidth] = useState(1);
const track = st.track;
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
if (!server || sharing) return;
setSharing(true);
try {
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
} catch {
Alert.alert(t('player.share'), t('player.share_error'));
} finally {
setSharing(false);
}
};
return (
<Screen>
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
</Pressable>
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
<DownloadButton track={track} />
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
<Icon name="share" size={24} color={colors.text} />
</Pressable>
<DownloadButton track={track} />
</View>
</View>
<View style={{ alignItems: 'center' }}>
+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 { useRouter } from 'expo-router';
import { ApiError, type Track } from '@pipu/core';
import type { ComponentProps, ReactNode } from 'react';
import { useState, useSyncExternalStore } from 'react';
@@ -199,6 +200,7 @@ export function TrackRow({
const colors = useColors();
const { t } = useTranslation();
const liked = track.reaction === 'like';
const router = useRouter();
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
return (
<Pressable
@@ -233,6 +235,10 @@ export function TrackRow({
</Pressable>
)}
{trailing}
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
<Icon name="playlist-add" size={22} />
</Pressable>
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
</Pressable>
+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', () => {
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 () => {
const alice = makeUser('alice', 'user');
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);
});
});
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 SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
import type { DesktopHandle } from '../desktop';
import { AlbumPage } from '../pages/AlbumPage';
import { ConnectPage } from '../pages/ConnectPage';
import { TrackPage } from '../pages/TrackPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage';
@@ -32,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
export interface RouterContext {
session: AuthSession;
desktop: DesktopHandle | null;
}
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
@@ -50,9 +55,12 @@ export function safeRedirect(target: unknown): string {
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
}
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
const user = signedInUser(session);
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
if (!user) {
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
throw redirect({ to: '/login', search: { redirect: location.href } });
}
return user;
}
@@ -61,8 +69,26 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError,
});
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
// Публичная: ссылку на приложения отправляют тем, у кого ещё нет клиента на устройстве.
const downloadAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/download', component: AppDownloadPage });
const androidAppRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/android',
beforeLoad: () => {
throw redirect({ to: '/download' });
},
component: AppDownloadPage,
});
const connectRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/connect',
beforeLoad: ({ context }) => {
if (!context.desktop) throw redirect({ to: '/login' });
if (context.desktop.server) throw redirect({ to: '/login' });
},
component: ConnectPage,
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
@@ -70,6 +96,7 @@ const loginRoute = createRoute({
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
beforeLoad: ({ context, search }) => {
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
const user = signedInUser(context.session);
if (!user) return;
const home = homeFor(user);
@@ -83,7 +110,7 @@ const setupAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/admin',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="admin" />,
@@ -93,7 +120,7 @@ const setupPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/password',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="password" />,
@@ -104,7 +131,7 @@ const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const user = requireUser(context.session, location, context.desktop);
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
return { user };
@@ -117,6 +144,9 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const 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 });
@@ -240,12 +270,17 @@ const adminSettingsRoute = createRoute({
});
const routeTree = rootRoute.addChildren([
downloadAppRoute,
androidAppRoute,
connectRoute,
loginRoute,
setupAdminRoute,
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
trackRoute,
playlistsRoute,
playlistRoute,
settingsRoute,
searchRoute,
historyRoute,
@@ -265,8 +300,12 @@ const routeTree = rootRoute.addChildren([
]),
]);
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
export function createAppRouter(
session: AuthSession,
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
) {
const { desktop = null, ...rest } = opts;
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
session.subscribe(() => void router.invalidate());
return router;
+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; }
.end { gap: 4px; }
.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 { Link } from '@tanstack/react-router';
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 { formatDuration } from '../shared/lib/format';
@@ -115,6 +116,7 @@ export function TrackList({
rowMeta,
lead,
disliked,
actions,
}: {
tracks: Track[];
showAlbum?: boolean;
@@ -126,6 +128,7 @@ export function TrackList({
lead?: (t: Track, i: number) => string;
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
disliked?: boolean;
actions?: (track: Track, index: number) => ReactNode;
}) {
const { t } = useTranslation();
const player = usePlayer();
@@ -154,7 +157,7 @@ export function TrackList({
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
@@ -181,7 +184,8 @@ export function TrackList({
{tr.album.title}
</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 ? (
<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 { createAppRouter } from './app/router';
import { applyTheme } from './app/session';
import { createDesktop, DesktopProvider } from './desktop';
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 роутера решают сразу.
await session.restore();
const router = createAppRouter(session);
const router = createAppRouter(session, { desktop });
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nextProvider i18n={i18n}>
<App session={session} router={router} />
<DesktopProvider value={desktop}>
<App session={session} router={router} />
</DesktopProvider>
</I18nextProvider>
</StrictMode>,
);
+2
View File
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { playIcon, shuffleIcon } from '../shared/ui/icons';
@@ -45,6 +46,7 @@ export function AlbumPage({ id }: { id: string }) {
)}
{a && a.tracks.some((tr) => tr.available) && (
<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)}>
{t('catalog.play')}
</Button>
+67 -32
View File
@@ -1,25 +1,27 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format';
import { detectAppClient, otherAppClients, type AppClient } from '../shared/lib/platform';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
* Публичная страница приложений (/download, /android): ссылку можно отправить человеку.
* Сборки — последний релиз Gitea через сервер; первым предлагается клиент текущей ОС.
*/
export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']);
const session = useSession();
const [copied, setCopied] = useState(false);
const detected = useMemo(() => detectAppClient(), []);
const latest = useQuery({
queryKey: ['android-latest'],
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
queryKey: ['app-clients'],
queryFn: async () => (await session.client.GET('/api/v1/app/clients')).data ?? null,
staleTime: 10 * 60_000,
});
const server = location.origin;
@@ -28,39 +30,59 @@ export function AppDownloadPage() {
setCopied(true);
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 (
<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>
<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>
<Icon src={downloadIcon} size={22} />
<span>
{t('app.download')}
<span className={s.downloadMeta}>
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
</span>
</span>
</a>
<ol className={s.steps}>
<li>{t('app.step_install')}</li>
<li>
{t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} />
</button>
</li>
<li>{t('app.step_updates')}</li>
</ol>
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{r.sha256}</code>
</details>
{primary ? (
<DownloadButton client={detected!} href={primary.download_url} meta={`${t('app.version', { version: data.version })} · ${formatBytes(primary.size, i18n.language)}`} />
) : detected ? (
<div className={s.hint}>{t('app.unavailable_platform', { platform: t(`app.platform.${detected}`) })}</div>
) : null}
{(primary || others.length > 0) && (
<ol className={s.steps}>
{detected && <li>{t(`app.step_install.${detected}`)}</li>}
<li>
{t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} />
</button>
</li>
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
</ol>
)}
{primary?.sha256 && (
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{primary.sha256}</code>
</details>
)}
{others.length > 0 && (
<div className={s.otherClients}>
<p className={s.otherTitle}>{t('app.other')}</p>
{others.map((id) => {
const file = files[id]!;
return (
<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>
@@ -68,3 +90,16 @@ export function AppDownloadPage() {
</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 { useSession } from '../app/session';
import { useDesktop } from '../desktop';
import { useErrorText } from '../shared/lib/errors';
import { Button, Field, FormError } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
@@ -11,6 +12,7 @@ import s from './auth.module.css';
export function LoginPage() {
const { t } = useTranslation(['auth', 'common']);
const session = useSession();
const desktop = useDesktop();
const errorText = useErrorText();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
@@ -41,7 +43,13 @@ export function LoginPage() {
footer={
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
}
+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 { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../app/session';
import { useDesktop } from '../desktop';
import { useErrorText, useFieldError } from '../shared/lib/errors';
import { formatDateTime } from '../shared/lib/format';
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
@@ -297,6 +299,8 @@ function PasswordSection() {
function SessionsSection() {
const { t, i18n } = useTranslation();
const session = useSession();
const desktop = useDesktop();
const navigate = useNavigate();
const queryClient = useQueryClient();
const errorText = useErrorText();
const sessions = useQuery({
@@ -333,6 +337,19 @@ function SessionsSection() {
</div>
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
<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)}>
{t('settings.logout_all')}
</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);
}
/* Страница Android-приложения (/android). */
/* Страница приложений (/download). */
.download {
display: flex;
align-items: center;
@@ -238,6 +238,40 @@
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 {
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; } }
+36
View File
@@ -10,6 +10,8 @@ import {
type Track,
} from '@pipu/core';
import { desktopHandle } from '../desktop';
export type { PlayContext, Track };
export interface WaveInfo {
@@ -121,6 +123,7 @@ export class Player {
};
this.bindAudio();
this.bindMediaSession();
this.bindDesktopMedia();
}
/** Запуск фоновой отправки событий (парный вызов — dispose). */
@@ -137,9 +140,13 @@ export class Player {
getState = () => this.state;
private lastDesktop: { id?: string; playing?: boolean } = {};
private desktopUnlisten: (() => void) | null = null;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
this.syncDesktopMedia();
}
private syncQueue() {
@@ -678,6 +685,33 @@ export class Player {
// --- 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() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
@@ -855,6 +889,8 @@ export class Player {
this.overlap.load();
for (const t of this.timers) clearInterval(t);
this.timers = [];
this.desktopUnlisten?.();
this.desktopUnlisten = null;
void this.events.flush();
}
}
+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 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 shareIcon } from '@material-symbols/svg-400/rounded/share.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 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;
const calls: { method: string; path: string; body: unknown }[] = [];
const reactions = new Map<string, 'like' | 'dislike'>();
const playlists = new Map<string, components['schemas']['Playlist']>();
const me = (u: FakeUser): Me => ({
id: u.id,
@@ -115,6 +116,28 @@ export function fakeApi(initial: FakeUser[]) {
min_android_version_code: 0,
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': {
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
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 (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) {
case 'POST /api/v1/auth/logout':
case 'POST /api/v1/auth/logout-all':
@@ -193,11 +233,14 @@ export function fakeApi(initial: FakeUser[]) {
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
const q = url.searchParams.get('q') ?? '';
const hit = q === 'кино' || q === 'rbyj';
const tracks = path === '/api/v1/search' && hit
? albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null }))
: [];
return json({
artists: hit ? [kino] : [],
albums: hit ? [albumSummary] : [],
tracks: [],
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
tracks,
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: tracks.length },
corrected_query: q === 'rbyj' ? 'кино' : null,
next_cursor: null,
});
@@ -260,6 +303,11 @@ export function fakeApi(initial: FakeUser[]) {
return json(admin(target));
}
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 (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
else reactions.delete(reactionPath[1]!);
@@ -268,5 +316,5 @@ export function fakeApi(initial: FakeUser[]) {
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 { useQuery } from '@tanstack/react-query';
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 { useAuthState, useSession, useUser } from '../app/session';
import { useDesktop } from '../desktop';
import { useCollectionCounts } from '../entities/collection';
import {
moreIcon,
@@ -24,7 +26,7 @@ import {
dislikeFillIcon,
dislikeIcon,
settingsIcon,
androidIcon,
downloadIcon,
chevronRightIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
@@ -46,6 +48,7 @@ type NavPath =
| '/uploads/new'
| '/uploads/mine'
| '/admin/settings'
| '/collection/playlists'
| '/collection/history'
| '/collection/likes'
| '/collection/dislikes';
@@ -153,6 +156,7 @@ function Shell() {
const { t } = useTranslation();
const user = useUser();
const session = useSession();
const desktop = useDesktop();
const navigate = useNavigate();
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
@@ -169,6 +173,7 @@ function Shell() {
<>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<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/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink
@@ -249,7 +254,9 @@ function Shell() {
}
items={[
{ 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() },
]}
/>
+17 -1
View File
@@ -1,4 +1,5 @@
.bar {
position: relative;
grid-area: player;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
@@ -310,11 +311,26 @@ a.source:hover {
.controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; }
.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));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 359px) {
.now > :first-child { display: none; }
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
.shareButton { display: none; }
}
.shareStatus {
position: absolute;
bottom: calc(100% + 8px);
right: 8px;
max-width: calc(100% - 16px);
padding: 10px 16px;
border-radius: 12px;
background: var(--surface3);
color: var(--text);
box-shadow: var(--shadow);
font-size: 13px;
}
+29 -1
View File
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
import {
albumIcon,
closeIcon,
@@ -22,6 +23,7 @@ import {
repeatIcon,
repeatOneIcon,
shuffleIcon,
shareIcon,
volumeIcon,
volumeOffIcon,
waveIcon,
@@ -35,6 +37,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const player = usePlayer();
const st = usePlayerState();
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 queueId = useId();
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 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 ctx = st.context;
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')
: 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 ?? '' });
return (
@@ -154,6 +178,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
</div>
<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
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
@@ -204,6 +231,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
/>
</div>
{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>
);
}
+4 -4
View File
@@ -5,7 +5,7 @@
## Суть
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
Масштаб: до ~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), шаблоны путей, сканер, метаданные, поиск |
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент |
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
+4 -2
View File
@@ -102,8 +102,10 @@
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
сборка под систему посетителя, остальные платформы ниже, адрес сервера для копирования и короткая инструкция.
APK, Linux (AppImage, deb) и Windows (NSIS) прикладываются к релизу Gitea автоматически. Та же страница — внизу экрана входа
и в меню профиля.
## 7. Наблюдение
+4
View File
@@ -91,6 +91,8 @@
| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` |
| DELETE | `/me/reactions/tracks/{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)
| Метод | Путь | Описание |
@@ -104,6 +106,8 @@
### Мобильное обновление — [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/download/{version}` | APK (прокси / кэш релиза Gitea) |
+3 -2
View File
@@ -6,14 +6,14 @@
- Всё разворачивается на одном сервере через Docker Compose.
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
- Клиенты: веб (SPA) и Android-приложение.
- Клиенты: веб (SPA), десктоп (Tauri) и Android-приложение.
## 2. Компоненты
```
┌──────────────┐ ┌────────────────┐
Web SPA ─────┐ │ │ │ │
├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
Desktop ─────┼──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
│ X-Accel) │ │ │──► Meilisearch
└──────┬───────┘ └───┬────────┬───┘
@@ -82,6 +82,7 @@
pipu-music/
├── apps/
│ ├── web/ # React + Vite SPA
│ ├── desktop/ # Tauri 2: то же SPA в нативном окне
│ └── mobile/ # React Native (Expo) Android
├── packages/ # общий TypeScript-код (pnpm workspaces)
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
+21 -2
View File
@@ -41,7 +41,7 @@ CREATE TABLE refresh_tokens (
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
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(),
expires_at timestamptz NOT NULL,
used_at timestamptz, -- ротирован
@@ -303,6 +303,25 @@ CREATE TABLE track_reactions ( -- Коллекция → Лайки
);
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 ( -- «не рекомендовать исполнителя» и т.п.
user_id uuid REFERENCES users(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_id text, -- id альбома / wave_session и т.п.
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,
PRIMARY KEY (id, occurred_at)
) PARTITION BY RANGE (occurred_at);
+1 -1
View File
@@ -76,7 +76,7 @@
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
| `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` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `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 (плеер идёт через WebKit, в AppImage GStreamer не упаковываем — иначе файл раздувается на десятки мегабайт).
Релиз по тегу собирает всё на Linux-раннере: AppImage/`.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/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`)
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
ссылка `https://<ваш-домен>/android`.
ссылка `https://<ваш-домен>/download`.
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
+3 -3
View File
@@ -42,12 +42,12 @@
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
прогресс, лайк / дизлайк, скачать, очередь (нижний лист).
прогресс, лайк / дизлайк, скачать, поделиться ссылкой на `/track/{id}`, очередь (нижний лист).
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
выбор занятия / настроения.
- **Коллекция**: История, Лайки, Дизлайки, **Скачанное**.
- **Коллекция**: кнопка **Плейлисты**, затем История, Лайки, Дизлайки, **Скачанное**. Плейлисты — [playlists.md](playlists.md).
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
очередь загрузок с пометкой «по Wi-Fi».
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
@@ -99,7 +99,7 @@
## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
Приложение распространяется только через 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/
├── app/ # провайдеры, роутер, глобальные стили, гарды
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
│ # collection/{history,likes,dislikes}, settings,
├── pages/ # login, setup-admin, setup-password, download, home, search, artist, album,
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
│ # admin/{users,sources,library,upload,upload-queue,settings}
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
├── features/ # like-track, change-password, mood-picker, create-user, pattern-editor, ...
@@ -37,8 +37,8 @@ apps/web/src/
### App shell
Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
Админка по роли) / контент / плеер.
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → Плейлисты / История / Лайки / Дизлайки,
Админка по роли) / контент / плеер. Личные плейлисты — [playlists.md](playlists.md).
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
@@ -55,6 +55,7 @@ apps/web/src/
- Два элемента `<audio>` для предзагрузки следующего трека и бесшовного перехода (опционально — кроссфейд 0–6 с в настройках).
- Media Session API: метаданные, обложка, кнопки ОС / наушников.
- Горячие клавиши: пробел — пауза, ←/→ — перемотка 10 с, Shift+←/→ — трек, L — лайк.
- «Поделиться» копирует адрес страницы трека (`/track/{id}`) без токенов стрима; на телефоне в браузере открывается системный лист.
- Логика очереди, событий, сохранения позиции — из `packages/core` (общая с мобильным); веб реализует
только адаптер `AudioEngine`.
+281 -3
View File
@@ -1293,6 +1293,44 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1331,10 +1369,72 @@ export interface paths {
patch?: 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 interface components {
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: {
required: boolean;
artists: {
@@ -1596,7 +1696,7 @@ export interface components {
/** @enum {string} */
Role: "admin" | "moderator" | "user";
/** @enum {string} */
ClientType: "web" | "android";
ClientType: "web" | "android" | "desktop";
LoginRequest: {
username: string;
password: string;
@@ -1605,7 +1705,7 @@ export interface components {
device_name?: string;
};
RefreshRequest: {
/** @description Только Android; веб передаёт токен в cookie */
/** @description Android и desktop; веб передаёт токен в cookie */
refresh_token?: string;
};
AuthTokens: {
@@ -1614,7 +1714,7 @@ export interface components {
token_type: "Bearer";
/** @description Срок жизни access-токена, секунды */
expires_in: number;
/** @description Только для `client_type: android` */
/** @description Для `client_type: android` и `client_type: desktop` */
refresh_token?: string;
user: components["schemas"]["Me"];
};
@@ -2187,6 +2287,22 @@ export interface components {
early_skip_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: {
/** @example 0.6.1 */
version: string;
@@ -4127,6 +4243,51 @@ export interface operations {
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: {
parameters: {
query?: never;
@@ -4171,4 +4332,121 @@ export interface operations {
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>();
constructor(opts: AuthSessionOptions) {
if (opts.clientType === 'android' && !opts.refreshStore) {
throw new Error('AuthSession: android client needs a refreshStore');
if (opts.clientType !== 'web' && !opts.refreshStore) {
throw new Error(`AuthSession: ${opts.clientType} client needs a refreshStore`);
}
this.opts = opts;
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
+1
View File
@@ -22,5 +22,6 @@ export {
type Track,
} from './player';
export { normalizeGain, TARGET_LUFS } from './gain';
export { trackShareUrl } from './share';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
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"
},
"app": {
"link": "Android app",
"title": "pipu for Android",
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
"download": "Download APK",
"link": "Download apps",
"badge": "Apps",
"title": "pipu on your devices",
"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}}",
"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:",
"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",
"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"
}
}
+27 -1
View File
@@ -41,7 +41,7 @@
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out",
"android_app": "Android app"
"download_apps": "Download apps"
},
"roles": {
"admin": "Administrator",
@@ -54,6 +54,10 @@
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
},
"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",
"label": "Player",
"play": "Play",
@@ -302,5 +306,27 @@
"pick_more_few": "Pick {{count}} more artists",
"pick_more_many": "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",
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
"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": "Выйти"
},
"app": {
"link": "Приложение для Android",
"title": "pipu для Android",
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
"download": "Скачать APK",
"link": "Скачать приложения",
"badge": "Приложения",
"title": "pipu на ваших устройствах",
"subtitle": "Клиенты для этого сервера: телефон, компьютер и веб. Сначала предлагается сборка под вашу систему.",
"platform": {
"android": "Android",
"windows": "Windows",
"linux": "Linux",
"macos": "macOS"
},
"download": {
"android": "Скачать APK",
"windows": "Скачать для Windows",
"linux": "Скачать AppImage",
"macos": "Скачать для macOS"
},
"version": "версия {{version}}",
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"other": "Другие платформы",
"step_install": {
"android": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"windows": "Запустите установщик и следуйте шагам. pipu появится в меню Пуск.",
"linux": "Сделайте файл исполняемым и откройте его. Для .deb — установите пакет.",
"macos": "Откройте образ и перетащите pipu в Программы."
},
"step_server": "Запустите pipu и введите адрес сервера:",
"copy_server": "Скопировать адрес сервера",
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
"step_updates": {
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
},
"checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
"unavailable_platform": "В последнем релизе пока нет сборки для {{platform}}.",
"to_login": "Войти в веб-версию"
}
}
+27 -1
View File
@@ -41,7 +41,7 @@
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти",
"android_app": "Приложение для Android"
"download_apps": "Скачать приложения"
},
"roles": {
"admin": "Администратор",
@@ -54,6 +54,10 @@
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
},
"player": {
"share": "Поделиться треком",
"link_copied": "Ссылка на трек скопирована",
"share_error": "Не удалось поделиться ссылкой. Попробуйте ещё раз.",
"track_page": "Трек",
"empty": "Ничего не играет",
"label": "Плеер",
"play": "Слушать",
@@ -302,5 +306,27 @@
"pick_more_few": "Выберите ещё {{count}} исполнителя",
"pick_more_many": "Выберите ещё {{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": "Размер файла не совпал с заявленным",
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
"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
version: 5.8.3
apps/desktop:
devDependencies:
'@tauri-apps/cli':
specifier: ^2.9.0
version: 2.12.3
apps/mobile:
dependencies:
'@expo/vector-icons':
@@ -116,7 +122,7 @@ importers:
version: 19.0.14
vitest:
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:
dependencies:
@@ -1458,6 +1464,82 @@ packages:
'@tanstack/virtual-core@3.19.6':
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':
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
engines: {node: '>=18'}
@@ -5560,6 +5642,53 @@ snapshots:
'@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':
dependencies:
'@babel/code-frame': 7.29.7
@@ -8118,7 +8247,7 @@ snapshots:
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:
cac: 6.7.14
debug: 4.4.3(supports-color@10.2.2)
@@ -8188,7 +8317,7 @@ snapshots:
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)(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:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -8211,7 +8340,7 @@ snapshots:
tinypool: 1.1.1
tinyrainbow: 2.0.0
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
optionalDependencies:
'@types/node': 22.20.5
+100 -15
View File
@@ -11,7 +11,7 @@
import { execFileSync } from 'node:child_process';
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 { fileURLToPath } from 'node:url';
@@ -29,6 +29,12 @@ const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
export const STATUS_START = '<!-- 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) по разделам заметок. */
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');
}
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
const start = body.indexOf(STATUS_START);
const end = body.indexOf(STATUS_END);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
/** Блок статуса сборки десктопа в тексте релиза (отдельный для linux и windows). */
export function renderDesktopStatus({ platform, status, runUrl, files }) {
const label = platform === 'windows' ? 'Windows' : 'Linux';
const pending = platform === 'windows' ? 'NSIS-установщик' : 'AppImage и .deb';
const lines =
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. */
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
function replaceBlock(body, startMark, endMark, block) {
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 item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
const out = [];
@@ -93,7 +145,10 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
if (prerelease) {
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
}
out.push(androidStatus, '', '## Что изменилось', '');
out.push(androidStatus, '');
if (desktopLinuxStatus) out.push(desktopLinuxStatus, '');
if (desktopWindowsStatus) out.push(desktopWindowsStatus, '');
out.push('## Что изменилось', '');
if (groups.breaking.length) {
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
}
@@ -124,11 +179,14 @@ export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups
'',
`Ожидается \`"version":"${version}"\`.`,
'',
'## Android',
'## Клиенты',
'',
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
'Скачайте с этой страницы или с `/download` на сервере (подскажет сборку под вашу систему).',
'',
`- **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).`,
'',
@@ -202,6 +260,8 @@ async function create() {
repoUrl,
groups: groupCommits(commitsBetween(prevTag, tag)),
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 saved = release
@@ -242,13 +302,38 @@ async function android(status, apkPath) {
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() {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'create') return create();
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
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])) {
+35
View File
@@ -4,8 +4,11 @@ import { test } from 'node:test';
import {
groupCommits,
renderAndroidStatus,
renderDesktopStatus,
renderNotes,
replaceAndroidStatus,
replaceDesktopStatus,
desktopStatusMarks,
STATUS_END,
STATUS_START,
} 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));
});
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', () => {
const notes = renderNotes({
version: '0.2.0',
@@ -86,12 +117,16 @@ test('release notes', () => {
repoUrl,
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
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, /\[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, /"version":"0\.2\.0"/);
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.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#самообновление):
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и APK.
// Package appupdate — раздача клиентов через сервер (docs/mobile.md#самообновление):
// сервер читает последний релиз Gitea своим токеном, кэширует ответ и файлы.
package appupdate
import (
@@ -25,7 +25,7 @@ import (
"pipu/api/internal/platform/httpx"
)
// CodeUnavailable — обновления не настроены или релиза с APK нет.
// CodeUnavailable — обновления не настроены или релиза с клиентами нет.
const CodeUnavailable = "app.update_unavailable"
const cacheTTL = 10 * time.Minute
@@ -73,14 +73,22 @@ type release struct {
Assets []asset `json:"assets"`
}
// Latest — сведения о последней версии.
type clientFile struct {
Kind, Name, URL, ChecksumURL, SHA256 string
Size int64
}
// Latest — сведения о последней версии и файлах клиентов.
type Latest struct {
Version string
VersionCode int
Notes string
Size int64
SHA256 string
apkURL string
Files map[string]clientFile
}
func (l *Latest) android() (clientFile, bool) {
f, ok := l.Files["android"]
return f, ok && f.SHA256 != ""
}
// Handler реализует app-часть gen.StrictServerInterface.
@@ -92,7 +100,7 @@ type Handler struct {
mu sync.Mutex
cached *Latest
cachedAt time.Time
dl sync.Mutex // одна загрузка APK за раз
dl sync.Mutex
}
// 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}
}
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
!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
}
// 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) {
if !h.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 {
h.log.WarnContext(ctx, "app update: gitea unavailable", "err", err)
if h.cached != nil {
return h.cached, nil // устаревший ответ лучше, чем никакого
return h.cached, nil
}
return nil, apperr.NotFound(CodeUnavailable, "release is unavailable")
}
@@ -156,35 +226,77 @@ func (h *Handler) latest(ctx context.Context) (*Latest, error) {
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, err.Error())
}
name := "pipu-music-" + version + ".apk"
var apk, sum *asset
for i := range rel.Assets {
switch rel.Assets[i].Name {
case name:
apk = &rel.Assets[i]
case name + ".sha256":
sum = &rel.Assets[i]
files := pickClientFiles(rel.Assets)
if len(files) == 0 {
return nil, apperr.NotFound(CodeUnavailable, "release has no client builds")
}
if apk, ok := files["android"]; ok {
if apk.ChecksumURL == "" {
delete(files, "android")
} 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 {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
sres, err := h.get(ctx, sum.URL)
h.cached = &Latest{Version: version, VersionCode: code, Notes: rel.Body, Files: files}
h.cachedAt = time.Now()
return h.cached, nil
}
func (h *Handler) readDigest(ctx context.Context, url string) (string, error) {
sres, err := h.get(ctx, url)
if err != nil {
return nil, apperr.NotFound(CodeUnavailable, "checksum is unavailable")
return "", err
}
raw, err := io.ReadAll(io.LimitReader(sres.Body, 256))
_ = sres.Body.Close()
if err != nil {
return nil, err
return "", err
}
digest := strings.ToLower(strings.Fields(string(raw) + " x")[0])
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}
h.cachedAt = time.Now()
return h.cached, nil
return digest, 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.
@@ -193,15 +305,18 @@ func (h *Handler) GetAndroidLatest(ctx context.Context, _ gen.GetAndroidLatestRe
if err != nil {
return nil, err
}
f, ok := l.android()
if !ok {
return nil, apperr.NotFound(CodeUnavailable, "release has no APK yet")
}
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,
}, nil
}
// ensure скачивает APK в кэш (если его там нет) и сверяет SHA-256 из релиза.
func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
dst := filepath.Join(h.cfg.Dir, "pipu-music-"+l.Version+".apk")
func (h *Handler) ensure(ctx context.Context, f clientFile) (string, error) {
dst := filepath.Join(h.cfg.Dir, f.Name)
if _, err := os.Stat(dst); err == 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 {
return "", err
}
res, err := h.get(ctx, l.apkURL)
res, err := h.get(ctx, f.URL)
if err != nil {
return "", err
}
defer func() { _ = res.Body.Close() }()
tmp, err := os.CreateTemp(h.cfg.Dir, ".apk-*")
tmp, err := os.CreateTemp(h.cfg.Dir, ".dl-*")
if err != nil {
return "", err
}
@@ -231,26 +346,53 @@ func (h *Handler) ensure(ctx context.Context, l *Latest) (string, error) {
if err := tmp.Close(); err != nil {
return "", err
}
if got := hex.EncodeToString(hash.Sum(nil)); got != l.SHA256 {
return "", fmt.Errorf("appupdate: checksum mismatch: %s != %s", got, l.SHA256)
if f.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 {
return "", err
}
h.cleanup(dst)
h.cleanup(f.Name)
return dst, nil
}
// cleanup оставляет в кэше только текущий APK.
func (h *Handler) cleanup(keep string) {
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*.apk"))
func (h *Handler) cleanup(keepName string) {
matches, _ := filepath.Glob(filepath.Join(h.cfg.Dir, "pipu-music-*"))
ver := versionFromName(keepName)
for _, m := range matches {
if m != keep {
base := filepath.Base(m)
if ver != "" && strings.Contains(base, ver) {
continue
}
if base != keepName {
_ = 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. Отдаётся только последняя версия.
func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroidApkRequestObject) (gen.DownloadAndroidApkResponseObject, error) {
l, err := h.latest(ctx)
@@ -260,22 +402,36 @@ func (h *Handler) DownloadAndroidApk(ctx context.Context, req gen.DownloadAndroi
if req.Version != l.Version {
return nil, apperr.NotFound(CodeUnavailable, "only the latest version is available")
}
path, err := h.ensure(ctx, l)
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
return h.serveFile(ctx, l, "android")
}
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
name string
req *http.Request
}
// VisitDownloadAndroidApkResponse реализует gen.DownloadAndroidApkResponseObject (Range — для докачки).
func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) error {
func (a fileResponse) 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)
if err != nil {
return err
@@ -288,7 +444,11 @@ func (a apkResponse) VisitDownloadAndroidApkResponse(w http.ResponseWriter) erro
if a.req == nil {
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+`"`)
http.ServeContent(w, a.req, a.name, info.ModTime(), f)
return nil
@@ -16,6 +16,22 @@ import (
"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) {
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 {
@@ -60,17 +76,18 @@ func TestLatestAndDownload(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "0.6.1", l.Version)
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)
got, _ := os.ReadFile(path)
assert.Equal(t, apk, got)
// Подменённый файл не пройдёт сверку.
require.NoError(t, os.Remove(path))
l.SHA256 = "00" + l.SHA256[2:]
_, err = h.ensure(context.Background(), l)
android.SHA256 = "00" + android.SHA256[2:]
_, err = h.ensure(context.Background(), android)
assert.ErrorContains(t, err, "checksum mismatch")
_, statErr := os.Stat(filepath.Join(dir, "pipu-music-0.6.1.apk"))
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, "/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/clients", nil).Code, "download page is public")
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,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
// Клиенты раздаются по ссылке /download без входа: в файлах нет ничего, кроме приложения.
"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> — тоже без заголовка; ссылка подписана для пользователя.
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/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 {
next.ServeHTTP(w, r.WithContext(ctx))
return
+2 -1
View File
@@ -27,10 +27,11 @@ type ClientType string
const (
ClientWeb ClientType = "web"
ClientAndroid ClientType = "android"
ClientDesktop ClientType = "desktop"
)
// 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). Пустые поля — значения по умолчанию клиента.
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;