Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
597067689a | ||
|
|
efe78d7e07 | ||
|
|
fa79394869 | ||
|
|
3a097c15c8 | ||
|
|
20abe20686 | ||
|
|
c34272d508 |
No files matched your search
@@ -0,0 +1,33 @@
|
||||
# Rust-часть десктопа: прокси и проверки URL. Полная сборка инсталлятора — локально
|
||||
# (`pnpm --filter @pipu/desktop bundle`): на раннере нет Windows и WebKitGTK.
|
||||
name: ci / desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.gitea/workflows/ci-desktop.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.gitea/workflows/ci-desktop.yml'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
rust:
|
||||
name: rust (desktop proxy)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Rust
|
||||
run: |
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
|
||||
- run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libdbus-1-dev
|
||||
- run: cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml
|
||||
@@ -57,5 +57,9 @@ jobs:
|
||||
with:
|
||||
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
|
||||
@@ -132,9 +132,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
|
||||
|
||||
@@ -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/
|
||||
@@ -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, скрипты установки и обновления |
|
||||
|
||||
+208
-4
@@ -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:
|
||||
@@ -2298,6 +2467,9 @@ components:
|
||||
UpdateUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
description: Новый логин. Может менять только администратор, в том числе у себя
|
||||
display_name:
|
||||
type: string
|
||||
maxLength: 64
|
||||
@@ -3320,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]
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
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 = '0.0.0';
|
||||
try {
|
||||
const tag = execFileSync('git', ['describe', '--tags', '--abbrev=0'], { encoding: 'utf8' }).trim();
|
||||
version = tag.replace(/^v/, '').replace(/-rc\.\d+$/, '');
|
||||
} catch {
|
||||
// local untagged build
|
||||
}
|
||||
conf.version = version;
|
||||
writeFileSync(resolve(root, 'src-tauri/tauri.release.json'), JSON.stringify(conf, null, 2));
|
||||
console.log(`desktop version ${version}`);
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"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",
|
||||
"tauri": "tauri",
|
||||
"icons": "tauri icon ../web/public/favicon.svg"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.9.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
[package]
|
||||
name = "pipu-desktop"
|
||||
version = "0.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "pipu_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[[bin]]
|
||||
name = "pipu-desktop"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon"] }
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["net", "sync", "time"] }
|
||||
axum = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
url = "2"
|
||||
souvlaki = "0.8.3"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["windows-native"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["sync-secret-service", "crypto-rust"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
keyring = { version = "3.6.3", features = ["apple-native"] }
|
||||
|
||||
[profile.release]
|
||||
strip = true
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "main",
|
||||
"description": "Only the bundled main window may invoke desktop integration.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default", "core:event:default"]
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -0,0 +1,157 @@
|
||||
mod media;
|
||||
mod proxy;
|
||||
mod store;
|
||||
|
||||
use media::Media;
|
||||
use proxy::Proxy;
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{
|
||||
image::Image,
|
||||
include_image,
|
||||
menu::{Menu, MenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
AppHandle, Manager, State,
|
||||
};
|
||||
|
||||
struct AppState {
|
||||
proxy: Proxy,
|
||||
media: Media,
|
||||
server: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Bootstrap {
|
||||
proxy_base: String,
|
||||
server: Option<String>,
|
||||
}
|
||||
|
||||
fn show_main(app: &AppHandle) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn bootstrap(app: AppHandle, state: State<AppState>) -> Result<Bootstrap, String> {
|
||||
Ok(Bootstrap {
|
||||
proxy_base: state.proxy.base.clone(),
|
||||
server: store::load_server(&app)?,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_server(app: AppHandle, state: State<'_, AppState>, url: String) -> Result<String, String> {
|
||||
let server = proxy::validate_server(&url)?;
|
||||
state.proxy.check(&server).await?;
|
||||
store::save_server(&app, Some(&server))?;
|
||||
*state.proxy.server.write().map_err(|e| e.to_string())? = Some(server.clone());
|
||||
*state.server.lock().map_err(|e| e.to_string())? = Some(server.clone());
|
||||
Ok(server)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn clear_server(app: AppHandle, state: State<AppState>) -> Result<(), String> {
|
||||
store::save_server(&app, None)?;
|
||||
store::save_user(&app, None)?;
|
||||
store::save_refresh(None)?;
|
||||
*state.proxy.server.write().map_err(|e| e.to_string())? = None;
|
||||
*state.server.lock().map_err(|e| e.to_string())? = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_get() -> Result<Option<String>, String> {
|
||||
store::load_refresh()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_set(token: String) -> Result<(), String> {
|
||||
store::save_refresh(Some(&token))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn token_clear() -> Result<(), String> {
|
||||
store::save_refresh(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_get(app: AppHandle) -> Result<Option<String>, String> {
|
||||
store::load_user(&app)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_set(app: AppHandle, json: String) -> Result<(), String> {
|
||||
store::save_user(&app, Some(&json))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn user_clear(app: AppHandle) -> Result<(), String> {
|
||||
store::save_user(&app, None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn media_set(
|
||||
state: State<AppState>,
|
||||
title: String,
|
||||
artist: String,
|
||||
album: String,
|
||||
cover_url: Option<String>,
|
||||
playing: bool,
|
||||
) -> Result<(), String> {
|
||||
state.media.set(&title, &artist, &album, cover_url.as_deref(), playing)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
|
||||
.plugin(tauri_plugin_window_state::Builder::default().build())
|
||||
.setup(|app| {
|
||||
let saved = store::load_server(app.handle())?;
|
||||
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
|
||||
let media = Media::new();
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = media.attach(app.handle().clone(), &window);
|
||||
}
|
||||
app.manage(AppState {
|
||||
proxy,
|
||||
media,
|
||||
server: Mutex::new(saved),
|
||||
});
|
||||
|
||||
let open = MenuItem::with_id(app, "open", "Открыть", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Выход", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(app, &[&open, &quit])?;
|
||||
let icon: Image = include_image!("icons/32x32.png");
|
||||
TrayIconBuilder::new()
|
||||
.icon(icon)
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(|app, event| match event.id.as_ref() {
|
||||
"open" => show_main(app),
|
||||
"quit" => app.exit(0),
|
||||
_ => {}
|
||||
})
|
||||
.on_tray_icon_event(|tray, event| {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
..
|
||||
} = event
|
||||
{
|
||||
show_main(tray.app_handle());
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("desktop app failed to start");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
pipu_desktop_lib::run();
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use souvlaki::{MediaControlEvent, MediaControls, MediaMetadata, MediaPlayback, PlatformConfig};
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Emitter, WebviewWindow};
|
||||
|
||||
pub struct Media {
|
||||
controls: Mutex<Option<MediaControls>>,
|
||||
}
|
||||
|
||||
impl Media {
|
||||
pub fn new() -> Self {
|
||||
Self { controls: Mutex::new(None) }
|
||||
}
|
||||
|
||||
pub fn attach(&self, app: AppHandle, window: &WebviewWindow) -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let hwnd = window.hwnd().ok().map(|h| h.0 as *mut std::ffi::c_void);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
let hwnd = {
|
||||
let _ = window;
|
||||
None
|
||||
};
|
||||
let mut controls = MediaControls::new(PlatformConfig {
|
||||
dbus_name: "app.pipu.music.desktop",
|
||||
display_name: "Pipu Music",
|
||||
hwnd,
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let handle = app.clone();
|
||||
controls
|
||||
.attach(move |event| {
|
||||
let name = match event {
|
||||
MediaControlEvent::Play => "play",
|
||||
MediaControlEvent::Pause => "pause",
|
||||
MediaControlEvent::Toggle => "toggle",
|
||||
MediaControlEvent::Next => "next",
|
||||
MediaControlEvent::Previous => "previous",
|
||||
MediaControlEvent::Stop => "stop",
|
||||
_ => return,
|
||||
};
|
||||
let _ = handle.emit("media", name);
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
*self.controls.lock().map_err(|e| e.to_string())? = Some(controls);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set(&self, title: &str, artist: &str, album: &str, cover_url: Option<&str>, playing: bool) -> Result<(), String> {
|
||||
let mut slot = self.controls.lock().map_err(|e| e.to_string())?;
|
||||
let Some(controls) = slot.as_mut() else { return Ok(()); };
|
||||
controls
|
||||
.set_metadata(MediaMetadata {
|
||||
title: Some(title),
|
||||
artist: Some(artist),
|
||||
album: Some(album),
|
||||
cover_url,
|
||||
duration: None,
|
||||
})
|
||||
.map_err(|e| format!("{e:?}"))?;
|
||||
let playback = if playing { MediaPlayback::Playing { progress: None } } else { MediaPlayback::Paused { progress: None } };
|
||||
controls.set_playback(playback).map_err(|e| format!("{e:?}"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Loopback-only streaming transport. The random per-process path is a capability;
|
||||
//! it never leaves the app except for OS media artwork requests. No open proxy.
|
||||
use axum::{body::Body, extract::{Request, State}, http::{HeaderMap, HeaderValue, Method, StatusCode}, response::{IntoResponse, Response}, Router};
|
||||
use serde_json::Value;
|
||||
use std::{sync::{Arc, RwLock}, time::Duration};
|
||||
use url::Url;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Proxy {
|
||||
pub server: Arc<RwLock<Option<String>>>,
|
||||
pub base: String,
|
||||
secret: String,
|
||||
authority: String,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
pub fn validate_server(raw: &str) -> Result<String, String> {
|
||||
let url = Url::parse(raw.trim()).map_err(|_| "Invalid server URL")?;
|
||||
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none()
|
||||
|| !url.username().is_empty() || url.password().is_some()
|
||||
|| url.query().is_some() || url.fragment().is_some()
|
||||
{ return Err("Use an HTTP(S) server address without credentials, query or fragment".into()); }
|
||||
Ok(url.as_str().trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
impl Proxy {
|
||||
pub async fn start(server: Option<String>) -> Result<Self, String> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.map_err(|e| e.to_string())?;
|
||||
let authority = listener.local_addr().map_err(|e| e.to_string())?.to_string();
|
||||
let secret = uuid::Uuid::new_v4().simple().to_string();
|
||||
let proxy = Self {
|
||||
server: Arc::new(RwLock::new(server)),
|
||||
base: format!("http://{authority}/{secret}"), secret, authority,
|
||||
client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.connect_timeout(Duration::from_secs(10))
|
||||
.read_timeout(Duration::from_secs(60))
|
||||
.build().map_err(|e| e.to_string())?,
|
||||
};
|
||||
let router = Router::new().fallback(forward).with_state(proxy.clone());
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = axum::serve(listener, router).await { eprintln!("Desktop transport stopped: {e}"); }
|
||||
});
|
||||
Ok(proxy)
|
||||
}
|
||||
|
||||
pub async fn check(&self, server: &str) -> Result<(), String> {
|
||||
let response = self.client.get(format!("{server}/api/v1/server-info"))
|
||||
.timeout(Duration::from_secs(10)).send().await.map_err(|_| "Server unavailable")?;
|
||||
if !response.status().is_success() { return Err("Server unavailable".into()); }
|
||||
let info: Value = response.json().await.map_err(|_| "Not a Pipu server")?;
|
||||
if info.get("api_version").is_none() || info.get("name").is_none() { return Err("Not a Pipu server".into()); }
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn allowed_origin(origin: &str) -> bool {
|
||||
matches!(origin, "tauri://localhost" | "http://tauri.localhost")
|
||||
|| (cfg!(debug_assertions) && origin == "http://127.0.0.1:5173")
|
||||
}
|
||||
|
||||
// Signed media and tus URLs are relative in the API. Rewrite only API URLs from
|
||||
// the configured server; never route arbitrary URLs supplied in server content.
|
||||
fn local_url(value: &str, server: &str, base: &str) -> Option<String> {
|
||||
if value.starts_with("/api/") { return Some(format!("{base}{value}")); }
|
||||
value.strip_prefix(server).filter(|p| p.starts_with("/api/"))
|
||||
.map(|p| format!("{base}{p}"))
|
||||
}
|
||||
|
||||
fn rewrite_json(value: &mut Value, server: &str, base: &str) {
|
||||
match value {
|
||||
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_json(v, server, base)),
|
||||
Value::Object(fields) => for (key, v) in fields {
|
||||
if matches!(key.as_str(), "stream_url" | "cover_url" | "tus_url" | "download_url") {
|
||||
if let Some(url) = v.as_str().and_then(|s| local_url(s, server, base)) { *v = Value::String(url); }
|
||||
} else { rewrite_json(v, server, base); }
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
|
||||
if let Some(origin) = origin {
|
||||
headers.insert("access-control-allow-origin", HeaderValue::from_str(origin).unwrap());
|
||||
headers.insert("vary", HeaderValue::from_static("Origin"));
|
||||
}
|
||||
headers.insert("access-control-allow-methods", HeaderValue::from_static("GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS"));
|
||||
headers.insert("access-control-allow-headers", HeaderValue::from_static("authorization, content-type, range, tus-resumable, upload-length, upload-offset, upload-metadata, upload-defer-length, x-requested-with"));
|
||||
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after"));
|
||||
}
|
||||
|
||||
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
|
||||
let origin = request.headers().get("origin").and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
if request.headers().get("host").and_then(|v| v.to_str().ok()) != Some(&proxy.authority)
|
||||
|| origin.as_deref().is_some_and(|s| !allowed_origin(s))
|
||||
{ return StatusCode::FORBIDDEN.into_response(); }
|
||||
let prefix = format!("/{}/api/", proxy.secret);
|
||||
let path = request.uri().path().to_owned();
|
||||
let Some(tail) = path.strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||
let tail = tail.to_owned();
|
||||
// Prevent URL normalization escaping the configured API path, including encoded segments.
|
||||
if tail.split('/').any(|s| s == "." || s == "..") || tail.to_lowercase().contains("%2e")
|
||||
|| tail.contains('\\') || tail.to_lowercase().contains("%5c") || tail.to_lowercase().contains("%2f")
|
||||
{ return StatusCode::BAD_REQUEST.into_response(); }
|
||||
if origin.is_none() && !matches!(*request.method(), Method::GET | Method::HEAD) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
let mut response = if request.method() == Method::OPTIONS {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
match transfer(&proxy, request, tail).await {
|
||||
Ok(response) => response,
|
||||
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||
}
|
||||
};
|
||||
cors(response.headers_mut(), origin.as_deref());
|
||||
response
|
||||
}
|
||||
|
||||
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||
let url = format!("{server}/api/{tail}{query}");
|
||||
let (parts, body) = request.into_parts();
|
||||
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||
}
|
||||
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||
let status = upstream.status();
|
||||
let mut headers = HeaderMap::new();
|
||||
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||
}
|
||||
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||
}
|
||||
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||
let body = if json && parts.method != Method::HEAD {
|
||||
// Bound only JSON, never buffer a song or an upload in memory.
|
||||
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||
rewrite_json(&mut value, &server, &proxy.base);
|
||||
headers.remove("content-length");
|
||||
headers.remove("etag");
|
||||
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||
let mut response = Response::new(body);
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = headers;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_server_and_scopes_media_urls() {
|
||||
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
const SERVICE: &str = "app.pipu.music.desktop";
|
||||
|
||||
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let path = config_dir(app)?.join("server.txt");
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => {
|
||||
let s = s.trim().to_string();
|
||||
Ok((!s.is_empty()).then_some(s))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||
let path = config_dir(app)?.join("server.txt");
|
||||
match server {
|
||||
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||
match refresh_entry()?.get_password() {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(keyring::Error::NoEntry) => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||
let entry = refresh_entry()?;
|
||||
match token {
|
||||
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||
None => match entry.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(keyring::Error::NoEntry) => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||
let path = config_dir(app)?.join("user.json");
|
||||
match fs::read_to_string(path) {
|
||||
Ok(s) => Ok(Some(s)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||
let path = config_dir(app)?.join("user.json");
|
||||
match json {
|
||||
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||
None => match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(e.to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Pipu Music",
|
||||
"version": "0.0.0",
|
||||
"identifier": "app.pipu.music.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm --filter @pipu/web dev",
|
||||
"devUrl": "http://127.0.0.1:5173",
|
||||
"beforeBuildCommand": "pnpm --filter @pipu/web build",
|
||||
"frontendDist": "../../web/dist"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["nsis", "appimage", "deb", "rpm"],
|
||||
"category": "Music",
|
||||
"shortDescription": "Self-hosted music player",
|
||||
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
||||
"linux": {
|
||||
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
||||
"appimage": { "bundleMediaFramework": true }
|
||||
},
|
||||
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { unwrap, type Track } from '@pipu/core';
|
||||
import { 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} />;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,8 +59,13 @@ 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>
|
||||
<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' }}>
|
||||
{prefs.mobile_player === 'wave' ? (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
||||
import { downloads } from '../offline/downloads';
|
||||
import { PersistentEvents } from '../offline/events';
|
||||
import { offlineWave } from './offlineWave';
|
||||
import { eventId } from './eventId';
|
||||
|
||||
export { offlineWave };
|
||||
|
||||
@@ -113,7 +114,7 @@ class Controller {
|
||||
this.server = server;
|
||||
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||
const sink = this.persistent;
|
||||
this.events = sink ? new EventBuffer({ send: sink.send }) : null;
|
||||
this.events = sink ? new EventBuffer({ send: sink.send }, Date.now, undefined, eventId) : null;
|
||||
void sink?.drain();
|
||||
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||
if (st.isConnected) void this.persistent?.drain();
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { uuidv7 } from '@pipu/core';
|
||||
|
||||
/** Hermes has no Web Crypto. Expo's installed native runtime supplies secure UUID randomness. */
|
||||
export function eventId(now: number): string {
|
||||
return uuidv7(now, () => {
|
||||
const hex = globalThis.expo.uuidv4().replace(/-/g, '');
|
||||
return Uint8Array.from(hex.match(/../g)!, (byte) => parseInt(byte, 16));
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { EventBuffer } from '@pipu/core';
|
||||
import { eventId } from '../src/player/eventId';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('queues restored-track events on Hermes without Web Crypto', () => {
|
||||
vi.stubGlobal('crypto', undefined);
|
||||
const nativeUuid = vi.fn()
|
||||
.mockReturnValueOnce('00000000-0000-4fff-bfff-ffffffffffff')
|
||||
.mockReturnValueOnce('00000000-0000-4fff-bfff-fffffffffffe');
|
||||
vi.stubGlobal('expo', { uuidv4: nativeUuid });
|
||||
const buffer = new EventBuffer({ send: vi.fn() }, () => 0x0190_0000_0000, undefined, eventId);
|
||||
buffer.push({ type: 'play_start', track_id: 'restored-track' });
|
||||
buffer.push({ type: 'skip', track_id: 'restored-track' });
|
||||
expect(buffer.pending[0]?.id).toBe('01900000-0000-7fff-bfff-ffffffffffff');
|
||||
expect(buffer.pending[1]?.id).not.toBe(buffer.pending[0]?.id);
|
||||
expect(nativeUuid).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -0,0 +1,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') });
|
||||
});
|
||||
}
|
||||
@@ -154,6 +154,39 @@ describe('users admin', () => {
|
||||
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each(['root', 'anna'])('lets admins change the username of %s', async (target) => {
|
||||
const root = makeUser('root', 'admin');
|
||||
const anna = makeUser('anna', 'user');
|
||||
const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||
await user.click(await screen.findByRole('button', { name: `Действия с ${target}` }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||
const input = within(dialog).getByLabelText('Логин');
|
||||
await user.clear(input);
|
||||
await user.type(input, `new.${target}`);
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument());
|
||||
expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument();
|
||||
expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`);
|
||||
if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } });
|
||||
});
|
||||
|
||||
it('keeps the username dialog open when the new username is taken', async () => {
|
||||
const root = makeUser('root', 'admin');
|
||||
const anna = makeUser('anna', 'user');
|
||||
const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
|
||||
await user.click(await screen.findByRole('button', { name: 'Действия с anna' }));
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' }));
|
||||
const dialog = screen.getByRole('dialog', { name: 'Изменить логин' });
|
||||
const input = within(dialog).getByLabelText('Логин');
|
||||
await user.clear(input);
|
||||
await user.type(input, 'ROOT');
|
||||
await user.click(within(dialog).getByRole('button', { name: 'Сохранить' }));
|
||||
await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true'));
|
||||
expect(api.users.get(anna.id)?.username).toBe('anna');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('offers moderators only what they may do', async () => {
|
||||
const mod = makeUser('mod', 'moderator');
|
||||
const anna = makeUser('anna', 'user');
|
||||
@@ -248,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 });
|
||||
@@ -350,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('Другое устройство');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { createDesktop, isDesktop } from './desktop';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('desktop bridge', () => {
|
||||
it('is inactive in the browser', () => {
|
||||
expect(isDesktop()).toBe(false);
|
||||
});
|
||||
|
||||
it('bootstraps through the Tauri invoke surface', async () => {
|
||||
const invoke = vi.fn(async (cmd: string) => {
|
||||
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||
return null;
|
||||
});
|
||||
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||
const desktop = await createDesktop();
|
||||
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||
expect(desktop?.server).toBe('https://music.example');
|
||||
await desktop?.setServer('https://music.example/pipu');
|
||||
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||
|
||||
type Tauri = {
|
||||
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||
};
|
||||
|
||||
function api(): Tauri | undefined {
|
||||
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||
}
|
||||
|
||||
export function isDesktop(): boolean {
|
||||
return api() !== undefined;
|
||||
}
|
||||
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const tauri = api();
|
||||
if (!tauri) throw new Error('Tauri API unavailable');
|
||||
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||
}
|
||||
|
||||
export type DesktopHandle = {
|
||||
proxyBase: string;
|
||||
server: string | null;
|
||||
setServer: (url: string) => Promise<string>;
|
||||
clearServer: () => Promise<void>;
|
||||
refreshStore: RefreshStore;
|
||||
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||
};
|
||||
|
||||
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||
|
||||
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||
return createElement(DesktopContext.Provider, { value }, children);
|
||||
}
|
||||
|
||||
export function useDesktop(): DesktopHandle | null {
|
||||
return useContext(DesktopContext);
|
||||
}
|
||||
|
||||
let active: DesktopHandle | null = null;
|
||||
|
||||
export function desktopHandle(): DesktopHandle | null {
|
||||
return active;
|
||||
}
|
||||
|
||||
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||
if (!isDesktop()) return null;
|
||||
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||
const handle: DesktopHandle = {
|
||||
proxyBase: boot.proxyBase,
|
||||
server: boot.server,
|
||||
setServer: async (url) => {
|
||||
const server = await invoke<string>('set_server', { url });
|
||||
handle.server = server;
|
||||
return server;
|
||||
},
|
||||
clearServer: async () => {
|
||||
await invoke('clear_server');
|
||||
handle.server = null;
|
||||
},
|
||||
refreshStore: {
|
||||
get: () => invoke<string | null>('token_get'),
|
||||
set: (token) => invoke('token_set', { token }),
|
||||
clear: async () => {
|
||||
await invoke('token_clear');
|
||||
await invoke('user_clear');
|
||||
},
|
||||
getUser: async () => {
|
||||
const json = await invoke<string | null>('user_get');
|
||||
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||
},
|
||||
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||
},
|
||||
listenMedia: async (handler) => {
|
||||
const tauri = api();
|
||||
if (!tauri) return () => undefined;
|
||||
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||
},
|
||||
setMedia: (info) => {
|
||||
void invoke('media_set', {
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
album: info.album,
|
||||
coverUrl: info.coverUrl ?? null,
|
||||
playing: info.playing,
|
||||
});
|
||||
},
|
||||
};
|
||||
active = handle;
|
||||
return handle;
|
||||
}
|
||||
@@ -277,4 +277,5 @@
|
||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||
.end { gap: 4px; }
|
||||
.like { opacity: 1; width: 44px; height: 44px; }
|
||||
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
|
||||
}
|
||||
@@ -1,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"
|
||||
|
||||
@@ -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)} />}
|
||||
</>;
|
||||
}
|
||||
+11
-2
@@ -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}>
|
||||
<DesktopProvider value={desktop}>
|
||||
<App session={session} router={router} />
|
||||
</DesktopProvider>
|
||||
</I18nextProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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,26 +30,26 @@ 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>
|
||||
{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}>
|
||||
<li>{t('app.step_install')}</li>
|
||||
{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')}>
|
||||
@@ -55,12 +57,32 @@ export function AppDownloadPage() {
|
||||
<Icon src={copied ? checkIcon : copyIcon} size={16} />
|
||||
</button>
|
||||
</li>
|
||||
<li>{t('app.step_updates')}</li>
|
||||
{detected && <li>{t(`app.step_updates.${detected}`)}</li>}
|
||||
</ol>
|
||||
)}
|
||||
{primary?.sha256 && (
|
||||
<details className={s.details}>
|
||||
<summary>{t('app.checksum')}</summary>
|
||||
<code>{r.sha256}</code>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from '@tanstack/react-router';
|
||||
|
||||
import { useDesktop } from '../desktop';
|
||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||
import { AuthLayout } from './AuthLayout';
|
||||
import s from './auth.module.css';
|
||||
|
||||
export function ConnectPage() {
|
||||
const { t } = useTranslation();
|
||||
const desktop = useDesktop();
|
||||
const router = useRouter();
|
||||
const [address, setAddress] = useState(desktop?.server ?? '');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!desktop) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await desktop.setServer(address);
|
||||
await router.invalidate();
|
||||
await router.navigate({ to: '/login' });
|
||||
} catch (err) {
|
||||
const text = err instanceof Error ? err.message : '';
|
||||
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthLayout>
|
||||
<h1>{t('server.title')}</h1>
|
||||
<p className={s.lead}>{t('app.tagline')}</p>
|
||||
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||
<Field
|
||||
label={t('server.address')}
|
||||
value={address}
|
||||
onChange={setAddress}
|
||||
autoComplete="url"
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
required
|
||||
/>
|
||||
<FormError>{error}</FormError>
|
||||
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
|
||||
{t('mobile.connect')}
|
||||
</Button>
|
||||
</form>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../app/session';
|
||||
import { Cover, TrackList } from '../entities/catalog';
|
||||
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||||
import { usePlayer } from '../player/context';
|
||||
import { useErrorText } from '../shared/lib/errors';
|
||||
import { formatDuration } from '../shared/lib/format';
|
||||
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||
import c from './catalog.module.css';
|
||||
import s from './page.module.css';
|
||||
import p from './playlists.module.css';
|
||||
|
||||
export function PlaylistsPage() {
|
||||
const { t } = useTranslation();
|
||||
const list = usePlaylists();
|
||||
const errorText = useErrorText();
|
||||
const navigate = useNavigate();
|
||||
const [create, setCreate] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? [];
|
||||
return <div className={s.page}>
|
||||
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||||
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||||
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
||||
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||
<Cover album={item} size="100%" />
|
||||
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||
{item.description && <span className={p.description}>{item.description}</span>}
|
||||
</Link>)}</div>
|
||||
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function PlaylistPage({ id }: { id: string }) {
|
||||
const { t } = useTranslation();
|
||||
const query = usePlaylist(id);
|
||||
const errorText = useErrorText();
|
||||
return <div className={s.page}>
|
||||
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||
const { t } = useTranslation();
|
||||
const session = useSession();
|
||||
const cache = useQueryClient();
|
||||
const player = usePlayer();
|
||||
const navigate = useNavigate();
|
||||
const errorText = useErrorText();
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [remove, setRemove] = useState(false);
|
||||
const [manage, setManage] = useState(false);
|
||||
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||||
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||||
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
||||
})), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); },
|
||||
onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })),
|
||||
onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } });
|
||||
const play = (shuffle: boolean) => {
|
||||
const tracks = playlist.tracks.filter(x => x.available);
|
||||
if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; }
|
||||
if (tracks.length) player.playList(tracks, 0, context);
|
||||
};
|
||||
const move = (index: number, delta: number) => {
|
||||
const ids = playlist.tracks.map(x => x.id);
|
||||
[ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!];
|
||||
save.mutate(ids);
|
||||
};
|
||||
return <>
|
||||
<header className={c.colHero}>
|
||||
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
||||
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||||
<p className={p.description}>{playlist.description}</p>
|
||||
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||||
</div>
|
||||
</header>
|
||||
<div className={s.toolbar}>
|
||||
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||
</div>
|
||||
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
||||
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||
actions={manage ? (track, i) => <span className={p.actions}>
|
||||
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
||||
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||
</span> : undefined} />
|
||||
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
||||
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { 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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type Entry = components['schemas']['AuditEntry'];
|
||||
function details(e: Entry, roleName: (r: string) => string): string {
|
||||
const p = e.payload ?? {};
|
||||
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
|
||||
if (e.action === 'user.username_change') return `${String(p.from)} → ${String(p.to)}`;
|
||||
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
|
||||
(v): v is string => typeof v === 'string' && v !== '',
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ export function UsersPage() {
|
||||
const [role, setRole] = useState<Role | null>(null);
|
||||
const query = useDebounced(q.trim(), 200);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState<AdminUser | null>(null);
|
||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
||||
const [actionError, setActionError] = useState<unknown>(null);
|
||||
@@ -112,6 +113,8 @@ export function UsersPage() {
|
||||
const actionsFor = (u: AdminUser): MenuItem[] => {
|
||||
const items: MenuItem[] = [];
|
||||
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
||||
if (me.role === 'admin')
|
||||
items.push({ label: t('users.change_username'), onSelect: () => setRenaming(u) });
|
||||
if (can('reset_password'))
|
||||
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
||||
if (can('reset_recommendations'))
|
||||
@@ -250,6 +253,18 @@ export function UsersPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{renaming && (
|
||||
<ChangeUsernameDialog
|
||||
user={renaming}
|
||||
onClose={() => setRenaming(null)}
|
||||
onSaved={async (updated) => {
|
||||
if (updated.id === me.id) session.setUser({ ...me, username: updated.username });
|
||||
setRenaming(null);
|
||||
await refresh();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{confirm && (
|
||||
<Dialog
|
||||
title={t(`users.${TITLES[confirm.kind]}`)}
|
||||
@@ -368,6 +383,40 @@ function CreateUserDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ChangeUsernameDialog({ user, onClose, onSaved }: {
|
||||
user: AdminUser;
|
||||
onClose: () => void;
|
||||
onSaved: (user: AdminUser) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const session = useSession();
|
||||
const errorText = useErrorText();
|
||||
const fieldError = useFieldError();
|
||||
const [username, setUsername] = useState(user.username);
|
||||
const save = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', {
|
||||
params: { path: { id: user.id } }, body: { username: username.trim() },
|
||||
})),
|
||||
onSuccess: onSaved,
|
||||
});
|
||||
const error = fieldError(save.error, 'username');
|
||||
return (
|
||||
<Dialog title={t('users.change_username')} onClose={onClose} footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={onClose}>{t('common:form.cancel')}</Button>
|
||||
<Button type="submit" form="change-username" variant="primary" busy={save.isPending}
|
||||
disabled={!username.trim() || username.trim() === user.username}>{t('common:settings.save')}</Button>
|
||||
</>
|
||||
}>
|
||||
<form id="change-username" className={s.sessions} noValidate onSubmit={(e) => { e.preventDefault(); save.mutate(); }}>
|
||||
<Field label={t('create.username')} value={username} onChange={setUsername} autoCapitalize="none"
|
||||
spellCheck={false} hint={t('create.username_hint')} error={error} mono />
|
||||
<FormError>{save.error && !error ? errorText(save.error) : null}</FormError>
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
|
||||
const { t } = useTranslation(['admin', 'common']);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
||||
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
|
||||
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
|
||||
.card strong { overflow-wrap: anywhere; }
|
||||
.card span { font-size: 13px; }
|
||||
.card:hover { background: #ffffff10; }
|
||||
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||
.actions { display: flex; align-items: center; }
|
||||
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
||||
.actions button:disabled { opacity: .3; cursor: default; }
|
||||
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
type Track,
|
||||
} from '@pipu/core';
|
||||
|
||||
import { desktopHandle } from '../desktop';
|
||||
|
||||
export type { PlayContext, Track };
|
||||
|
||||
export interface WaveInfo {
|
||||
@@ -97,6 +99,7 @@ export class Player {
|
||||
private timers: ReturnType<typeof setInterval>[] = [];
|
||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||
private waveLoading = false;
|
||||
private restoreVersion = 0;
|
||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||
onReaction: (() => void) | null = null;
|
||||
|
||||
@@ -120,6 +123,7 @@ export class Player {
|
||||
};
|
||||
this.bindAudio();
|
||||
this.bindMediaSession();
|
||||
this.bindDesktopMedia();
|
||||
}
|
||||
|
||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||
@@ -136,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() {
|
||||
@@ -159,6 +167,7 @@ export class Player {
|
||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||
const target = tracks[index];
|
||||
if (!target || !playable.length) return;
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
this.context = context;
|
||||
@@ -171,6 +180,7 @@ export class Player {
|
||||
|
||||
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||
this.restoreVersion++;
|
||||
this.finishAttempt('skip');
|
||||
this.leaveWave();
|
||||
const res = unwrap(
|
||||
@@ -275,7 +285,7 @@ export class Player {
|
||||
|
||||
play() {
|
||||
if (!this.queue.current) return;
|
||||
if (!this.audio.src) {
|
||||
if (!this.audio.src || !this.attempt) {
|
||||
void this.load(true, this.state.positionMs);
|
||||
return;
|
||||
}
|
||||
@@ -388,9 +398,12 @@ export class Player {
|
||||
private async load(autoplay: boolean, startMs = 0) {
|
||||
const track = this.queue.current;
|
||||
if (!track?.stream_url) return;
|
||||
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
this.audio.src = track.stream_url;
|
||||
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
||||
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||
// Keep the restored element's buffer when the user presses Play.
|
||||
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||
this.updateMediaMetadata(track);
|
||||
if (!autoplay) return;
|
||||
this.applyLoudness();
|
||||
this.attempt = new PlayAttempt(track, startMs);
|
||||
this.preloaded = null;
|
||||
@@ -401,7 +414,6 @@ export class Player {
|
||||
context_type: this.context?.type,
|
||||
context_id: this.context?.id,
|
||||
});
|
||||
this.updateMediaMetadata(track);
|
||||
this.saveServer();
|
||||
void this.refillWave();
|
||||
if (autoplay) {
|
||||
@@ -597,22 +609,28 @@ export class Player {
|
||||
if (a.ended) return;
|
||||
this.update({ playing: false });
|
||||
const t = this.queue.current;
|
||||
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||
if (t && this.attempt) {
|
||||
this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||
this.saveServer();
|
||||
}
|
||||
});
|
||||
a.addEventListener('waiting', () => {
|
||||
if (this.attempt) this.update({ loading: true });
|
||||
});
|
||||
a.addEventListener('waiting', () => this.update({ loading: true }));
|
||||
a.addEventListener('timeupdate', () => {
|
||||
const ms = Math.round(a.currentTime * 1000);
|
||||
this.attempt?.tick(ms);
|
||||
this.update({ positionMs: ms });
|
||||
this.updatePositionState();
|
||||
if (this.state.playing) {
|
||||
this.maybePreload(ms);
|
||||
this.maybeCrossfade(ms);
|
||||
}
|
||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||
this.saveLocal();
|
||||
});
|
||||
a.addEventListener('ended', () => {
|
||||
if (this.ignoreEnded || this.fading) return;
|
||||
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||
this.finishAttempt('play_end');
|
||||
if (this.queue.next(true)) {
|
||||
this.syncQueue();
|
||||
@@ -629,11 +647,18 @@ export class Player {
|
||||
private async onError() {
|
||||
const track = this.queue.current;
|
||||
if (!track) return;
|
||||
// A speculative load must never start playback or skip the saved track.
|
||||
if (!this.attempt) {
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
return;
|
||||
}
|
||||
if (this.refreshedFor !== track.id) {
|
||||
this.refreshedFor = track.id;
|
||||
try {
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||
const fresh = res.items[0];
|
||||
if (this.queue.current !== track || !this.attempt) return;
|
||||
if (fresh?.stream_url) {
|
||||
Object.assign(track, fresh);
|
||||
void this.load(true, this.state.positionMs);
|
||||
@@ -660,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;
|
||||
@@ -775,6 +827,7 @@ export class Player {
|
||||
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||
*/
|
||||
async restore() {
|
||||
const version = ++this.restoreVersion;
|
||||
let local: Saved | null = null;
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_KEY);
|
||||
@@ -804,8 +857,9 @@ export class Player {
|
||||
} catch {
|
||||
// офлайн — остаёмся с локальной копией
|
||||
}
|
||||
if (!saved?.queue.length) return;
|
||||
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||
if (version !== this.restoreVersion) return;
|
||||
const wanted = saved.queue[saved.index];
|
||||
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||
if (!tracks.length) return;
|
||||
@@ -819,14 +873,24 @@ export class Player {
|
||||
this.syncQueue();
|
||||
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||
this.updateMediaMetadata(tracks[idx]!);
|
||||
// Prepare at the saved position without creating a listening attempt.
|
||||
await this.load(false, pos);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this.restoreVersion++;
|
||||
this.fading = false;
|
||||
this.finishAttempt('skip');
|
||||
this.audio.pause();
|
||||
this.audio.removeAttribute('src');
|
||||
this.audio.load();
|
||||
this.overlap.pause();
|
||||
this.overlap.removeAttribute('src');
|
||||
this.overlap.load();
|
||||
for (const t of this.timers) clearInterval(t);
|
||||
this.timers = [];
|
||||
this.desktopUnlisten?.();
|
||||
this.desktopUnlisten = null;
|
||||
void this.events.flush();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { AuthSession, Track } from '@pipu/core';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Player } from './player';
|
||||
|
||||
const track: Track = {
|
||||
id: 'a', title: 'A', duration_ms: 200_000, artists: [],
|
||||
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||
genres: [], disc_no: null, track_no: null, available: true,
|
||||
stream_url: '/s/a', reaction: null, features: null,
|
||||
};
|
||||
const ok = (data: unknown) => ({ data, response: new Response() });
|
||||
|
||||
function fixture() {
|
||||
localStorage.removeItem('pipu.player');
|
||||
const client = {
|
||||
GET: vi.fn().mockResolvedValue(ok({
|
||||
track_id: 'a', queue: ['a'], queue_index: 0, position_ms: 42_000,
|
||||
volume: 0.8, shuffle: false, repeat_mode: 'off', updated_at: '2026-10-21',
|
||||
})),
|
||||
POST: vi.fn().mockResolvedValue(ok({ items: [track] })),
|
||||
PUT: vi.fn().mockResolvedValue(ok({})),
|
||||
};
|
||||
const audio = document.createElement('audio');
|
||||
const player = new Player({ client, state: { status: 'signed_out' } } as unknown as AuthSession, audio);
|
||||
return { player, audio, client };
|
||||
}
|
||||
|
||||
afterEach(() => { vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
|
||||
|
||||
describe('restored audio preparation', () => {
|
||||
it('buffers at the saved position without playback or listening events, then reuses the source', async () => {
|
||||
const { player, audio, client } = fixture();
|
||||
const play = vi.spyOn(audio, 'play');
|
||||
const source = vi.spyOn(audio, 'src', 'set');
|
||||
await player.restore();
|
||||
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||
expect(audio.currentTime).toBe(42);
|
||||
expect(audio.preload).toBe('auto');
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(client.PUT).not.toHaveBeenCalled();
|
||||
expect(player.getState()).toMatchObject({ playing: false, loading: false, positionMs: 42_000 });
|
||||
player.onHide();
|
||||
expect(client.POST).toHaveBeenCalledTimes(1); // Only the track lookup, no listening events.
|
||||
player.play();
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
expect(source).toHaveBeenCalledTimes(1);
|
||||
expect(player.getState().playing).toBe(true);
|
||||
player.dispose();
|
||||
});
|
||||
|
||||
it('does not autoplay or skip after a preload failure; Play retries the source', async () => {
|
||||
const { player, audio } = fixture();
|
||||
const play = vi.spyOn(audio, 'play');
|
||||
await player.restore();
|
||||
audio.dispatchEvent(new Event('error'));
|
||||
audio.dispatchEvent(new Event('ended'));
|
||||
expect(play).not.toHaveBeenCalled();
|
||||
expect(player.getState()).toMatchObject({ track: { id: 'a' }, positionMs: 42_000, playing: false });
|
||||
player.play();
|
||||
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||
expect(play).toHaveBeenCalledTimes(1);
|
||||
player.dispose();
|
||||
});
|
||||
|
||||
it.each(['selection', 'dispose'])('ignores a restore lookup completed after %s', async (action) => {
|
||||
const { player, client, audio } = fixture();
|
||||
let resolve!: (value: unknown) => void;
|
||||
client.POST.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||
const restoring = player.restore();
|
||||
await vi.waitFor(() => expect(client.POST).toHaveBeenCalled());
|
||||
if (action === 'selection') player.playList([{ ...track, id: 'b', stream_url: '/s/b' }], 0, { type: 'search' });
|
||||
else player.dispose();
|
||||
resolve(ok({ items: [track] }));
|
||||
await restoring;
|
||||
expect(audio.getAttribute('src')).toBe(action === 'selection' ? '/s/b' : null);
|
||||
if (action === 'selection') player.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { detectAppClient, otherAppClients } from './platform';
|
||||
|
||||
describe('detectAppClient', () => {
|
||||
it('picks the native client from the user agent', () => {
|
||||
expect(detectAppClient('Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36')).toBe('android');
|
||||
expect(detectAppClient('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe('windows');
|
||||
expect(detectAppClient('Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)')).toBe('macos');
|
||||
expect(detectAppClient('Mozilla/5.0 (X11; Linux x86_64)')).toBe('linux');
|
||||
expect(detectAppClient('Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)')).toBeNull();
|
||||
});
|
||||
|
||||
it('lists the remaining platforms after the detected one', () => {
|
||||
expect(otherAppClients('windows')).toEqual(['macos', 'linux', 'android']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export type AppClient = 'android' | 'windows' | 'linux' | 'macos';
|
||||
|
||||
const ORDER: AppClient[] = ['windows', 'macos', 'linux', 'android'];
|
||||
|
||||
/** Клиент, который стоит предложить первым по User-Agent. На iOS приложения нет. */
|
||||
export function detectAppClient(ua = typeof navigator === 'undefined' ? '' : navigator.userAgent): AppClient | null {
|
||||
if (/Android/i.test(ua)) return 'android';
|
||||
if (/iPhone|iPad|iPod/i.test(ua)) return null;
|
||||
if (/Windows/i.test(ua)) return 'windows';
|
||||
if (/Mac OS X|Macintosh/i.test(ua)) return 'macos';
|
||||
if (/Linux|X11/i.test(ua)) return 'linux';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function otherAppClients(primary: AppClient | null): AppClient[] {
|
||||
return ORDER.filter((c) => c !== primary);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { shareTrack } from './share';
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe('shareTrack', () => {
|
||||
it('copies on desktop even when native sharing exists', async () => {
|
||||
const share = vi.fn();
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Windows');
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows', share, clipboard: { writeText } });
|
||||
try {
|
||||
expect(await shareTrack('id', 'Title')).toBe('copied');
|
||||
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/track/id`);
|
||||
expect(share).not.toHaveBeenCalled();
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('opens Android sharing with a page URL', async () => {
|
||||
const share = vi.fn().mockResolvedValue(undefined);
|
||||
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||
try {
|
||||
expect(await shareTrack('id', 'Title')).toBe('shared');
|
||||
expect(share).toHaveBeenCalledWith({ title: 'Title', url: `${window.location.origin}/track/id` });
|
||||
} finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('does not copy or report an error when the share sheet is dismissed', async () => {
|
||||
const share = vi.fn().mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
|
||||
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||
try { expect(await shareTrack('id', 'Title')).toBe('cancelled'); }
|
||||
finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
|
||||
it('reports clipboard failures instead of pretending the link was copied', async () => {
|
||||
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Denied')) } });
|
||||
try { await expect(shareTrack('id', 'Title')).rejects.toThrow('Denied'); }
|
||||
finally { vi.unstubAllGlobals(); }
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { trackShareUrl } from '@pipu/core';
|
||||
|
||||
/** Desktop always copies; mobile browsers can open their native share sheet. */
|
||||
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||
const url = trackShareUrl(window.location.origin, id);
|
||||
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
|
||||
try {
|
||||
await navigator.share({ title, url });
|
||||
return 'shared';
|
||||
} catch (error) {
|
||||
if (typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError') return 'cancelled';
|
||||
// If sharing is unavailable, still allow copying the link.
|
||||
}
|
||||
}
|
||||
await navigator.clipboard.writeText(url);
|
||||
return 'copied';
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
|
||||
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
|
||||
export { default as 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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -243,6 +286,13 @@ export function fakeApi(initial: FakeUser[]) {
|
||||
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
|
||||
const target = users.get(userPath[1]!);
|
||||
if (!target) return problem(404, 'common.not_found');
|
||||
if (typeof body?.username === 'string') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
const username = body.username.trim().toLowerCase();
|
||||
if ([...users.values()].some((u) => u.id !== target.id && u.username.toLowerCase() === username))
|
||||
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
|
||||
target.username = username;
|
||||
}
|
||||
if (typeof body?.reviews_uploads === 'boolean') {
|
||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||
target.reviews_uploads = body.reviews_uploads;
|
||||
@@ -253,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]!);
|
||||
@@ -261,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) };
|
||||
}
|
||||
@@ -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() },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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,7 +311,7 @@ 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)); }
|
||||
}
|
||||
@@ -318,3 +319,16 @@ a.source:hover {
|
||||
@media (max-width: 359px) {
|
||||
.now > :first-child { display: none; }
|
||||
}
|
||||
|
||||
.shareStatus {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
right: 8px;
|
||||
max-width: calc(100% - 16px);
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
background: var(--surface3);
|
||||
color: var(--text);
|
||||
box-shadow: var(--shadow);
|
||||
font-size: 13px;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||
import { 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
@@ -5,7 +5,7 @@
|
||||
## Суть
|
||||
|
||||
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
||||
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
||||
|
||||
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
||||
@@ -16,7 +16,7 @@
|
||||
## Доступ и учётные записи
|
||||
|
||||
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
||||
В мобильном приложении на этом экране также указывается **адрес сервера**.
|
||||
В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
|
||||
- **Регистрации нет.** Учётные записи создаёт администрация.
|
||||
- Роли:
|
||||
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
||||
@@ -57,19 +57,19 @@
|
||||
### Левая панель навигации
|
||||
- **Главная**
|
||||
- **Коллекция** — с подкатегориями:
|
||||
- **Плейлисты** — личные подборки треков;
|
||||
- **История** — прослушанные треки;
|
||||
- **Лайки** — понравившиеся треки;
|
||||
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
|
||||
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
|
||||
- **Админка** — видна только администраторам и модераторам.
|
||||
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
|
||||
|
||||
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
|
||||
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
|
||||
|
||||
### Плеер (всегда внизу)
|
||||
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
|
||||
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь.
|
||||
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь.
|
||||
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
|
||||
и даже на другом устройстве можно продолжить с того же места.
|
||||
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
|
||||
|
||||
@@ -15,8 +15,10 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
||||
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
|
||||
| [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 |
|
||||
|
||||
+3
-2
@@ -102,8 +102,9 @@
|
||||
прочитайте заметки к релизу на странице релизов Gitea.
|
||||
|
||||
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
|
||||
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
|
||||
Раздать приложение — ссылка `https://<ваш-домен>/download` (без входа; `/android` перенаправляет сюда):
|
||||
сборка под систему посетителя (APK, Windows, Linux, macOS), остальные платформы ниже, адрес сервера
|
||||
для копирования и короткая инструкция. Та же страница — внизу экрана входа и в меню профиля.
|
||||
|
||||
## 7. Наблюдение
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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 клиент + типы
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
| Создать пользователя с ролью `user` | — | ✅ | ✅ |
|
||||
| Сбросить пароль / рекомендации, заблокировать `user` | — | ✅ | ✅ |
|
||||
| Создать `moderator` / `admin`, менять роли | — | — | ✅ |
|
||||
| Менять логин любого пользователя, включая себя | — | — | ✅ |
|
||||
| Сбросить пароль / рекомендации, заблокировать `moderator` | — | — | ✅ |
|
||||
| Удалить пользователя | — | — | ✅ |
|
||||
| Хранилища, сканирование, переиндексация | — | — | ✅ |
|
||||
|
||||
+21
-2
@@ -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
@@ -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` | |
|
||||
|
||||
@@ -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.
|
||||
|
||||
Готовые файлы с последнего релиза Gitea сервер отдаёт на публичной странице `/download`: посетителю сразу предлагается сборка его ОС, остальные платформы и Android — ниже.
|
||||
@@ -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, как в этой инструкции.
|
||||
|
||||
+7
-3
@@ -29,6 +29,10 @@
|
||||
- Доступ к экранам ограничен через `Stack.Protected` до их отрисовки. Перенаправление из `useEffect`
|
||||
выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с `Error: no session`.
|
||||
Регрессия первого запуска, повторного открытия и защищённой ссылки: `.maestro/startup.yaml`.
|
||||
- События прослушивания используют UUIDv7 со случайными байтами из нативного `expo.uuidv4()`.
|
||||
Web Crypto (`crypto.getRandomValues`) в Hermes отсутствует: браузерный генератор ронял приложение
|
||||
при восстановлении непустой очереди с компьютера, когда TrackPlayer сообщал о выбранном треке.
|
||||
Генератор передаётся в общий `EventBuffer`; веб продолжает использовать Web Crypto.
|
||||
|
||||
## Экраны
|
||||
|
||||
@@ -38,12 +42,12 @@
|
||||
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
|
||||
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
|
||||
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
|
||||
прогресс, лайк / дизлайк, скачать, очередь (нижний лист).
|
||||
прогресс, лайк / дизлайк, скачать, поделиться ссылкой на `/track/{id}`, очередь (нижний лист).
|
||||
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
|
||||
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
|
||||
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
|
||||
выбор занятия / настроения.
|
||||
- **Коллекция**: История, Лайки, Дизлайки, **Скачанное**.
|
||||
- **Коллекция**: кнопка **Плейлисты**, затем История, Лайки, Дизлайки, **Скачанное**. Плейлисты — [playlists.md](playlists.md).
|
||||
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
|
||||
очередь загрузок с пометкой «по Wi-Fi».
|
||||
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
|
||||
@@ -95,7 +99,7 @@
|
||||
|
||||
## Самообновление
|
||||
|
||||
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
||||
Первая установка — публичная страница `/download` веб-клиента (`/android` ведёт туда же): APK, десктопные сборки по ОС, адрес сервера и инструкция.
|
||||
|
||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Личные плейлисты
|
||||
|
||||
Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
|
||||
|
||||
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/v1/me/playlists` — сводки личных плейлистов, последние изменённые первыми.
|
||||
- `POST /api/v1/me/playlists` — создание: `name`, `description`, `track_ids`.
|
||||
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
|
||||
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
|
||||
- `DELETE /api/v1/me/playlists/{id}` — удаление.
|
||||
|
||||
Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками.
|
||||
|
||||
Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы.
|
||||
|
||||
Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
|
||||
|
||||
## Проверки
|
||||
|
||||
- `pnpm --filter @pipu/web test src/App.test.tsx -t playlists`
|
||||
- `pnpm --filter @pipu/web test:e2e e2e/playlists.spec.ts`
|
||||
- `pnpm --filter @pipu/web build`
|
||||
- `pnpm --filter @pipu/mobile typecheck`
|
||||
- В `services/api`: `go test ./internal/playback ./internal/server`
|
||||
- С отдельной тестовой PostgreSQL/pgvector: `TEST_DATABASE_URL=… go test -tags integration ./internal/playback -run TestPlaylistPersistenceAndIsolation -v`. Проверяет сохранение порядка, изоляцию владельцев, откат при несуществующем треке, конфликт конкурентных записей и каскадное удаление членства.
|
||||
+5
-4
@@ -22,8 +22,8 @@
|
||||
```
|
||||
apps/web/src/
|
||||
├── 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`.
|
||||
|
||||
|
||||
@@ -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"];
|
||||
};
|
||||
@@ -1705,6 +1805,8 @@ export interface components {
|
||||
temporary_password: string;
|
||||
};
|
||||
UpdateUserRequest: {
|
||||
/** @description Новый логин. Может менять только администратор, в том числе у себя */
|
||||
username?: string;
|
||||
/** @description Пустая строка — сбросить имя */
|
||||
display_name?: string;
|
||||
role?: components["schemas"]["Role"];
|
||||
@@ -2185,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;
|
||||
@@ -4125,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;
|
||||
@@ -4169,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"];
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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';
|
||||
@@ -188,6 +188,7 @@ export class EventBuffer {
|
||||
private readonly sink: EventSink,
|
||||
private readonly now: () => number = Date.now,
|
||||
private readonly maxRetryMs = 5 * 60_000,
|
||||
private readonly createId: (now: number) => string = uuidv7,
|
||||
) {}
|
||||
|
||||
get pending(): readonly PlayEvent[] {
|
||||
@@ -196,7 +197,7 @@ export class EventBuffer {
|
||||
|
||||
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
|
||||
const t = this.now();
|
||||
this.queue.push({ id: e.id ?? uuidv7(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
|
||||
this.queue.push({ id: e.id ?? this.createId(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
|
||||
if (this.queue.length >= 500) void this.flush();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { trackShareUrl } from './share';
|
||||
|
||||
describe('trackShareUrl', () => {
|
||||
it('builds a page URL without stream credentials', () => {
|
||||
expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Public page URL: never includes playback credentials or stream URLs. */
|
||||
export function trackShareUrl(server: string, trackId: string): string {
|
||||
return new URL(`/track/${encodeURIComponent(trackId)}`, server).href;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"users": {
|
||||
"change_username": "Change username",
|
||||
"title": "Users",
|
||||
"subtitle": "Create users and moderators, manage roles, upload review, reset passwords, block accounts",
|
||||
"create": "Create user",
|
||||
@@ -60,6 +61,7 @@
|
||||
"empty": "No entries yet",
|
||||
"more": "Show more",
|
||||
"actions": {
|
||||
"user.username_change": "Username changed",
|
||||
"user.bootstrap_create": "Initial administrator created",
|
||||
"user.bootstrap_credentials": "Administrator credentials set",
|
||||
"user.create": "User created",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"users": {
|
||||
"change_username": "Изменить логин",
|
||||
"title": "Пользователи",
|
||||
"subtitle": "Создание пользователей и модераторов, роли, аудит загрузок, сброс пароля, блокировка",
|
||||
"create": "Создать пользователя",
|
||||
@@ -60,6 +61,7 @@
|
||||
"empty": "Записей пока нет",
|
||||
"more": "Показать ещё",
|
||||
"actions": {
|
||||
"user.username_change": "Изменён логин",
|
||||
"user.bootstrap_create": "Создан стартовый администратор",
|
||||
"user.bootstrap_credentials": "Заданы учётные данные администратора",
|
||||
"user.create": "Создан пользователь",
|
||||
|
||||
@@ -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": "Войти в веб-версию"
|
||||
}
|
||||
}
|
||||
@@ -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": "Плейлисты не найдены"
|
||||
}
|
||||
}
|
||||
@@ -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": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными."
|
||||
}
|
||||
}
|
||||
Generated
+133
-4
@@ -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
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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 apk == nil || sum == nil {
|
||||
if len(files) == 0 {
|
||||
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))
|
||||
|
||||
@@ -150,13 +150,14 @@ func displayName(s string) (*string, error) {
|
||||
|
||||
// UserPatch — изменения пользователя из админки. nil — не менять.
|
||||
type UserPatch struct {
|
||||
Username *string
|
||||
DisplayName *string
|
||||
Role *Role
|
||||
IsBlocked *bool
|
||||
ReviewsUploads *bool
|
||||
}
|
||||
|
||||
// UpdateUser меняет имя, роль и блокировку с проверкой прав по каждому изменению.
|
||||
// UpdateUser меняет логин, имя, роль и блокировку с проверкой прав по каждому изменению.
|
||||
func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, patch UserPatch) (User, error) {
|
||||
if patch.Role != nil && !patch.Role.Valid() {
|
||||
return User{}, apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
|
||||
@@ -178,6 +179,19 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
|
||||
})
|
||||
}
|
||||
|
||||
if patch.Username != nil {
|
||||
if p.Role != RoleAdmin {
|
||||
return forbidden()
|
||||
}
|
||||
username := NormalizeUsername(*patch.Username)
|
||||
if err := ValidateUsername("username", username); err != nil {
|
||||
return err
|
||||
}
|
||||
if username != target.Username {
|
||||
upd.Username = &username
|
||||
audit("user.username_change", map[string]any{"from": target.Username, "to": username})
|
||||
}
|
||||
}
|
||||
if patch.DisplayName != nil {
|
||||
if !CanManageUser(actor, tgt, UserEdit) {
|
||||
return forbidden()
|
||||
@@ -247,6 +261,9 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if errors.Is(err, ErrUsernameTaken) {
|
||||
return User{}, apperr.Validation("username", CodeUsernameTaken, "username is taken", nil)
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObjec
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
patch := UserPatch{DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads}
|
||||
patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads}
|
||||
if req.Body.Role != nil {
|
||||
r := Role(*req.Body.Role)
|
||||
patch.Role = &r
|
||||
|
||||
@@ -76,6 +76,34 @@ func TestAdminCreatesModeratorWhoCreatesUser(t *testing.T) {
|
||||
assert.Equal(t, auth.CodeForbidden, e.createUser(listener, "another", "user").code())
|
||||
}
|
||||
|
||||
func TestAdminChangesUsername(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
root := e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
listener := e.addUser("listener", "listener-password-1", auth.RoleUser)
|
||||
mod := e.addUser("mod", "mod-password-1", auth.RoleModerator)
|
||||
tok := e.signIn("root", "root-password-1")
|
||||
patch := func(token string, id uuid.UUID, username string) resp {
|
||||
return e.do(http.MethodPatch, "/api/v1/admin/users/"+id.String(), map[string]any{"username": username}, bearer(token))
|
||||
}
|
||||
for _, target := range []auth.User{listener, mod, root} {
|
||||
r := patch(tok, target.ID, " NEW."+target.Username+" ")
|
||||
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
|
||||
assert.Equal(t, "new."+target.Username, r.body["username"])
|
||||
assert.Equal(t, "user.username_change", e.store.Audit[len(e.store.Audit)-1].Action)
|
||||
assert.Equal(t, http.StatusOK, e.login("new."+target.Username, map[uuid.UUID]string{
|
||||
root.ID: "root-password-1", listener.ID: "listener-password-1", mod.ID: "mod-password-1",
|
||||
}[target.ID], "web").Code)
|
||||
}
|
||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/me", nil, bearer(tok)).Code, "own rename preserves session")
|
||||
assert.Equal(t, auth.CodeUsernameTaken, patch(tok, listener.ID, "NEW.ROOT").code())
|
||||
assert.Equal(t, auth.CodeUsernameInvalid, patch(tok, listener.ID, "invalid space").code())
|
||||
assert.Equal(t, auth.CodeUsernameInvalid, patch(tok, listener.ID, "").code())
|
||||
assert.Equal(t, "new.listener", e.store.Users[listener.ID].Username)
|
||||
modTok := e.signIn("new.mod", "mod-password-1")
|
||||
assert.Equal(t, auth.CodeForbidden, patch(modTok, listener.ID, "forbidden").code())
|
||||
assert.Equal(t, auth.CodeForbidden, patch(modTok, mod.ID, "forbidden").code())
|
||||
}
|
||||
|
||||
func TestCreateUserValidation(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -293,7 +293,18 @@ func (s *MemStore) LockActiveAdmins(ctx context.Context) (int, error) { return s
|
||||
|
||||
// UpdateUser реализует auth.Store.
|
||||
func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUpdate) (auth.User, error) {
|
||||
if upd.Username != nil {
|
||||
unlock := s.lock()
|
||||
taken := s.usernameTaken(*upd.Username, id)
|
||||
unlock()
|
||||
if taken {
|
||||
return auth.User{}, auth.ErrUsernameTaken
|
||||
}
|
||||
}
|
||||
err := s.update(id, func(u *auth.User) {
|
||||
if upd.Username != nil {
|
||||
u.Username = *upd.Username
|
||||
}
|
||||
if upd.DisplayName != nil {
|
||||
u.DisplayName = *upd.DisplayName
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Loaded 100 of 107 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user