Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
@@ -37,3 +37,7 @@ Thumbs.db
|
|||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
.cache/
|
.cache/
|
||||||
|
apps/desktop/src-tauri/target/
|
||||||
|
apps/desktop/src-tauri/gen/
|
||||||
|
apps/desktop/src-tauri/tauri.release.json
|
||||||
|
apps/desktop/release/
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# pipu-music
|
# pipu-music
|
||||||
|
|
||||||
Self-hosted музыкальный сервис: веб и Android-приложение, библиотека на NFS,
|
Self-hosted музыкальный сервис: веб, десктоп и Android-приложение, библиотека на NFS,
|
||||||
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
умная бесконечная лента «Моя волна» с учётом вкуса, звучания треков и настроения.
|
||||||
|
|
||||||
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
|
||||||
@@ -15,6 +15,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
|
|||||||
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
| `services/api` | Go: публичный API (`cmd/api`) и агент хранилищ (`cmd/storage-agent`) |
|
||||||
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
| `services/recommender` | Python: рекомендации (`pipu-rec serve`) и аудиоанализ (`pipu-rec analyze`) |
|
||||||
| `apps/web` | Веб-клиент (React + Vite) |
|
| `apps/web` | Веб-клиент (React + Vite) |
|
||||||
|
| `apps/desktop` | Десктоп Windows / Linux / macOS (Tauri, тот же веб) |
|
||||||
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
| `apps/mobile` | Android-приложение (React Native + Expo) |
|
||||||
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
|
||||||
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
|
||||||
|
|||||||
+129
-3
@@ -1538,6 +1538,82 @@ paths:
|
|||||||
format: binary
|
format: binary
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/me/playlists:
|
||||||
|
get:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: listPlaylists
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistList'
|
||||||
|
post:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: createPlaylist
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistInput'
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'201':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
/api/v1/me/playlists/{id}:
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema: { type: string, format: uuid }
|
||||||
|
get:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: getPlaylist
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
put:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: updatePlaylist
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/PlaylistUpdate'
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'200':
|
||||||
|
description: Success
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Playlist'
|
||||||
|
delete:
|
||||||
|
tags: [collection]
|
||||||
|
operationId: deletePlaylist
|
||||||
|
responses:
|
||||||
|
default:
|
||||||
|
$ref: '#/components/responses/Problem'
|
||||||
|
'204':
|
||||||
|
description: Deleted
|
||||||
|
|
||||||
components:
|
components:
|
||||||
parameters:
|
parameters:
|
||||||
SourceId:
|
SourceId:
|
||||||
@@ -1598,6 +1674,53 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Problem"
|
$ref: "#/components/schemas/Problem"
|
||||||
schemas:
|
schemas:
|
||||||
|
PlaylistInput:
|
||||||
|
type: object
|
||||||
|
required: [name, description, track_ids]
|
||||||
|
properties:
|
||||||
|
name: { type: string, minLength: 1, maxLength: 120 }
|
||||||
|
description: { type: string, maxLength: 2000 }
|
||||||
|
track_ids:
|
||||||
|
type: array
|
||||||
|
maxItems: 2000
|
||||||
|
uniqueItems: true
|
||||||
|
items: { type: string, format: uuid }
|
||||||
|
PlaylistUpdate:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/PlaylistInput'
|
||||||
|
- type: object
|
||||||
|
required: [revision]
|
||||||
|
properties:
|
||||||
|
revision: { type: integer, minimum: 1 }
|
||||||
|
PlaylistSummary:
|
||||||
|
type: object
|
||||||
|
required: [id, name, description, revision, track_count, duration_ms, created_at, updated_at]
|
||||||
|
properties:
|
||||||
|
id: { type: string, format: uuid }
|
||||||
|
name: { type: string }
|
||||||
|
description: { type: string }
|
||||||
|
revision: { type: integer }
|
||||||
|
track_count: { type: integer }
|
||||||
|
duration_ms: { type: integer, format: int64 }
|
||||||
|
created_at: { type: string, format: date-time }
|
||||||
|
updated_at: { type: string, format: date-time }
|
||||||
|
Playlist:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/components/schemas/PlaylistSummary'
|
||||||
|
- type: object
|
||||||
|
required: [tracks]
|
||||||
|
properties:
|
||||||
|
tracks:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/Track' }
|
||||||
|
PlaylistList:
|
||||||
|
type: object
|
||||||
|
required: [items]
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items: { $ref: '#/components/schemas/PlaylistSummary' }
|
||||||
|
|
||||||
Onboarding:
|
Onboarding:
|
||||||
type: object
|
type: object
|
||||||
required: [required, artists, genres]
|
required: [required, artists, genres]
|
||||||
@@ -2100,7 +2223,7 @@ components:
|
|||||||
enum: [admin, moderator, user]
|
enum: [admin, moderator, user]
|
||||||
ClientType:
|
ClientType:
|
||||||
type: string
|
type: string
|
||||||
enum: [web, android]
|
enum: [web, android, desktop]
|
||||||
LoginRequest:
|
LoginRequest:
|
||||||
type: object
|
type: object
|
||||||
required: [username, password, client_type]
|
required: [username, password, client_type]
|
||||||
@@ -2120,7 +2243,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
refresh_token:
|
refresh_token:
|
||||||
type: string
|
type: string
|
||||||
description: Только Android; веб передаёт токен в cookie
|
description: Android и desktop; веб передаёт токен в cookie
|
||||||
AuthTokens:
|
AuthTokens:
|
||||||
type: object
|
type: object
|
||||||
required: [access_token, token_type, expires_in, user]
|
required: [access_token, token_type, expires_in, user]
|
||||||
@@ -2135,7 +2258,7 @@ components:
|
|||||||
description: Срок жизни access-токена, секунды
|
description: Срок жизни access-токена, секунды
|
||||||
refresh_token:
|
refresh_token:
|
||||||
type: string
|
type: string
|
||||||
description: "Только для `client_type: android`"
|
description: "Для `client_type: android` и `client_type: desktop`"
|
||||||
user:
|
user:
|
||||||
$ref: "#/components/schemas/Me"
|
$ref: "#/components/schemas/Me"
|
||||||
UiPrefs:
|
UiPrefs:
|
||||||
@@ -2298,6 +2421,9 @@ components:
|
|||||||
UpdateUserRequest:
|
UpdateUserRequest:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Новый логин. Может менять только администратор, в том числе у себя
|
||||||
display_name:
|
display_name:
|
||||||
type: string
|
type: string
|
||||||
maxLength: 64
|
maxLength: 64
|
||||||
|
|||||||
@@ -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,168 @@
|
|||||||
|
//! 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 Some(tail) = request.uri().path().strip_prefix(&prefix) else { return StatusCode::NOT_FOUND.into_response(); };
|
||||||
|
// 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.to_owned()).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
cors(response.headers_mut(), origin.as_deref());
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn transfer(proxy: &Proxy, request: Request, tail: String) -> Result<Response, String> {
|
||||||
|
let server = proxy.server.read().map_err(|_| "State unavailable")?.clone().ok_or("No server selected")?;
|
||||||
|
let query = request.uri().query().map(|q| format!("?{q}")).unwrap_or_default();
|
||||||
|
let url = format!("{server}/api/{tail}{query}");
|
||||||
|
let (parts, body) = request.into_parts();
|
||||||
|
let mut upstream = proxy.client.request(parts.method.clone(), url);
|
||||||
|
for name in ["authorization", "content-type", "content-length", "range", "if-range", "if-none-match", "tus-resumable", "upload-length", "upload-offset", "upload-metadata", "upload-defer-length"] {
|
||||||
|
if let Some(value) = parts.headers.get(name) { upstream = upstream.header(name, value); }
|
||||||
|
}
|
||||||
|
let upstream = upstream.body(reqwest::Body::wrap_stream(body.into_data_stream())).send().await.map_err(|e| e.to_string())?;
|
||||||
|
let status = upstream.status();
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
for name in ["content-type", "content-length", "content-range", "accept-ranges", "etag", "last-modified", "cache-control", "upload-offset", "upload-length", "tus-resumable", "retry-after", "content-disposition"] {
|
||||||
|
if let Some(value) = upstream.headers().get(name) { headers.insert(name, value.clone()); }
|
||||||
|
}
|
||||||
|
if let Some(location) = upstream.headers().get("location").and_then(|v| v.to_str().ok()) {
|
||||||
|
let local = local_url(location, &server, &proxy.base).ok_or("External redirect refused")?;
|
||||||
|
headers.insert("location", HeaderValue::from_str(&local).map_err(|e| e.to_string())?);
|
||||||
|
}
|
||||||
|
let json = headers.get("content-type").and_then(|v| v.to_str().ok())
|
||||||
|
.is_some_and(|v| v.contains("application/json") || v.contains("application/problem+json"));
|
||||||
|
let body = if json && parts.method != Method::HEAD {
|
||||||
|
// Bound only JSON, never buffer a song or an upload in memory.
|
||||||
|
let bytes = axum::body::to_bytes(Body::from_stream(upstream.bytes_stream()), 16 * 1024 * 1024).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut value: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
rewrite_json(&mut value, &server, &proxy.base);
|
||||||
|
headers.remove("content-length");
|
||||||
|
headers.remove("etag");
|
||||||
|
Body::from(serde_json::to_vec(&value).map_err(|e| e.to_string())?)
|
||||||
|
} else { Body::from_stream(upstream.bytes_stream()) };
|
||||||
|
let mut response = Response::new(body);
|
||||||
|
*response.status_mut() = status;
|
||||||
|
*response.headers_mut() = headers;
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn validates_server_and_scopes_media_urls() {
|
||||||
|
assert_eq!(validate_server("https://music.example/pipu/").unwrap(), "https://music.example/pipu");
|
||||||
|
for url in ["file:///etc/passwd", "https://user:pass@host", "https://host/?token=x", "https://host/#x"] { assert!(validate_server(url).is_err()); }
|
||||||
|
assert_eq!(local_url("https://music.example.evil/api/a", "https://music.example", "http://local"), None);
|
||||||
|
let mut value = serde_json::json!({"tracks": [{"stream_url": "/api/v1/stream/a?sig=b", "title": "/api/private", "album": {"cover_url": "/api/v1/covers/a"}}]});
|
||||||
|
rewrite_json(&mut value, "https://music.example", "http://local/key");
|
||||||
|
assert_eq!(value["tracks"][0]["stream_url"], "http://local/key/api/v1/stream/a?sig=b");
|
||||||
|
assert_eq!(value["tracks"][0]["title"], "/api/private");
|
||||||
|
assert_eq!(value["tracks"][0]["album"]["cover_url"], "http://local/key/api/v1/covers/a");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tauri::{AppHandle, Manager};
|
||||||
|
|
||||||
|
const SERVICE: &str = "app.pipu.music.desktop";
|
||||||
|
|
||||||
|
fn config_dir(app: &AppHandle) -> Result<PathBuf, String> {
|
||||||
|
let dir = app.path().app_config_dir().map_err(|e| e.to_string())?;
|
||||||
|
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
Ok(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_server(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => {
|
||||||
|
let s = s.trim().to_string();
|
||||||
|
Ok((!s.is_empty()).then_some(s))
|
||||||
|
}
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_server(app: &AppHandle, server: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("server.txt");
|
||||||
|
match server {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_entry() -> Result<keyring::Entry, String> {
|
||||||
|
keyring::Entry::new(SERVICE, "refresh").map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_refresh() -> Result<Option<String>, String> {
|
||||||
|
match refresh_entry()?.get_password() {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_refresh(token: Option<&str>) -> Result<(), String> {
|
||||||
|
let entry = refresh_entry()?;
|
||||||
|
match token {
|
||||||
|
Some(t) => entry.set_password(t).map_err(|e| e.to_string()),
|
||||||
|
None => match entry.delete_credential() {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(keyring::Error::NoEntry) => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_user(app: &AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match fs::read_to_string(path) {
|
||||||
|
Ok(s) => Ok(Some(s)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_user(app: &AppHandle, json: Option<&str>) -> Result<(), String> {
|
||||||
|
let path = config_dir(app)?.join("user.json");
|
||||||
|
match json {
|
||||||
|
Some(s) => fs::write(path, s).map_err(|e| e.to_string()),
|
||||||
|
None => match fs::remove_file(path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(e.to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "Pipu Music",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"identifier": "app.pipu.music.desktop",
|
||||||
|
"build": {
|
||||||
|
"beforeDevCommand": "pnpm --filter @pipu/web dev",
|
||||||
|
"devUrl": "http://127.0.0.1:5173",
|
||||||
|
"beforeBuildCommand": "pnpm --filter @pipu/web build",
|
||||||
|
"frontendDist": "../../web/dist"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"withGlobalTauri": true,
|
||||||
|
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["nsis", "appimage", "deb", "rpm"],
|
||||||
|
"category": "Music",
|
||||||
|
"shortDescription": "Self-hosted music player",
|
||||||
|
"icon": ["icons/32x32.png", "icons/128x128.png", "icons/128x128@2x.png", "icons/icon.ico", "icons/icon.icns"],
|
||||||
|
"linux": {
|
||||||
|
"deb": { "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1", "libsecret-1-0", "gstreamer1.0-plugins-good", "gstreamer1.0-plugins-bad", "gstreamer1.0-libav"] },
|
||||||
|
"appimage": { "bundleMediaFramework": true }
|
||||||
|
},
|
||||||
|
"windows": { "nsis": { "installMode": "currentUser", "languages": ["English", "Russian"], "displayLanguageSelector": true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { unwrap, type Track } from '@pipu/core';
|
import { unwrap, type Track } from '@pipu/core';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -20,6 +21,7 @@ export default function CollectionScreen() {
|
|||||||
const session = useSession();
|
const session = useSession();
|
||||||
const current = usePlayerState().track;
|
const current = usePlayerState().track;
|
||||||
const [tab, setTab] = useState<Tab>('likes');
|
const [tab, setTab] = useState<Tab>('likes');
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const list = useInfiniteQuery({
|
const list = useInfiniteQuery({
|
||||||
queryKey: ['m-collection', tab],
|
queryKey: ['m-collection', tab],
|
||||||
@@ -54,6 +56,8 @@ export default function CollectionScreen() {
|
|||||||
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
const empty = { history: 'collection.empty_history', likes: 'collection.empty_likes', dislikes: 'collection.empty_dislikes' } as const;
|
||||||
|
|
||||||
const tabs = (
|
const tabs = (
|
||||||
|
<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t('playlists.title')} icon="queue-music" variant="secondary" onPress={() => router.push('/playlists')} />
|
||||||
<Segmented<Tab>
|
<Segmented<Tab>
|
||||||
value={tab}
|
value={tab}
|
||||||
onChange={setTab}
|
onChange={setTab}
|
||||||
@@ -64,6 +68,7 @@ export default function CollectionScreen() {
|
|||||||
{ value: 'dislikes', label: t('collection.dislikes') },
|
{ value: 'dislikes', label: t('collection.dislikes') },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
if (tab === 'downloads') return <DownloadsTab tabs={tabs} />;
|
||||||
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ function Screens() {
|
|||||||
<Stack.Screen name="(tabs)" />
|
<Stack.Screen name="(tabs)" />
|
||||||
<Stack.Screen name="album/[id]" />
|
<Stack.Screen name="album/[id]" />
|
||||||
<Stack.Screen name="artist/[id]" />
|
<Stack.Screen name="artist/[id]" />
|
||||||
|
<Stack.Screen name="playlists" />
|
||||||
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
|
||||||
</Stack.Protected>
|
</Stack.Protected>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
|
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
|
||||||
|
import { trackShareUrl } from '@pipu/core';
|
||||||
|
|
||||||
import { useUser } from '../lib/session';
|
import { useSessionCtx, useUser } from '../lib/session';
|
||||||
import { player } from '../player/controller';
|
import { player } from '../player/controller';
|
||||||
import { usePlayerState } from '../player/hooks';
|
import { usePlayerState } from '../player/hooks';
|
||||||
import { useColors } from '../theme';
|
import { useColors } from '../theme';
|
||||||
@@ -21,6 +22,8 @@ export default function PlayerScreen() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const prefs = useUser().ui_prefs;
|
const prefs = useUser().ui_prefs;
|
||||||
|
const { server } = useSessionCtx();
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
const [barWidth, setBarWidth] = useState(1);
|
const [barWidth, setBarWidth] = useState(1);
|
||||||
const track = st.track;
|
const track = st.track;
|
||||||
|
|
||||||
@@ -36,6 +39,17 @@ export default function PlayerScreen() {
|
|||||||
const duration = st.durationMs || track.duration_ms;
|
const duration = st.durationMs || track.duration_ms;
|
||||||
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
|
||||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const share = async () => {
|
||||||
|
if (!server || sharing) return;
|
||||||
|
setSharing(true);
|
||||||
|
try {
|
||||||
|
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
|
||||||
|
} catch {
|
||||||
|
Alert.alert(t('player.share'), t('player.share_error'));
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Screen>
|
<Screen>
|
||||||
@@ -45,7 +59,12 @@ export default function PlayerScreen() {
|
|||||||
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
<Icon name="keyboard-arrow-down" size={32} color={colors.text} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
<Text style={{ color: colors.text2, fontSize: 13 }}>{st.wave ? t('wave.title') : t('mobile.now_playing')}</Text>
|
||||||
<DownloadButton track={track} />
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 16 }}>
|
||||||
|
<Pressable onPress={() => void share()} disabled={!server || sharing} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.share')} accessibilityState={{ disabled: !server || sharing }}>
|
||||||
|
<Icon name="share" size={24} color={colors.text} />
|
||||||
|
</Pressable>
|
||||||
|
<DownloadButton track={track} />
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={{ alignItems: 'center' }}>
|
<View style={{ alignItems: 'center' }}>
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
|
||||||
|
import { useSession, useUser } from '../lib/session';
|
||||||
|
import { player } from '../player/controller';
|
||||||
|
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
|
||||||
|
|
||||||
|
type Playlist = components['schemas']['Playlist'];
|
||||||
|
const input = (p: Playlist) => ({ name: p.name, description: p.description, revision: p.revision, track_ids: p.tracks.map(t => t.id) });
|
||||||
|
|
||||||
|
export default function PlaylistsScreen() {
|
||||||
|
const { id, track } = useLocalSearchParams<{ id?: string; track?: string }>();
|
||||||
|
return id ? <Detail key={id} id={id} /> : <List key={track ?? 'list'} track={track} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function List({ track }: { track?: string }) {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const list = useQuery({ queryKey: ['m-playlists', user.id], queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
|
const add = useMutation({ mutationFn: async (id: string) => {
|
||||||
|
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||||
|
const body = input(p); body.track_ids = [...new Set([...body.track_ids, track!])];
|
||||||
|
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||||
|
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||||
|
return <Screen><FlatList
|
||||||
|
data={list.data?.items.filter(p => p.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? []}
|
||||||
|
keyExtractor={p => p.id} onRefresh={() => void list.refetch()} refreshing={list.isRefetching}
|
||||||
|
contentContainerStyle={{ padding: 16, gap: 12 }}
|
||||||
|
ListHeaderComponent={<View style={{ gap: 12 }}>
|
||||||
|
<Button title={t('form.close')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<H1>{t(track ? 'playlists.add' : 'playlists.title')}</H1>
|
||||||
|
<Button title={t('playlists.create')} onPress={() => setCreate(true)} disabled={add.isPending} />
|
||||||
|
<Field label={t('playlists.search')} value={search} onChangeText={setSearch} />
|
||||||
|
<ErrorBox>{list.error || add.error ? errorText(list.error ?? add.error) : null}</ErrorBox>
|
||||||
|
{list.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
|
</View>}
|
||||||
|
ListEmptyComponent={list.isSuccess ? <Muted>{t(search ? 'playlists.no_results' : 'playlists.empty')}</Muted> : null}
|
||||||
|
renderItem={({ item }) => <Button title={`${item.name} · ${item.track_count}`} variant="secondary" disabled={add.isPending}
|
||||||
|
onPress={() => track ? add.mutate(item.id) : router.push({ pathname: '/playlists', params: { id: item.id } })} />}
|
||||||
|
/>{create && <Editor track={track} onClose={() => setCreate(false)} onSaved={p => {
|
||||||
|
setCreate(false); if (track) router.back(); else router.push({ pathname: '/playlists', params: { id: p.id } });
|
||||||
|
}} />}</Screen>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; track?: string; onClose: () => void; onSaved: (p: Playlist) => void }) {
|
||||||
|
const session = useSession();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [original] = useState(playlist);
|
||||||
|
const [name, setName] = useState(playlist?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||||
|
const save = useMutation({ mutationFn: async () => {
|
||||||
|
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? (track ? [track] : []) };
|
||||||
|
return original ? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||||
|
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||||
|
}, onSuccess: p => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); onSaved(p); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
|
return <Modal animationType="slide" onRequestClose={() => { if (!save.isPending) onClose(); }}><Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
|
||||||
|
<H1>{t(original ? 'playlists.edit' : 'playlists.create')}</H1>
|
||||||
|
<Field label={t('playlists.name')} value={name} onChangeText={setName} maxLength={120} autoFocus />
|
||||||
|
<Field label={t('playlists.description')} value={description} onChangeText={setDescription} maxLength={2000} multiline />
|
||||||
|
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
|
||||||
|
<Button title={t('playlists.save')} disabled={!name.trim()} busy={save.isPending} onPress={() => save.mutate()} />
|
||||||
|
<Button title={t('form.cancel')} variant="ghost" disabled={save.isPending} onPress={onClose} />
|
||||||
|
</ScrollView></Screen></Modal>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ id }: { id: string }) {
|
||||||
|
const session = useSession();
|
||||||
|
const user = useUser();
|
||||||
|
const router = useRouter();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [edit, setEdit] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const [playError, setPlayError] = useState<unknown>();
|
||||||
|
const key = ['m-playlists', user.id, id];
|
||||||
|
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
|
const p = query.data;
|
||||||
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
|
||||||
|
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
|
||||||
|
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id } } })),
|
||||||
|
onSuccess: () => { cache.removeQueries({ queryKey: key }); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); router.back(); } });
|
||||||
|
const play = (shuffle: boolean) => {
|
||||||
|
const tracks = p?.tracks.filter(t => t.available) ?? [];
|
||||||
|
if (shuffle) for (let i=tracks.length-1;i>0;i--) { const j=Math.floor(Math.random()*(i+1)); [tracks[i],tracks[j]]=[tracks[j]!,tracks[i]!]; }
|
||||||
|
if (tracks.length) void player.playList(tracks,0,{ type:'collection', id, label:p?.name }).catch(setPlayError);
|
||||||
|
};
|
||||||
|
const move = (index: number, delta: number) => {
|
||||||
|
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
|
||||||
|
};
|
||||||
|
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
|
||||||
|
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
|
||||||
|
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
|
||||||
|
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox>
|
||||||
|
{query.isPending && <Muted>{t('playlists.loading')}</Muted>}
|
||||||
|
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
|
||||||
|
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
|
||||||
|
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
|
||||||
|
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
|
||||||
|
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
|
||||||
|
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
|
||||||
|
{text:t('form.cancel'),style:'cancel'}, {text:t('playlists.delete'),style:'destructive',onPress:()=>deletion.mutate()},
|
||||||
|
])} />
|
||||||
|
</>}
|
||||||
|
</View>}
|
||||||
|
ListEmptyComponent={p ? <View style={{padding:16}}><Muted>{t('playlists.empty_tracks')}</Muted></View> : null}
|
||||||
|
renderItem={({item,index}) => <View><TrackRow track={item} onPress={() => void player.playList(p!.tracks,index,{type:'collection',id,label:p!.name}).catch(setPlayError)} />
|
||||||
|
{manage && <View style={{padding:12,gap:6}}>
|
||||||
|
<Button title={t('playlists.move_up',{name:item.title})} variant="ghost" disabled={!index || save.isPending} onPress={() => move(index,-1)} />
|
||||||
|
<Button title={t('playlists.move_down',{name:item.title})} variant="ghost" disabled={index===p!.tracks.length-1 || save.isPending} onPress={() => move(index,1)} />
|
||||||
|
<Button title={t('playlists.remove_track',{name:item.title})} variant="danger" disabled={save.isPending} onPress={() => save.mutate(p!.tracks.filter(t => t.id!==item.id).map(t => t.id))} />
|
||||||
|
</View>}
|
||||||
|
</View>}
|
||||||
|
/>{edit && p && <Editor playlist={p} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}</Screen>;
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ import { playerCache, qualityPrefs, type Quality } from '../lib/prefs';
|
|||||||
import { downloads } from '../offline/downloads';
|
import { downloads } from '../offline/downloads';
|
||||||
import { PersistentEvents } from '../offline/events';
|
import { PersistentEvents } from '../offline/events';
|
||||||
import { offlineWave } from './offlineWave';
|
import { offlineWave } from './offlineWave';
|
||||||
|
import { eventId } from './eventId';
|
||||||
|
|
||||||
export { offlineWave };
|
export { offlineWave };
|
||||||
|
|
||||||
@@ -113,7 +114,7 @@ class Controller {
|
|||||||
this.server = server;
|
this.server = server;
|
||||||
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
this.persistent = session && userId ? new PersistentEvents(session, server, userId) : null;
|
||||||
const sink = this.persistent;
|
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();
|
void sink?.drain();
|
||||||
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
this.unsubscribeNet ??= NetInfo.addEventListener((st) => {
|
||||||
if (st.isConnected) void this.persistent?.drain();
|
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 MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { ApiError, type Track } from '@pipu/core';
|
import { ApiError, type Track } from '@pipu/core';
|
||||||
import type { ComponentProps, ReactNode } from 'react';
|
import type { ComponentProps, ReactNode } from 'react';
|
||||||
import { useState, useSyncExternalStore } from 'react';
|
import { useState, useSyncExternalStore } from 'react';
|
||||||
@@ -199,6 +200,7 @@ export function TrackRow({
|
|||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const liked = track.reaction === 'like';
|
const liked = track.reaction === 'like';
|
||||||
|
const router = useRouter();
|
||||||
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
const saved = useSyncExternalStore(downloads.subscribe, () => downloads.isDownloaded(track.id));
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -233,6 +235,10 @@ export function TrackRow({
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
)}
|
)}
|
||||||
{trailing}
|
{trailing}
|
||||||
|
<Pressable accessibilityRole="button" accessibilityLabel={t('playlists.add')} style={{ padding: 8 }}
|
||||||
|
onPress={(e) => { e.stopPropagation(); router.push({ pathname: '/playlists', params: { track: track.id } }); }}>
|
||||||
|
<Icon name="playlist-add" size={22} />
|
||||||
|
</Pressable>
|
||||||
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
{saved && !trailing && <Icon name="download-done" size={16} color={colors.accent2} />}
|
||||||
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
<Text style={s.duration}>{formatDuration(track.duration_ms)}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -0,0 +1,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();
|
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 () => {
|
it('offers moderators only what they may do', async () => {
|
||||||
const mod = makeUser('mod', 'moderator');
|
const mod = makeUser('mod', 'moderator');
|
||||||
const anna = makeUser('anna', 'user');
|
const anna = makeUser('anna', 'user');
|
||||||
@@ -248,6 +281,30 @@ describe('search box', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('player', () => {
|
describe('player', () => {
|
||||||
|
it('opens a shared track after login and copies its page URL from the player', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const path = '/track/00000000-0000-4000-8000-000000000101';
|
||||||
|
const { user, router } = await renderApp([alice], path);
|
||||||
|
await screen.findByRole('heading', { name: 'Вход' });
|
||||||
|
await user.type(screen.getByLabelText('Логин'), alice.username);
|
||||||
|
await user.type(screen.getByLabelText('Пароль'), alice.password);
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Войти' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Закрой за мной дверь' });
|
||||||
|
expect(router.state.location.pathname).toBe(path);
|
||||||
|
await user.click(screen.getByRole('listitem'));
|
||||||
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
|
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
|
||||||
|
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
|
||||||
|
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows a missing shared track without starting playback', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
await renderApp([alice], '/track/missing', { signedInAs: alice });
|
||||||
|
expect(await screen.findByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
|
||||||
const alice = makeUser('alice', 'user');
|
const alice = makeUser('alice', 'user');
|
||||||
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
@@ -350,3 +407,55 @@ describe('safeRedirect', () => {
|
|||||||
expect(safeRedirect(input)).toBe(expected);
|
expect(safeRedirect(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('playlists', () => {
|
||||||
|
it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'В дорогу');
|
||||||
|
await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'В дорогу' });
|
||||||
|
const id = [...api.playlists.keys()][0]!;
|
||||||
|
expect(api.playlists.get(id)?.description).toBe('Музыка для дороги');
|
||||||
|
await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } });
|
||||||
|
await screen.findByRole('heading', { name: 'Группа крови' });
|
||||||
|
await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!);
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2));
|
||||||
|
await router.navigate({ to: '/playlist/$id', params: { id } });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Управлять треками' }));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь'));
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled());
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' }));
|
||||||
|
await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1));
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||||
|
await user.clear(screen.getByLabelText('Название'));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Вечер');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Вечер' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Удалить плейлист' }));
|
||||||
|
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Плейлисты' });
|
||||||
|
expect(api.playlists.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps stale editor open on conflict without overwriting newer changes', async () => {
|
||||||
|
const alice = makeUser('alice', 'user');
|
||||||
|
const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice });
|
||||||
|
await user.click(await screen.findByRole('button', { name: 'Создать плейлист' }));
|
||||||
|
await user.type(screen.getByLabelText('Название'), 'Первый');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
await screen.findByRole('heading', { name: 'Первый' });
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Изменить плейлист' }));
|
||||||
|
const item = [...api.playlists.values()][0]!;
|
||||||
|
api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' });
|
||||||
|
await user.type(screen.getByLabelText('Название'), ' изменён');
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве');
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,9 +18,13 @@ const MyUploadsPage = lazy(() => import('../pages/uploads/MyUploadsPage').then((
|
|||||||
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
const SuggestPage = lazy(() => import('../pages/uploads/SuggestPage').then((m) => ({ default: m.SuggestPage })));
|
||||||
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
const SourcesPage = lazy(() => import('../pages/admin/SourcesPage').then((m) => ({ default: m.SourcesPage })));
|
||||||
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
const UsersPage = lazy(() => import('../pages/admin/UsersPage').then((m) => ({ default: m.UsersPage })));
|
||||||
|
import type { DesktopHandle } from '../desktop';
|
||||||
import { AlbumPage } from '../pages/AlbumPage';
|
import { AlbumPage } from '../pages/AlbumPage';
|
||||||
|
import { ConnectPage } from '../pages/ConnectPage';
|
||||||
|
import { TrackPage } from '../pages/TrackPage';
|
||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
|
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
|
||||||
import { HomePage } from '../pages/HomePage';
|
import { HomePage } from '../pages/HomePage';
|
||||||
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||||
import { LoginPage } from '../pages/LoginPage';
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
@@ -32,6 +36,7 @@ import { RouteError } from '../widgets/RouteError';
|
|||||||
|
|
||||||
export interface RouterContext {
|
export interface RouterContext {
|
||||||
session: AuthSession;
|
session: AuthSession;
|
||||||
|
desktop: DesktopHandle | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
|
||||||
@@ -50,9 +55,12 @@ export function safeRedirect(target: unknown): string {
|
|||||||
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
|
function requireUser(session: AuthSession, location: ParsedLocation, desktop: DesktopHandle | null): AuthUser {
|
||||||
const user = signedInUser(session);
|
const user = signedInUser(session);
|
||||||
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
|
if (!user) {
|
||||||
|
if (desktop && !desktop.server) throw redirect({ to: '/connect' });
|
||||||
|
throw redirect({ to: '/login', search: { redirect: location.href } });
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,12 +72,23 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|||||||
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||||
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||||
|
|
||||||
|
const connectRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/connect',
|
||||||
|
beforeLoad: ({ context }) => {
|
||||||
|
if (!context.desktop) throw redirect({ to: '/login' });
|
||||||
|
if (context.desktop.server) throw redirect({ to: '/login' });
|
||||||
|
},
|
||||||
|
component: ConnectPage,
|
||||||
|
});
|
||||||
|
|
||||||
const loginRoute = createRoute({
|
const loginRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/login',
|
path: '/login',
|
||||||
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
|
||||||
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
|
||||||
beforeLoad: ({ context, search }) => {
|
beforeLoad: ({ context, search }) => {
|
||||||
|
if (context.desktop && !context.desktop.server) throw redirect({ to: '/connect' });
|
||||||
const user = signedInUser(context.session);
|
const user = signedInUser(context.session);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
@@ -83,7 +102,7 @@ const setupAdminRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/admin',
|
path: '/setup/admin',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="admin" />,
|
component: () => <SetupPage variant="admin" />,
|
||||||
@@ -93,7 +112,7 @@ const setupPasswordRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/setup/password',
|
path: '/setup/password',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
|
||||||
},
|
},
|
||||||
component: () => <SetupPage variant="password" />,
|
component: () => <SetupPage variant="password" />,
|
||||||
@@ -104,7 +123,7 @@ const appRoute = createRoute({
|
|||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
id: 'app',
|
id: 'app',
|
||||||
beforeLoad: ({ context, location }) => {
|
beforeLoad: ({ context, location }) => {
|
||||||
const user = requireUser(context.session, location);
|
const user = requireUser(context.session, location, context.desktop);
|
||||||
const home = homeFor(user);
|
const home = homeFor(user);
|
||||||
if (home !== '/') throw redirect({ to: home });
|
if (home !== '/') throw redirect({ to: home });
|
||||||
return { user };
|
return { user };
|
||||||
@@ -117,6 +136,9 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
|
||||||
|
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
|
||||||
|
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
|
||||||
|
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
|
||||||
|
|
||||||
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
|
||||||
|
|
||||||
@@ -241,11 +263,15 @@ const adminSettingsRoute = createRoute({
|
|||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
androidAppRoute,
|
androidAppRoute,
|
||||||
|
connectRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupAdminRoute,
|
setupAdminRoute,
|
||||||
setupPasswordRoute,
|
setupPasswordRoute,
|
||||||
appRoute.addChildren([
|
appRoute.addChildren([
|
||||||
homeRoute,
|
homeRoute,
|
||||||
|
trackRoute,
|
||||||
|
playlistsRoute,
|
||||||
|
playlistRoute,
|
||||||
settingsRoute,
|
settingsRoute,
|
||||||
searchRoute,
|
searchRoute,
|
||||||
historyRoute,
|
historyRoute,
|
||||||
@@ -265,8 +291,12 @@ const routeTree = rootRoute.addChildren([
|
|||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
|
export function createAppRouter(
|
||||||
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
|
session: AuthSession,
|
||||||
|
opts: { history?: Parameters<typeof createRouter>[0]['history']; desktop?: DesktopHandle | null } = {},
|
||||||
|
) {
|
||||||
|
const { desktop = null, ...rest } = opts;
|
||||||
|
const router = createRouter({ routeTree, context: { session, desktop }, defaultPreload: false, ...rest });
|
||||||
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
|
||||||
session.subscribe(() => void router.invalidate());
|
session.subscribe(() => void router.invalidate());
|
||||||
return router;
|
return router;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createDesktop, isDesktop } from './desktop';
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('desktop bridge', () => {
|
||||||
|
it('is inactive in the browser', () => {
|
||||||
|
expect(isDesktop()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bootstraps through the Tauri invoke surface', async () => {
|
||||||
|
const invoke = vi.fn(async (cmd: string) => {
|
||||||
|
if (cmd === 'bootstrap') return { proxyBase: 'http://127.0.0.1:9/abc', server: 'https://music.example' };
|
||||||
|
if (cmd === 'set_server') return 'https://music.example/pipu';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke }, event: { listen: vi.fn() } };
|
||||||
|
const desktop = await createDesktop();
|
||||||
|
expect(desktop?.proxyBase).toBe('http://127.0.0.1:9/abc');
|
||||||
|
expect(desktop?.server).toBe('https://music.example');
|
||||||
|
await desktop?.setServer('https://music.example/pipu');
|
||||||
|
expect(invoke).toHaveBeenCalledWith('set_server', { url: 'https://music.example/pipu' });
|
||||||
|
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import type { AuthUser, RefreshStore } from '@pipu/core';
|
||||||
|
import { createContext, createElement, useContext, type ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Tauri = {
|
||||||
|
core: { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||||
|
event: { listen: (event: string, handler: (e: { payload: string }) => void) => Promise<() => void> };
|
||||||
|
};
|
||||||
|
|
||||||
|
function api(): Tauri | undefined {
|
||||||
|
return (window as Window & { __TAURI__?: Tauri }).__TAURI__;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDesktop(): boolean {
|
||||||
|
return api() !== undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
|
const tauri = api();
|
||||||
|
if (!tauri) throw new Error('Tauri API unavailable');
|
||||||
|
return tauri.core.invoke(cmd, args) as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DesktopHandle = {
|
||||||
|
proxyBase: string;
|
||||||
|
server: string | null;
|
||||||
|
setServer: (url: string) => Promise<string>;
|
||||||
|
clearServer: () => Promise<void>;
|
||||||
|
refreshStore: RefreshStore;
|
||||||
|
listenMedia: (handler: (action: string) => void) => Promise<() => void>;
|
||||||
|
setMedia: (info: { title: string; artist: string; album: string; coverUrl?: string | null; playing: boolean }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DesktopContext = createContext<DesktopHandle | null>(null);
|
||||||
|
|
||||||
|
export function DesktopProvider({ value, children }: { value: DesktopHandle | null; children: ReactNode }) {
|
||||||
|
return createElement(DesktopContext.Provider, { value }, children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDesktop(): DesktopHandle | null {
|
||||||
|
return useContext(DesktopContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
let active: DesktopHandle | null = null;
|
||||||
|
|
||||||
|
export function desktopHandle(): DesktopHandle | null {
|
||||||
|
return active;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createDesktop(): Promise<DesktopHandle | null> {
|
||||||
|
if (!isDesktop()) return null;
|
||||||
|
const boot = await invoke<{ proxyBase: string; server: string | null }>('bootstrap');
|
||||||
|
const handle: DesktopHandle = {
|
||||||
|
proxyBase: boot.proxyBase,
|
||||||
|
server: boot.server,
|
||||||
|
setServer: async (url) => {
|
||||||
|
const server = await invoke<string>('set_server', { url });
|
||||||
|
handle.server = server;
|
||||||
|
return server;
|
||||||
|
},
|
||||||
|
clearServer: async () => {
|
||||||
|
await invoke('clear_server');
|
||||||
|
handle.server = null;
|
||||||
|
},
|
||||||
|
refreshStore: {
|
||||||
|
get: () => invoke<string | null>('token_get'),
|
||||||
|
set: (token) => invoke('token_set', { token }),
|
||||||
|
clear: async () => {
|
||||||
|
await invoke('token_clear');
|
||||||
|
await invoke('user_clear');
|
||||||
|
},
|
||||||
|
getUser: async () => {
|
||||||
|
const json = await invoke<string | null>('user_get');
|
||||||
|
return json ? (JSON.parse(json) as AuthUser) : null;
|
||||||
|
},
|
||||||
|
setUser: (user) => invoke('user_set', { json: JSON.stringify(user) }),
|
||||||
|
},
|
||||||
|
listenMedia: async (handler) => {
|
||||||
|
const tauri = api();
|
||||||
|
if (!tauri) return () => undefined;
|
||||||
|
return tauri.event.listen('media', (e) => handler(e.payload));
|
||||||
|
},
|
||||||
|
setMedia: (info) => {
|
||||||
|
void invoke('media_set', {
|
||||||
|
title: info.title,
|
||||||
|
artist: info.artist,
|
||||||
|
album: info.album,
|
||||||
|
coverUrl: info.coverUrl ?? null,
|
||||||
|
playing: info.playing,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
active = handle;
|
||||||
|
return handle;
|
||||||
|
}
|
||||||
@@ -277,4 +277,5 @@
|
|||||||
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
.track:has(.lead), .track:has(.lead):has(.trackAlbum) { grid-template-columns: 24px 40px minmax(0, 1fr) auto; }
|
||||||
.end { gap: 4px; }
|
.end { gap: 4px; }
|
||||||
.like { opacity: 1; width: 44px; height: 44px; }
|
.like { opacity: 1; width: 44px; height: 44px; }
|
||||||
|
.track:has([data-playlist-actions]) .end { grid-column: 3 / -1; justify-content: flex-end; }
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
import { Fragment, useRef } from 'react';
|
import { Fragment, useRef, type ReactNode } from 'react';
|
||||||
|
import { AddToPlaylist } from './playlists';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
@@ -115,6 +116,7 @@ export function TrackList({
|
|||||||
rowMeta,
|
rowMeta,
|
||||||
lead,
|
lead,
|
||||||
disliked,
|
disliked,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
tracks: Track[];
|
tracks: Track[];
|
||||||
showAlbum?: boolean;
|
showAlbum?: boolean;
|
||||||
@@ -126,6 +128,7 @@ export function TrackList({
|
|||||||
lead?: (t: Track, i: number) => string;
|
lead?: (t: Track, i: number) => string;
|
||||||
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
/** Список дизлайков: строки приглушены, вместо лайка — «снять дизлайк». */
|
||||||
disliked?: boolean;
|
disliked?: boolean;
|
||||||
|
actions?: (track: Track, index: number) => ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
@@ -154,7 +157,7 @@ export function TrackList({
|
|||||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||||
title={tr.available ? undefined : t('catalog.unavailable')}
|
title={tr.available ? undefined : t('catalog.unavailable')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!(e.target as HTMLElement).closest('a, button')) play();
|
if (e.currentTarget.contains(e.target as Node) && !(e.target as HTMLElement).closest('a, button')) play();
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
if (e.key === 'Enter' && e.target === e.currentTarget) play();
|
||||||
@@ -181,7 +184,8 @@ export function TrackList({
|
|||||||
{tr.album.title}
|
{tr.album.title}
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
<span className={s.end}>
|
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
|
||||||
|
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
|
||||||
{disliked ? (
|
{disliked ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
|
||||||
|
import { queueIcon } from '../shared/ui/icons';
|
||||||
|
import s from './catalog.module.css';
|
||||||
|
|
||||||
|
export type Playlist = components['schemas']['Playlist'];
|
||||||
|
export type PlaylistInput = components['schemas']['PlaylistInput'];
|
||||||
|
|
||||||
|
export function usePlaylists(enabled = true) {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({ queryKey: ['playlists'], enabled,
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePlaylist(id: string) {
|
||||||
|
const session = useSession();
|
||||||
|
return useQuery({ queryKey: ['playlists', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
|
||||||
|
return { name: p.name, description: p.description, track_ids: p.tracks.map(t => t.id), revision: p.revision };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistEditor({ playlist, trackIds = [], onClose, onSaved }: {
|
||||||
|
playlist?: Playlist; trackIds?: string[]; onClose: () => void; onSaved: (p: Playlist) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [original] = useState(playlist);
|
||||||
|
const [name, setName] = useState(playlist?.name ?? '');
|
||||||
|
const [description, setDescription] = useState(playlist?.description ?? '');
|
||||||
|
const save = useMutation({ mutationFn: async () => {
|
||||||
|
const body = { name: name.trim(), description, track_ids: original?.tracks.map(t => t.id) ?? trackIds };
|
||||||
|
return original
|
||||||
|
? unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id: original.id } }, body: { ...body, revision: original.revision } }))
|
||||||
|
: unwrap(await session.client.POST('/api/v1/me/playlists', { body }));
|
||||||
|
}, onSuccess: p => {
|
||||||
|
cache.setQueryData(['playlists', p.id], p);
|
||||||
|
void cache.invalidateQueries({ queryKey: ['playlists'] });
|
||||||
|
onSaved(p);
|
||||||
|
}, onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||||
|
const close = useCallback(() => { if (!save.isPending) onClose(); }, [save.isPending, onClose]);
|
||||||
|
return <Dialog title={t(playlist ? 'playlists.edit' : 'playlists.create')} onClose={close}>
|
||||||
|
<form onSubmit={e => { e.preventDefault(); if (!save.isPending && name.trim()) save.mutate(); }}>
|
||||||
|
<Field label={t('playlists.name')} value={name} onChange={setName} maxLength={120} required autoFocus />
|
||||||
|
<Field label={t('playlists.description')} value={description} onChange={setDescription} maxLength={2000} />
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
<Button variant="primary" type="submit" busy={save.isPending} disabled={!name.trim()}>{t('playlists.save')}</Button>
|
||||||
|
<Button variant="ghost" onClick={onClose} disabled={save.isPending}>{t('form.cancel')}</Button>
|
||||||
|
</form>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mounted only while open, so catalog rows do not each subscribe to the playlist list. */
|
||||||
|
function PlaylistPicker({ trackIds, onClose }: { trackIds: string[]; onClose: () => void }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const list = usePlaylists();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const add = useMutation({ mutationFn: async (id: string) => {
|
||||||
|
const p = unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } }));
|
||||||
|
const body = playlistInput(p);
|
||||||
|
body.track_ids = [...new Set([...body.track_ids, ...trackIds])];
|
||||||
|
return unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body }));
|
||||||
|
}, onSuccess: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); onClose(); } });
|
||||||
|
if (create) return <PlaylistEditor trackIds={trackIds} onClose={() => setCreate(false)} onSaved={onClose} />;
|
||||||
|
return <Dialog title={t('playlists.add')} onClose={() => { if (!add.isPending) onClose(); }}>
|
||||||
|
<FormError>{list.error || add.error ? errorText(list.error ?? add.error) : null}</FormError>
|
||||||
|
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||||
|
{list.isSuccess && list.data.items.length === 0 && <p>{t('playlists.empty')}</p>}
|
||||||
|
<div style={{ display: 'grid', gap: 8 }}>
|
||||||
|
{list.data?.items.map(p => <Button key={p.id} disabled={add.isPending} onClick={() => add.mutate(p.id)}>{p.name} · {p.track_count}</Button>)}
|
||||||
|
<Button variant="primary" disabled={add.isPending} onClick={() => setCreate(true)}>{t('playlists.create')}</Button>
|
||||||
|
</div>
|
||||||
|
</Dialog>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddToPlaylist({ trackIds, compact = false }: { trackIds: string[]; compact?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return <>
|
||||||
|
{compact ? <button type="button" className={s.like} aria-label={t('playlists.add')} title={t('playlists.add')} onClick={() => setOpen(true)}><Icon src={queueIcon} size={18} /></button>
|
||||||
|
: <Button icon={queueIcon} disabled={trackIds.length === 0} onClick={() => setOpen(true)}>{t('playlists.add')}</Button>}
|
||||||
|
{open && <PlaylistPicker trackIds={trackIds} onClose={() => setOpen(false)} />}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
+12
-3
@@ -9,6 +9,7 @@ import { I18nextProvider } from 'react-i18next';
|
|||||||
import { App } from './App';
|
import { App } from './App';
|
||||||
import { createAppRouter } from './app/router';
|
import { createAppRouter } from './app/router';
|
||||||
import { applyTheme } from './app/session';
|
import { applyTheme } from './app/session';
|
||||||
|
import { createDesktop, DesktopProvider } from './desktop';
|
||||||
|
|
||||||
const LOCALE_KEY = 'pipu.locale';
|
const LOCALE_KEY = 'pipu.locale';
|
||||||
|
|
||||||
@@ -63,15 +64,23 @@ i18n.on('languageChanged', (l) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
|
const desktop = await createDesktop();
|
||||||
|
const session = new AuthSession({
|
||||||
|
clientType: desktop ? 'desktop' : 'web',
|
||||||
|
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
|
||||||
|
baseUrl: desktop?.proxyBase,
|
||||||
|
refreshStore: desktop?.refreshStore,
|
||||||
|
});
|
||||||
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
|
||||||
await session.restore();
|
await session.restore();
|
||||||
const router = createAppRouter(session);
|
const router = createAppRouter(session, { desktop });
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<I18nextProvider i18n={i18n}>
|
<I18nextProvider i18n={i18n}>
|
||||||
<App session={session} router={router} />
|
<DesktopProvider value={desktop}>
|
||||||
|
<App session={session} router={router} />
|
||||||
|
</DesktopProvider>
|
||||||
</I18nextProvider>
|
</I18nextProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
import { Cover, TrackList } from '../entities/catalog';
|
import { Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { AddToPlaylist } from '../entities/playlists';
|
||||||
import { usePlayer } from '../player/context';
|
import { usePlayer } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
@@ -45,6 +46,7 @@ export function AlbumPage({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
{a && a.tracks.some((tr) => tr.available) && (
|
{a && a.tracks.some((tr) => tr.available) && (
|
||||||
<div className={c.heroActions}>
|
<div className={c.heroActions}>
|
||||||
|
<AddToPlaylist trackIds={a.tracks.map(tr => tr.id)} />
|
||||||
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
<Button variant="primary" className={c.pill} icon={playIcon} onClick={() => player.playList(a.tracks, 0, ctx)}>
|
||||||
{t('catalog.play')}
|
{t('catalog.play')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useState, type FormEvent } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRouter } from '@tanstack/react-router';
|
||||||
|
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
import s from './auth.module.css';
|
||||||
|
|
||||||
|
export function ConnectPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const router = useRouter();
|
||||||
|
const [address, setAddress] = useState(desktop?.server ?? '');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!desktop) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await desktop.setServer(address);
|
||||||
|
await router.invalidate();
|
||||||
|
await router.navigate({ to: '/login' });
|
||||||
|
} catch (err) {
|
||||||
|
const text = err instanceof Error ? err.message : '';
|
||||||
|
setError(/Invalid|query|fragment|credentials/i.test(text) ? t('server.invalid_address') : t('server.offline'));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout>
|
||||||
|
<h1>{t('server.title')}</h1>
|
||||||
|
<p className={s.lead}>{t('app.tagline')}</p>
|
||||||
|
<form onSubmit={(e) => void submit(e)} noValidate>
|
||||||
|
<Field
|
||||||
|
label={t('server.address')}
|
||||||
|
value={address}
|
||||||
|
onChange={setAddress}
|
||||||
|
autoComplete="url"
|
||||||
|
autoCapitalize="none"
|
||||||
|
spellCheck={false}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormError>{error}</FormError>
|
||||||
|
<Button type="submit" variant="primary" className={s.submit} busy={busy} disabled={!address.trim()}>
|
||||||
|
{t('mobile.connect')}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useState, type FormEvent } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../app/session';
|
import { useSession } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useErrorText } from '../shared/lib/errors';
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
import { Button, Field, FormError } from '../shared/ui/ui';
|
import { Button, Field, FormError } from '../shared/ui/ui';
|
||||||
import { AuthLayout } from './AuthLayout';
|
import { AuthLayout } from './AuthLayout';
|
||||||
@@ -11,6 +12,7 @@ import s from './auth.module.css';
|
|||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { t } = useTranslation(['auth', 'common']);
|
const { t } = useTranslation(['auth', 'common']);
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -41,7 +43,13 @@ export function LoginPage() {
|
|||||||
footer={
|
footer={
|
||||||
info.data ? (
|
info.data ? (
|
||||||
<>
|
<>
|
||||||
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
pipu {info.data.version}
|
||||||
|
{desktop ? null : (
|
||||||
|
<>
|
||||||
|
{' · '}
|
||||||
|
<a href="/android">{t('app.link')}</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
) : undefined
|
) : undefined
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Link, useNavigate } from '@tanstack/react-router';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist } from '../entities/playlists';
|
||||||
|
import { usePlayer } from '../player/context';
|
||||||
|
import { useErrorText } from '../shared/lib/errors';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { Button, Dialog, Field, FormError } from '../shared/ui/ui';
|
||||||
|
import { playIcon, shuffleIcon } from '../shared/ui/icons';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import p from './playlists.module.css';
|
||||||
|
|
||||||
|
export function PlaylistsPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const list = usePlaylists();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [create, setCreate] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const items = list.data?.items.filter(x => x.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())) ?? [];
|
||||||
|
return <div className={s.page}>
|
||||||
|
<header className={s.toolbar}><h1>{t('playlists.title')}</h1><Button variant="primary" onClick={() => setCreate(true)}>{t('playlists.create')}</Button></header>
|
||||||
|
<Field label={t('playlists.search')} value={search} onChange={setSearch} type="search" />
|
||||||
|
<FormError>{list.error ? errorText(list.error) : null}</FormError>
|
||||||
|
{list.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||||
|
{list.isSuccess && !items.length && <p className={s.empty}>{t(search ? 'playlists.no_results' : 'playlists.empty')}</p>}
|
||||||
|
<div className={p.grid}>{items.map(item => <Link key={item.id} to="/playlist/$id" params={{ id: item.id }} className={p.card}>
|
||||||
|
<Cover album={item} size="100%" />
|
||||||
|
<strong>{item.name}</strong><span>{t('catalog.track_count', { count: item.track_count })} · {formatDuration(item.duration_ms)}</span>
|
||||||
|
{item.description && <span className={p.description}>{item.description}</span>}
|
||||||
|
</Link>)}</div>
|
||||||
|
{create && <PlaylistEditor onClose={() => setCreate(false)} onSaved={item => { setCreate(false); void navigate({ to: '/playlist/$id', params: { id: item.id } }); }} />}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlaylistPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const query = usePlaylist(id);
|
||||||
|
const errorText = useErrorText();
|
||||||
|
return <div className={s.page}>
|
||||||
|
<Link to="/collection/playlists">← {t('playlists.title')}</Link>
|
||||||
|
<FormError>{query.error ? errorText(query.error) : null}</FormError>
|
||||||
|
{query.isPending && <p role="status">{t('playlists.loading')}</p>}
|
||||||
|
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const cache = useQueryClient();
|
||||||
|
const player = usePlayer();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const errorText = useErrorText();
|
||||||
|
const [edit, setEdit] = useState(false);
|
||||||
|
const [remove, setRemove] = useState(false);
|
||||||
|
const [manage, setManage] = useState(false);
|
||||||
|
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
|
||||||
|
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
|
||||||
|
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
|
||||||
|
})), onSuccess: data => { cache.setQueryData(['playlists', playlist.id], data); void cache.invalidateQueries({ queryKey: ['playlists'] }); },
|
||||||
|
onError: () => { void cache.invalidateQueries({ queryKey: ['playlists'] }); } });
|
||||||
|
const deletion = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}', { params: { path: { id: playlist.id } } })),
|
||||||
|
onSuccess: () => { cache.removeQueries({ queryKey: ['playlists', playlist.id] }); void cache.invalidateQueries({ queryKey: ['playlists'] }); void navigate({ to: '/collection/playlists' }); } });
|
||||||
|
const play = (shuffle: boolean) => {
|
||||||
|
const tracks = playlist.tracks.filter(x => x.available);
|
||||||
|
if (shuffle) for (let i = tracks.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [tracks[i], tracks[j]] = [tracks[j]!, tracks[i]!]; }
|
||||||
|
if (tracks.length) player.playList(tracks, 0, context);
|
||||||
|
};
|
||||||
|
const move = (index: number, delta: number) => {
|
||||||
|
const ids = playlist.tracks.map(x => x.id);
|
||||||
|
[ids[index], ids[index + delta]] = [ids[index + delta]!, ids[index]!];
|
||||||
|
save.mutate(ids);
|
||||||
|
};
|
||||||
|
return <>
|
||||||
|
<header className={c.colHero}>
|
||||||
|
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
|
||||||
|
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
|
||||||
|
<p className={p.description}>{playlist.description}</p>
|
||||||
|
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className={s.toolbar}>
|
||||||
|
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
|
||||||
|
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
|
||||||
|
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
|
||||||
|
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
|
||||||
|
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
|
||||||
|
</div>
|
||||||
|
<FormError>{save.error ? errorText(save.error) : null}</FormError>
|
||||||
|
{!playlist.tracks.length && <div className={s.empty}><p>{t('playlists.empty_tracks')}</p><Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link></div>}
|
||||||
|
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
|
||||||
|
actions={manage ? (track, i) => <span className={p.actions}>
|
||||||
|
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
|
||||||
|
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
|
||||||
|
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
|
||||||
|
</span> : undefined} />
|
||||||
|
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
|
||||||
|
{remove && <Dialog title={t('playlists.delete')} onClose={() => { if (!deletion.isPending) setRemove(false); }} footer={<>
|
||||||
|
<Button disabled={deletion.isPending} onClick={() => setRemove(false)}>{t('form.cancel')}</Button>
|
||||||
|
<Button variant="danger" busy={deletion.isPending} onClick={() => deletion.mutate()}>{t('playlists.delete')}</Button>
|
||||||
|
</>}><p>{t('playlists.delete_confirm', { name: playlist.name })}</p><FormError>{deletion.error ? errorText(deletion.error) : null}</FormError></Dialog>}
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession, useUser } from '../app/session';
|
import { useSession, useUser } from '../app/session';
|
||||||
|
import { useDesktop } from '../desktop';
|
||||||
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
import { useErrorText, useFieldError } from '../shared/lib/errors';
|
||||||
import { formatDateTime } from '../shared/lib/format';
|
import { formatDateTime } from '../shared/lib/format';
|
||||||
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
|
||||||
@@ -297,6 +299,8 @@ function PasswordSection() {
|
|||||||
function SessionsSection() {
|
function SessionsSection() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
const desktop = useDesktop();
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const errorText = useErrorText();
|
const errorText = useErrorText();
|
||||||
const sessions = useQuery({
|
const sessions = useQuery({
|
||||||
@@ -333,6 +337,19 @@ function SessionsSection() {
|
|||||||
</div>
|
</div>
|
||||||
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
|
||||||
<div>
|
<div>
|
||||||
|
{desktop && (
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
void (async () => {
|
||||||
|
await desktop.clearServer();
|
||||||
|
await session.logout();
|
||||||
|
await navigate({ to: '/connect' });
|
||||||
|
})();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('mobile.change_server')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button variant="danger" onClick={() => void session.logout(true)}>
|
<Button variant="danger" onClick={() => void session.logout(true)}>
|
||||||
{t('settings.logout_all')}
|
{t('settings.logout_all')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { unwrap } from '@pipu/core';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from '@tanstack/react-router';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useSession } from '../app/session';
|
||||||
|
import { ArtistLinks, Cover, TrackList } from '../entities/catalog';
|
||||||
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { NotFound } from './NotFound';
|
||||||
|
import s from './page.module.css';
|
||||||
|
import c from './catalog.module.css';
|
||||||
|
|
||||||
|
export function TrackPage({ id }: { id: string }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
|
const track = useQuery({
|
||||||
|
queryKey: ['track', id],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })),
|
||||||
|
});
|
||||||
|
if (track.isError) return <NotFound error={track.error} />;
|
||||||
|
const tr = track.data;
|
||||||
|
return (
|
||||||
|
<div className={s.page}>
|
||||||
|
<header className={c.hero}>
|
||||||
|
{tr && <Cover album={tr.album} size={200} />}
|
||||||
|
<div className={c.heroBody}>
|
||||||
|
<span className={c.kind}>{t('player.track_page')}</span>
|
||||||
|
<h1 className={c.heroTitle}>{tr?.title ?? '…'}</h1>
|
||||||
|
{tr && <>
|
||||||
|
<span className={c.heroMeta}><ArtistLinks artists={tr.artists} /> · {formatDuration(tr.duration_ms)}</span>
|
||||||
|
<Link to="/album/$id" params={{ id: tr.album.id }}>{tr.album.title}</Link>
|
||||||
|
</>}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
{tr && <TrackList tracks={[tr]} context={{ type: 'queue' }} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ type Entry = components['schemas']['AuditEntry'];
|
|||||||
function details(e: Entry, roleName: (r: string) => string): string {
|
function details(e: Entry, roleName: (r: string) => string): string {
|
||||||
const p = e.payload ?? {};
|
const p = e.payload ?? {};
|
||||||
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
|
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(
|
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
|
||||||
(v): v is string => typeof v === 'string' && v !== '',
|
(v): v is string => typeof v === 'string' && v !== '',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export function UsersPage() {
|
|||||||
const [role, setRole] = useState<Role | null>(null);
|
const [role, setRole] = useState<Role | null>(null);
|
||||||
const query = useDebounced(q.trim(), 200);
|
const query = useDebounced(q.trim(), 200);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [renaming, setRenaming] = useState<AdminUser | null>(null);
|
||||||
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
const [confirm, setConfirm] = useState<Confirm | null>(null);
|
||||||
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
|
||||||
const [actionError, setActionError] = useState<unknown>(null);
|
const [actionError, setActionError] = useState<unknown>(null);
|
||||||
@@ -112,6 +113,8 @@ export function UsersPage() {
|
|||||||
const actionsFor = (u: AdminUser): MenuItem[] => {
|
const actionsFor = (u: AdminUser): MenuItem[] => {
|
||||||
const items: MenuItem[] = [];
|
const items: MenuItem[] = [];
|
||||||
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
|
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'))
|
if (can('reset_password'))
|
||||||
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
|
||||||
if (can('reset_recommendations'))
|
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 && (
|
{confirm && (
|
||||||
<Dialog
|
<Dialog
|
||||||
title={t(`users.${TITLES[confirm.kind]}`)}
|
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 }) {
|
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
|
||||||
const { t } = useTranslation(['admin', 'common']);
|
const { t } = useTranslation(['admin', 'common']);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 24px; margin-top: 24px; }
|
||||||
|
.card { display: flex; flex-direction: column; gap: 8px; color: inherit; text-decoration: none; min-width: 0; border-radius: 16px; padding: 12px; background: var(--surface, #ffffff08); }
|
||||||
|
.card > :first-child { aspect-ratio: 1; border-radius: 12px; }
|
||||||
|
.card strong { overflow-wrap: anywhere; }
|
||||||
|
.card span { font-size: 13px; }
|
||||||
|
.card:hover { background: #ffffff10; }
|
||||||
|
.description { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.card .description { overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
|
||||||
|
.actions { display: flex; align-items: center; }
|
||||||
|
.actions button { min-width: 44px; min-height: 44px; border: 0; background: transparent; color: inherit; cursor: pointer; font-size: 20px; }
|
||||||
|
.actions button:disabled { opacity: .3; cursor: default; }
|
||||||
|
@media (max-width: 600px) { .grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } .card { padding: 8px; } }
|
||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
type Track,
|
type Track,
|
||||||
} from '@pipu/core';
|
} from '@pipu/core';
|
||||||
|
|
||||||
|
import { desktopHandle } from '../desktop';
|
||||||
|
|
||||||
export type { PlayContext, Track };
|
export type { PlayContext, Track };
|
||||||
|
|
||||||
export interface WaveInfo {
|
export interface WaveInfo {
|
||||||
@@ -97,6 +99,7 @@ export class Player {
|
|||||||
private timers: ReturnType<typeof setInterval>[] = [];
|
private timers: ReturnType<typeof setInterval>[] = [];
|
||||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||||
private waveLoading = false;
|
private waveLoading = false;
|
||||||
|
private restoreVersion = 0;
|
||||||
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
|
||||||
onReaction: (() => void) | null = null;
|
onReaction: (() => void) | null = null;
|
||||||
|
|
||||||
@@ -120,6 +123,7 @@ export class Player {
|
|||||||
};
|
};
|
||||||
this.bindAudio();
|
this.bindAudio();
|
||||||
this.bindMediaSession();
|
this.bindMediaSession();
|
||||||
|
this.bindDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
/** Запуск фоновой отправки событий (парный вызов — dispose). */
|
||||||
@@ -136,9 +140,13 @@ export class Player {
|
|||||||
|
|
||||||
getState = () => this.state;
|
getState = () => this.state;
|
||||||
|
|
||||||
|
private lastDesktop: { id?: string; playing?: boolean } = {};
|
||||||
|
private desktopUnlisten: (() => void) | null = null;
|
||||||
|
|
||||||
private update(patch: Partial<PlayerState>) {
|
private update(patch: Partial<PlayerState>) {
|
||||||
this.state = { ...this.state, ...patch };
|
this.state = { ...this.state, ...patch };
|
||||||
for (const fn of this.listeners) fn();
|
for (const fn of this.listeners) fn();
|
||||||
|
this.syncDesktopMedia();
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncQueue() {
|
private syncQueue() {
|
||||||
@@ -159,6 +167,7 @@ export class Player {
|
|||||||
const playable = tracks.filter((t) => t.available && t.stream_url);
|
const playable = tracks.filter((t) => t.available && t.stream_url);
|
||||||
const target = tracks[index];
|
const target = tracks[index];
|
||||||
if (!target || !playable.length) return;
|
if (!target || !playable.length) return;
|
||||||
|
this.restoreVersion++;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
this.context = context;
|
this.context = context;
|
||||||
@@ -171,6 +180,7 @@ export class Player {
|
|||||||
|
|
||||||
/** Запустить волну (или перезапустить с новым контекстом). */
|
/** Запустить волну (или перезапустить с новым контекстом). */
|
||||||
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
|
||||||
|
this.restoreVersion++;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.leaveWave();
|
this.leaveWave();
|
||||||
const res = unwrap(
|
const res = unwrap(
|
||||||
@@ -275,7 +285,7 @@ export class Player {
|
|||||||
|
|
||||||
play() {
|
play() {
|
||||||
if (!this.queue.current) return;
|
if (!this.queue.current) return;
|
||||||
if (!this.audio.src) {
|
if (!this.audio.src || !this.attempt) {
|
||||||
void this.load(true, this.state.positionMs);
|
void this.load(true, this.state.positionMs);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -388,9 +398,12 @@ export class Player {
|
|||||||
private async load(autoplay: boolean, startMs = 0) {
|
private async load(autoplay: boolean, startMs = 0) {
|
||||||
const track = this.queue.current;
|
const track = this.queue.current;
|
||||||
if (!track?.stream_url) return;
|
if (!track?.stream_url) return;
|
||||||
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
|
||||||
this.audio.src = track.stream_url;
|
// Keep the restored element's buffer when the user presses Play.
|
||||||
if (startMs > 0) this.audio.currentTime = startMs / 1000;
|
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
|
||||||
|
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
|
||||||
|
this.updateMediaMetadata(track);
|
||||||
|
if (!autoplay) return;
|
||||||
this.applyLoudness();
|
this.applyLoudness();
|
||||||
this.attempt = new PlayAttempt(track, startMs);
|
this.attempt = new PlayAttempt(track, startMs);
|
||||||
this.preloaded = null;
|
this.preloaded = null;
|
||||||
@@ -401,7 +414,6 @@ export class Player {
|
|||||||
context_type: this.context?.type,
|
context_type: this.context?.type,
|
||||||
context_id: this.context?.id,
|
context_id: this.context?.id,
|
||||||
});
|
});
|
||||||
this.updateMediaMetadata(track);
|
|
||||||
this.saveServer();
|
this.saveServer();
|
||||||
void this.refillWave();
|
void this.refillWave();
|
||||||
if (autoplay) {
|
if (autoplay) {
|
||||||
@@ -597,22 +609,28 @@ export class Player {
|
|||||||
if (a.ended) return;
|
if (a.ended) return;
|
||||||
this.update({ playing: false });
|
this.update({ playing: false });
|
||||||
const t = this.queue.current;
|
const t = this.queue.current;
|
||||||
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
if (t && this.attempt) {
|
||||||
this.saveServer();
|
this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
|
||||||
|
this.saveServer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
a.addEventListener('waiting', () => {
|
||||||
|
if (this.attempt) this.update({ loading: true });
|
||||||
});
|
});
|
||||||
a.addEventListener('waiting', () => this.update({ loading: true }));
|
|
||||||
a.addEventListener('timeupdate', () => {
|
a.addEventListener('timeupdate', () => {
|
||||||
const ms = Math.round(a.currentTime * 1000);
|
const ms = Math.round(a.currentTime * 1000);
|
||||||
this.attempt?.tick(ms);
|
this.attempt?.tick(ms);
|
||||||
this.update({ positionMs: ms });
|
this.update({ positionMs: ms });
|
||||||
this.updatePositionState();
|
this.updatePositionState();
|
||||||
this.maybePreload(ms);
|
if (this.state.playing) {
|
||||||
this.maybeCrossfade(ms);
|
this.maybePreload(ms);
|
||||||
|
this.maybeCrossfade(ms);
|
||||||
|
}
|
||||||
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
|
||||||
this.saveLocal();
|
this.saveLocal();
|
||||||
});
|
});
|
||||||
a.addEventListener('ended', () => {
|
a.addEventListener('ended', () => {
|
||||||
if (this.ignoreEnded || this.fading) return;
|
if (this.ignoreEnded || this.fading || !this.attempt) return;
|
||||||
this.finishAttempt('play_end');
|
this.finishAttempt('play_end');
|
||||||
if (this.queue.next(true)) {
|
if (this.queue.next(true)) {
|
||||||
this.syncQueue();
|
this.syncQueue();
|
||||||
@@ -629,11 +647,18 @@ export class Player {
|
|||||||
private async onError() {
|
private async onError() {
|
||||||
const track = this.queue.current;
|
const track = this.queue.current;
|
||||||
if (!track) return;
|
if (!track) return;
|
||||||
|
// A speculative load must never start playback or skip the saved track.
|
||||||
|
if (!this.attempt) {
|
||||||
|
this.audio.removeAttribute('src');
|
||||||
|
this.audio.load();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (this.refreshedFor !== track.id) {
|
if (this.refreshedFor !== track.id) {
|
||||||
this.refreshedFor = track.id;
|
this.refreshedFor = track.id;
|
||||||
try {
|
try {
|
||||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
|
||||||
const fresh = res.items[0];
|
const fresh = res.items[0];
|
||||||
|
if (this.queue.current !== track || !this.attempt) return;
|
||||||
if (fresh?.stream_url) {
|
if (fresh?.stream_url) {
|
||||||
Object.assign(track, fresh);
|
Object.assign(track, fresh);
|
||||||
void this.load(true, this.state.positionMs);
|
void this.load(true, this.state.positionMs);
|
||||||
@@ -660,6 +685,33 @@ export class Player {
|
|||||||
|
|
||||||
// --- Media Session ---
|
// --- Media Session ---
|
||||||
|
|
||||||
|
private bindDesktopMedia() {
|
||||||
|
void desktopHandle()?.listenMedia((action) => {
|
||||||
|
if (action === 'play') this.play();
|
||||||
|
else if (action === 'pause' || action === 'stop') this.pause();
|
||||||
|
else if (action === 'toggle') this.toggle();
|
||||||
|
else if (action === 'next') this.next();
|
||||||
|
else if (action === 'previous') this.prev();
|
||||||
|
}).then((stop) => {
|
||||||
|
this.desktopUnlisten = stop;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncDesktopMedia() {
|
||||||
|
const desktop = desktopHandle();
|
||||||
|
const track = this.state.track;
|
||||||
|
if (!desktop || !track) return;
|
||||||
|
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
|
||||||
|
this.lastDesktop = { id: track.id, playing: this.state.playing };
|
||||||
|
desktop.setMedia({
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artists.map((a) => a.name).join(', '),
|
||||||
|
album: track.album.title,
|
||||||
|
coverUrl: track.album.cover_url,
|
||||||
|
playing: this.state.playing,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private bindMediaSession() {
|
private bindMediaSession() {
|
||||||
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
|
||||||
if (!ms) return;
|
if (!ms) return;
|
||||||
@@ -775,6 +827,7 @@ export class Player {
|
|||||||
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
|
||||||
*/
|
*/
|
||||||
async restore() {
|
async restore() {
|
||||||
|
const version = ++this.restoreVersion;
|
||||||
let local: Saved | null = null;
|
let local: Saved | null = null;
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(LOCAL_KEY);
|
const raw = localStorage.getItem(LOCAL_KEY);
|
||||||
@@ -804,8 +857,9 @@ export class Player {
|
|||||||
} catch {
|
} catch {
|
||||||
// офлайн — остаёмся с локальной копией
|
// офлайн — остаёмся с локальной копией
|
||||||
}
|
}
|
||||||
if (!saved?.queue.length) return;
|
if (version !== this.restoreVersion || !saved?.queue.length) return;
|
||||||
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
|
||||||
|
if (version !== this.restoreVersion) return;
|
||||||
const wanted = saved.queue[saved.index];
|
const wanted = saved.queue[saved.index];
|
||||||
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
const tracks = res.items.filter((t) => t.available && t.stream_url);
|
||||||
if (!tracks.length) return;
|
if (!tracks.length) return;
|
||||||
@@ -819,14 +873,24 @@ export class Player {
|
|||||||
this.syncQueue();
|
this.syncQueue();
|
||||||
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
|
||||||
this.updateMediaMetadata(tracks[idx]!);
|
this.updateMediaMetadata(tracks[idx]!);
|
||||||
|
// Prepare at the saved position without creating a listening attempt.
|
||||||
|
await this.load(false, pos);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispose() {
|
dispose() {
|
||||||
|
this.restoreVersion++;
|
||||||
|
this.fading = false;
|
||||||
this.finishAttempt('skip');
|
this.finishAttempt('skip');
|
||||||
this.audio.pause();
|
this.audio.pause();
|
||||||
this.audio.removeAttribute('src');
|
this.audio.removeAttribute('src');
|
||||||
|
this.audio.load();
|
||||||
|
this.overlap.pause();
|
||||||
|
this.overlap.removeAttribute('src');
|
||||||
|
this.overlap.load();
|
||||||
for (const t of this.timers) clearInterval(t);
|
for (const t of this.timers) clearInterval(t);
|
||||||
this.timers = [];
|
this.timers = [];
|
||||||
|
this.desktopUnlisten?.();
|
||||||
|
this.desktopUnlisten = null;
|
||||||
void this.events.flush();
|
void this.events.flush();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type { AuthSession, Track } from '@pipu/core';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { Player } from './player';
|
||||||
|
|
||||||
|
const track: Track = {
|
||||||
|
id: 'a', title: 'A', duration_ms: 200_000, artists: [],
|
||||||
|
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
|
||||||
|
genres: [], disc_no: null, track_no: null, available: true,
|
||||||
|
stream_url: '/s/a', reaction: null, features: null,
|
||||||
|
};
|
||||||
|
const ok = (data: unknown) => ({ data, response: new Response() });
|
||||||
|
|
||||||
|
function fixture() {
|
||||||
|
localStorage.removeItem('pipu.player');
|
||||||
|
const client = {
|
||||||
|
GET: vi.fn().mockResolvedValue(ok({
|
||||||
|
track_id: 'a', queue: ['a'], queue_index: 0, position_ms: 42_000,
|
||||||
|
volume: 0.8, shuffle: false, repeat_mode: 'off', updated_at: '2026-10-21',
|
||||||
|
})),
|
||||||
|
POST: vi.fn().mockResolvedValue(ok({ items: [track] })),
|
||||||
|
PUT: vi.fn().mockResolvedValue(ok({})),
|
||||||
|
};
|
||||||
|
const audio = document.createElement('audio');
|
||||||
|
const player = new Player({ client, state: { status: 'signed_out' } } as unknown as AuthSession, audio);
|
||||||
|
return { player, audio, client };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
|
||||||
|
|
||||||
|
describe('restored audio preparation', () => {
|
||||||
|
it('buffers at the saved position without playback or listening events, then reuses the source', async () => {
|
||||||
|
const { player, audio, client } = fixture();
|
||||||
|
const play = vi.spyOn(audio, 'play');
|
||||||
|
const source = vi.spyOn(audio, 'src', 'set');
|
||||||
|
await player.restore();
|
||||||
|
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||||
|
expect(audio.currentTime).toBe(42);
|
||||||
|
expect(audio.preload).toBe('auto');
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(client.PUT).not.toHaveBeenCalled();
|
||||||
|
expect(player.getState()).toMatchObject({ playing: false, loading: false, positionMs: 42_000 });
|
||||||
|
player.onHide();
|
||||||
|
expect(client.POST).toHaveBeenCalledTimes(1); // Only the track lookup, no listening events.
|
||||||
|
player.play();
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(source).toHaveBeenCalledTimes(1);
|
||||||
|
expect(player.getState().playing).toBe(true);
|
||||||
|
player.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not autoplay or skip after a preload failure; Play retries the source', async () => {
|
||||||
|
const { player, audio } = fixture();
|
||||||
|
const play = vi.spyOn(audio, 'play');
|
||||||
|
await player.restore();
|
||||||
|
audio.dispatchEvent(new Event('error'));
|
||||||
|
audio.dispatchEvent(new Event('ended'));
|
||||||
|
expect(play).not.toHaveBeenCalled();
|
||||||
|
expect(player.getState()).toMatchObject({ track: { id: 'a' }, positionMs: 42_000, playing: false });
|
||||||
|
player.play();
|
||||||
|
expect(audio.getAttribute('src')).toBe('/s/a');
|
||||||
|
expect(play).toHaveBeenCalledTimes(1);
|
||||||
|
player.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(['selection', 'dispose'])('ignores a restore lookup completed after %s', async (action) => {
|
||||||
|
const { player, client, audio } = fixture();
|
||||||
|
let resolve!: (value: unknown) => void;
|
||||||
|
client.POST.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||||||
|
const restoring = player.restore();
|
||||||
|
await vi.waitFor(() => expect(client.POST).toHaveBeenCalled());
|
||||||
|
if (action === 'selection') player.playList([{ ...track, id: 'b', stream_url: '/s/b' }], 0, { type: 'search' });
|
||||||
|
else player.dispose();
|
||||||
|
resolve(ok({ items: [track] }));
|
||||||
|
await restoring;
|
||||||
|
expect(audio.getAttribute('src')).toBe(action === 'selection' ? '/s/b' : null);
|
||||||
|
if (action === 'selection') player.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { shareTrack } from './share';
|
||||||
|
|
||||||
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
|
describe('shareTrack', () => {
|
||||||
|
it('copies on desktop even when native sharing exists', async () => {
|
||||||
|
const share = vi.fn();
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Windows');
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Windows', share, clipboard: { writeText } });
|
||||||
|
try {
|
||||||
|
expect(await shareTrack('id', 'Title')).toBe('copied');
|
||||||
|
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/track/id`);
|
||||||
|
expect(share).not.toHaveBeenCalled();
|
||||||
|
} finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens Android sharing with a page URL', async () => {
|
||||||
|
const share = vi.fn().mockResolvedValue(undefined);
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||||
|
try {
|
||||||
|
expect(await shareTrack('id', 'Title')).toBe('shared');
|
||||||
|
expect(share).toHaveBeenCalledWith({ title: 'Title', url: `${window.location.origin}/track/id` });
|
||||||
|
} finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not copy or report an error when the share sheet is dismissed', async () => {
|
||||||
|
const share = vi.fn().mockRejectedValue(new DOMException('Cancelled', 'AbortError'));
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Android', share });
|
||||||
|
try { expect(await shareTrack('id', 'Title')).toBe('cancelled'); }
|
||||||
|
finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reports clipboard failures instead of pretending the link was copied', async () => {
|
||||||
|
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText: vi.fn().mockRejectedValue(new Error('Denied')) } });
|
||||||
|
try { await expect(shareTrack('id', 'Title')).rejects.toThrow('Denied'); }
|
||||||
|
finally { vi.unstubAllGlobals(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { trackShareUrl } from '@pipu/core';
|
||||||
|
|
||||||
|
/** Desktop always copies; mobile browsers can open their native share sheet. */
|
||||||
|
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
|
||||||
|
const url = trackShareUrl(window.location.origin, id);
|
||||||
|
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({ title, url });
|
||||||
|
return 'shared';
|
||||||
|
} catch (error) {
|
||||||
|
if (typeof error === 'object' && error !== null && 'name' in error && error.name === 'AbortError') return 'cancelled';
|
||||||
|
// If sharing is unavailable, still allow copying the link.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
return 'copied';
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
|
|||||||
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
|
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
|
||||||
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
|
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
|
||||||
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
|
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
|
||||||
|
export { default as shareIcon } from '@material-symbols/svg-400/rounded/share.svg';
|
||||||
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
|
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
|
||||||
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
|
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
|
||||||
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
|
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
let token = 0;
|
let token = 0;
|
||||||
const calls: { method: string; path: string; body: unknown }[] = [];
|
const calls: { method: string; path: string; body: unknown }[] = [];
|
||||||
const reactions = new Map<string, 'like' | 'dislike'>();
|
const reactions = new Map<string, 'like' | 'dislike'>();
|
||||||
|
const playlists = new Map<string, components['schemas']['Playlist']>();
|
||||||
|
|
||||||
const me = (u: FakeUser): Me => ({
|
const me = (u: FakeUser): Me => ({
|
||||||
id: u.id,
|
id: u.id,
|
||||||
@@ -127,6 +128,23 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
|
|
||||||
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
|
||||||
|
|
||||||
|
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
|
||||||
|
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
|
||||||
|
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
|
||||||
|
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
|
||||||
|
const old = playlists.get(id);
|
||||||
|
if (playlistPath && !old) return problem(404, 'playlist.not_found');
|
||||||
|
if (req.method === 'GET') return json(old);
|
||||||
|
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
|
||||||
|
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
|
||||||
|
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
|
||||||
|
const item = { id, name: body?.name as string, description: body?.description as string, tracks,
|
||||||
|
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
|
||||||
|
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
|
||||||
|
playlists.set(id, item);
|
||||||
|
return json(item, old ? 200 : 201);
|
||||||
|
}
|
||||||
|
|
||||||
switch (route) {
|
switch (route) {
|
||||||
case 'POST /api/v1/auth/logout':
|
case 'POST /api/v1/auth/logout':
|
||||||
case 'POST /api/v1/auth/logout-all':
|
case 'POST /api/v1/auth/logout-all':
|
||||||
@@ -193,11 +211,14 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
|
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
|
||||||
const q = url.searchParams.get('q') ?? '';
|
const q = url.searchParams.get('q') ?? '';
|
||||||
const hit = q === 'кино' || q === 'rbyj';
|
const hit = q === 'кино' || q === 'rbyj';
|
||||||
|
const tracks = path === '/api/v1/search' && hit
|
||||||
|
? albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null }))
|
||||||
|
: [];
|
||||||
return json({
|
return json({
|
||||||
artists: hit ? [kino] : [],
|
artists: hit ? [kino] : [],
|
||||||
albums: hit ? [albumSummary] : [],
|
albums: hit ? [albumSummary] : [],
|
||||||
tracks: [],
|
tracks,
|
||||||
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
|
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: tracks.length },
|
||||||
corrected_query: q === 'rbyj' ? 'кино' : null,
|
corrected_query: q === 'rbyj' ? 'кино' : null,
|
||||||
next_cursor: null,
|
next_cursor: null,
|
||||||
});
|
});
|
||||||
@@ -243,6 +264,13 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
|
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
|
||||||
const target = users.get(userPath[1]!);
|
const target = users.get(userPath[1]!);
|
||||||
if (!target) return problem(404, 'common.not_found');
|
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 (typeof body?.reviews_uploads === 'boolean') {
|
||||||
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
|
||||||
target.reviews_uploads = body.reviews_uploads;
|
target.reviews_uploads = body.reviews_uploads;
|
||||||
@@ -253,6 +281,11 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
return json(admin(target));
|
return json(admin(target));
|
||||||
}
|
}
|
||||||
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
|
||||||
|
const trackPath = /^\/api\/v1\/tracks\/([^/]+)$/.exec(path);
|
||||||
|
if (trackPath && req.method === 'GET') {
|
||||||
|
const track = albumTracks.find(t => t.id === trackPath[1]);
|
||||||
|
return track ? json({ ...track, reaction: reactions.get(track.id) ?? null }) : problem(404, 'common.not_found');
|
||||||
|
}
|
||||||
if (reactionPath) {
|
if (reactionPath) {
|
||||||
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
|
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
|
||||||
else reactions.delete(reactionPath[1]!);
|
else reactions.delete(reactionPath[1]!);
|
||||||
@@ -261,5 +294,5 @@ export function fakeApi(initial: FakeUser[]) {
|
|||||||
return problem(404, 'common.not_found');
|
return problem(404, 'common.not_found');
|
||||||
};
|
};
|
||||||
|
|
||||||
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
|
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { queueIcon } from '../shared/ui/icons';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
|
||||||
@@ -46,6 +47,7 @@ type NavPath =
|
|||||||
| '/uploads/new'
|
| '/uploads/new'
|
||||||
| '/uploads/mine'
|
| '/uploads/mine'
|
||||||
| '/admin/settings'
|
| '/admin/settings'
|
||||||
|
| '/collection/playlists'
|
||||||
| '/collection/history'
|
| '/collection/history'
|
||||||
| '/collection/likes'
|
| '/collection/likes'
|
||||||
| '/collection/dislikes';
|
| '/collection/dislikes';
|
||||||
@@ -169,6 +171,7 @@ function Shell() {
|
|||||||
<>
|
<>
|
||||||
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
|
||||||
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
|
||||||
|
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
|
||||||
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
|
||||||
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
|
||||||
<NavLink
|
<NavLink
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
.bar {
|
.bar {
|
||||||
|
position: relative;
|
||||||
grid-area: player;
|
grid-area: player;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
|
||||||
@@ -310,7 +311,7 @@ a.source:hover {
|
|||||||
.controls { gap: 2px; }
|
.controls { gap: 2px; }
|
||||||
.play, .icon { width: 44px; height: 44px; }
|
.play, .icon { width: 44px; height: 44px; }
|
||||||
.right { display: flex; gap: 0; }
|
.right { display: flex; gap: 0; }
|
||||||
.right > :not(.queueButton) { display: none; }
|
.right > :not(.queueButton):not(.shareButton) { display: none; }
|
||||||
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
|
||||||
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
|
||||||
}
|
}
|
||||||
@@ -318,3 +319,16 @@ a.source:hover {
|
|||||||
@media (max-width: 359px) {
|
@media (max-width: 359px) {
|
||||||
.now > :first-child { display: none; }
|
.now > :first-child { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shareStatus {
|
||||||
|
position: absolute;
|
||||||
|
bottom: calc(100% + 8px);
|
||||||
|
right: 8px;
|
||||||
|
max-width: calc(100% - 16px);
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--surface3);
|
||||||
|
color: var(--text);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { ArtistLinks, Cover } from '../entities/catalog';
|
import { ArtistLinks, Cover } from '../entities/catalog';
|
||||||
import { usePlayer, usePlayerState } from '../player/context';
|
import { usePlayer, usePlayerState } from '../player/context';
|
||||||
import { formatDuration } from '../shared/lib/format';
|
import { formatDuration } from '../shared/lib/format';
|
||||||
|
import { shareTrack } from '../shared/lib/share';
|
||||||
import {
|
import {
|
||||||
albumIcon,
|
albumIcon,
|
||||||
closeIcon,
|
closeIcon,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
repeatIcon,
|
repeatIcon,
|
||||||
repeatOneIcon,
|
repeatOneIcon,
|
||||||
shuffleIcon,
|
shuffleIcon,
|
||||||
|
shareIcon,
|
||||||
volumeIcon,
|
volumeIcon,
|
||||||
volumeOffIcon,
|
volumeOffIcon,
|
||||||
waveIcon,
|
waveIcon,
|
||||||
@@ -35,6 +37,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
|
||||||
|
const [sharing, setSharing] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
setShareStatus(null);
|
||||||
|
}, [st.track?.id]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!shareStatus) return;
|
||||||
|
const timer = setTimeout(() => setShareStatus(null), 4000);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [shareStatus]);
|
||||||
const queueButton = useRef<HTMLButtonElement>(null);
|
const queueButton = useRef<HTMLButtonElement>(null);
|
||||||
const queueId = useId();
|
const queueId = useId();
|
||||||
const closeQueue = useCallback(() => {
|
const closeQueue = useCallback(() => {
|
||||||
@@ -57,6 +69,18 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
|
||||||
|
const share = async () => {
|
||||||
|
setSharing(true);
|
||||||
|
setShareStatus(null);
|
||||||
|
try {
|
||||||
|
const result = await shareTrack(track.id, track.title);
|
||||||
|
if (result === 'copied') setShareStatus('copied');
|
||||||
|
} catch {
|
||||||
|
setShareStatus('error');
|
||||||
|
} finally {
|
||||||
|
setSharing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
const repeatLabel = t(`player.repeat_${st.repeat}`);
|
||||||
const ctx = st.context;
|
const ctx = st.context;
|
||||||
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
|
const tr = t as unknown as (k: string, o?: Record<string, unknown>) => string;
|
||||||
@@ -67,7 +91,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
? t('player.source_wave_seed', { label: ctx.label })
|
? t('player.source_wave_seed', { label: ctx.label })
|
||||||
: t('player.source_wave')
|
: t('player.source_wave')
|
||||||
: ctx.type === 'collection'
|
: ctx.type === 'collection'
|
||||||
? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
|
? ctx.label ?? tr(`player.source_${ctx.id === 'dislikes' ? 'dislikes' : 'likes'}`)
|
||||||
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
|
: tr(`player.source_${ctx.type}`, { label: ctx.label ?? '' });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -154,6 +178,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={s.right}>
|
<div className={s.right}>
|
||||||
|
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
|
||||||
|
<Icon src={shareIcon} size={20} />
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(s.icon, st.shuffle && s.accent)}
|
className={cx(s.icon, st.shuffle && s.accent)}
|
||||||
@@ -204,6 +231,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||||
|
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -5,7 +5,7 @@
|
|||||||
## Суть
|
## Суть
|
||||||
|
|
||||||
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
Закрытое (self-hosted) музыкальное приложение для прослушивания музыки с собственного сервера.
|
||||||
Есть веб-версия и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
Есть веб-версия, десктоп и мобильное приложение для Android. Главная фишка — **«Моя волна»**: бесконечный умный поток
|
||||||
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
музыки, который подстраивается под вкус пользователя и под выбранное настроение или занятие.
|
||||||
|
|
||||||
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
Масштаб: до ~100 пользователей, библиотека от 11 000 треков и больше.
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
## Доступ и учётные записи
|
## Доступ и учётные записи
|
||||||
|
|
||||||
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
- При первом открытии приложение показывает **экран входа** (логин + пароль).
|
||||||
В мобильном приложении на этом экране также указывается **адрес сервера**.
|
В мобильном приложении и на десктопе на этом экране также указывается **адрес сервера**.
|
||||||
- **Регистрации нет.** Учётные записи создаёт администрация.
|
- **Регистрации нет.** Учётные записи создаёт администрация.
|
||||||
- Роли:
|
- Роли:
|
||||||
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
- **Администратор** — полный доступ; единственный, кто может создавать и назначать модераторов
|
||||||
@@ -57,19 +57,19 @@
|
|||||||
### Левая панель навигации
|
### Левая панель навигации
|
||||||
- **Главная**
|
- **Главная**
|
||||||
- **Коллекция** — с подкатегориями:
|
- **Коллекция** — с подкатегориями:
|
||||||
|
- **Плейлисты** — личные подборки треков;
|
||||||
- **История** — прослушанные треки;
|
- **История** — прослушанные треки;
|
||||||
- **Лайки** — понравившиеся треки;
|
- **Лайки** — понравившиеся треки;
|
||||||
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
|
- **Дизлайки** — треки, которые пользователь не хочет слышать (можно убрать дизлайк);
|
||||||
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
|
- **Скачанное** — только в мобильном приложении, треки, сохранённые для офлайна.
|
||||||
- **Админка** — видна только администраторам и модераторам.
|
- **Админка** — видна только администраторам и модераторам.
|
||||||
- Список будет расширяться в будущем (плейлисты и т.п.), дизайн должен это допускать.
|
|
||||||
|
|
||||||
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
|
На мобильном вместо левой панели — нижняя навигация (Главная / Поиск / Коллекция / Профиль),
|
||||||
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
|
плеер — мини-полоска над навигацией, разворачивается в полноэкранный плеер.
|
||||||
|
|
||||||
### Плеер (всегда внизу)
|
### Плеер (всегда внизу)
|
||||||
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
|
- Полноценный плеер в виде полоски: обложка, название трека, исполнитель, назад / пауза / вперёд,
|
||||||
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, открыть очередь.
|
прогресс-бар с перемоткой, время, громкость, **лайк** и **дизлайк**, поделиться ссылкой на трек, открыть очередь.
|
||||||
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
|
- **Запоминает последний трек и момент**, на котором остановились: после перезахода / перезагрузки
|
||||||
и даже на другом устройстве можно продолжить с того же места.
|
и даже на другом устройстве можно продолжить с того же места.
|
||||||
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
|
- Если играет «Моя волна», плеер показывает это (и выбранное настроение / занятие).
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
|||||||
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
|
| [library-and-storage.md](library-and-storage.md) | Хранилища (NFS), шаблоны путей, сканер, метаданные, поиск |
|
||||||
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
|
| [uploads.md](uploads.md) | Предложение треков модераторами: очередь, квота, дубли, одобрение, раскладка в хранилище |
|
||||||
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
|
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
|
||||||
|
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
|
||||||
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
|
||||||
| [web.md](web.md) | Веб-клиент |
|
| [web.md](web.md) | Веб-клиент |
|
||||||
|
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
|
||||||
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
| [mobile.md](mobile.md) | Android-приложение, офлайн, самообновление |
|
||||||
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
|
||||||
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
||||||
|
|||||||
@@ -91,6 +91,8 @@
|
|||||||
| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` |
|
| PUT | `/me/reactions/tracks/{id}` | `{reaction: "like"|"dislike"}` |
|
||||||
| DELETE | `/me/reactions/tracks/{id}` | Снять реакцию |
|
| DELETE | `/me/reactions/tracks/{id}` | Снять реакцию |
|
||||||
| PUT/DELETE | `/me/reactions/artists/{id}` | Реакция на исполнителя |
|
| PUT/DELETE | `/me/reactions/artists/{id}` | Реакция на исполнителя |
|
||||||
|
| GET/POST | `/me/playlists` | Сводки личных плейлистов; создание `{name, description, track_ids}` |
|
||||||
|
| GET/PUT/DELETE | `/me/playlists/{id}` | Метаданные и треки; атомарная замена с `revision`; удаление. Подробно — [playlists.md](playlists.md) |
|
||||||
|
|
||||||
### Моя волна — [recommendations.md](recommendations.md)
|
### Моя волна — [recommendations.md](recommendations.md)
|
||||||
| Метод | Путь | Описание |
|
| Метод | Путь | Описание |
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
- Всё разворачивается на одном сервере через Docker Compose.
|
- Всё разворачивается на одном сервере через Docker Compose.
|
||||||
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
- Музыка лежит на NFS-хранилищах, которые подключает администратор из админки. Для всех сервисов доступ к музыке —
|
||||||
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
**только чтение**; единственный писатель — `storage-agent`, который раскладывает одобренные треки из очереди на добавление.
|
||||||
- Клиенты: веб (SPA) и Android-приложение.
|
- Клиенты: веб (SPA), десктоп (Tauri) и Android-приложение.
|
||||||
|
|
||||||
## 2. Компоненты
|
## 2. Компоненты
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┐ ┌────────────────┐
|
┌──────────────┐ ┌────────────────┐
|
||||||
Web SPA ─────┐ │ │ │ │
|
Web SPA ─────┐ │ │ │ │
|
||||||
├──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
Desktop ─────┼──► │ nginx │────►│ api │──► PostgreSQL (+pgvector)
|
||||||
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
Android ─────┘ │ (TLS, SPA, │ │ (Go) │──► Redis
|
||||||
│ X-Accel) │ │ │──► Meilisearch
|
│ X-Accel) │ │ │──► Meilisearch
|
||||||
└──────┬───────┘ └───┬────────┬───┘
|
└──────┬───────┘ └───┬────────┬───┘
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
pipu-music/
|
pipu-music/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ ├── web/ # React + Vite SPA
|
│ ├── web/ # React + Vite SPA
|
||||||
|
│ ├── desktop/ # Tauri 2: то же SPA в нативном окне
|
||||||
│ └── mobile/ # React Native (Expo) Android
|
│ └── mobile/ # React Native (Expo) Android
|
||||||
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
├── packages/ # общий TypeScript-код (pnpm workspaces)
|
||||||
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
│ ├── api-client/ # сгенерированный из OpenAPI клиент + типы
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
| Создать пользователя с ролью `user` | — | ✅ | ✅ |
|
| Создать пользователя с ролью `user` | — | ✅ | ✅ |
|
||||||
| Сбросить пароль / рекомендации, заблокировать `user` | — | ✅ | ✅ |
|
| Сбросить пароль / рекомендации, заблокировать `user` | — | ✅ | ✅ |
|
||||||
| Создать `moderator` / `admin`, менять роли | — | — | ✅ |
|
| Создать `moderator` / `admin`, менять роли | — | — | ✅ |
|
||||||
|
| Менять логин любого пользователя, включая себя | — | — | ✅ |
|
||||||
| Сбросить пароль / рекомендации, заблокировать `moderator` | — | — | ✅ |
|
| Сбросить пароль / рекомендации, заблокировать `moderator` | — | — | ✅ |
|
||||||
| Удалить пользователя | — | — | ✅ |
|
| Удалить пользователя | — | — | ✅ |
|
||||||
| Хранилища, сканирование, переиндексация | — | — | ✅ |
|
| Хранилища, сканирование, переиндексация | — | — | ✅ |
|
||||||
|
|||||||
+21
-2
@@ -41,7 +41,7 @@ CREATE TABLE refresh_tokens (
|
|||||||
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
|
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
|
||||||
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
|
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
|
||||||
device_name text, -- "Chrome / Windows", "Pixel 8"
|
device_name text, -- "Chrome / Windows", "Pixel 8"
|
||||||
client_type text NOT NULL, -- 'web' | 'android'
|
client_type text NOT NULL, -- 'web' | 'android' | 'desktop'
|
||||||
created_at timestamptz NOT NULL DEFAULT now(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
expires_at timestamptz NOT NULL,
|
expires_at timestamptz NOT NULL,
|
||||||
used_at timestamptz, -- ротирован
|
used_at timestamptz, -- ротирован
|
||||||
@@ -303,6 +303,25 @@ CREATE TABLE track_reactions ( -- Коллекция → Лайки
|
|||||||
);
|
);
|
||||||
CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC);
|
CREATE INDEX ON track_reactions (user_id, reaction, created_at DESC);
|
||||||
|
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
|
||||||
|
description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
||||||
|
revision integer NOT NULL DEFAULT 1, -- оптимистичная блокировка при PUT
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX playlists_owner ON playlists(user_id, updated_at DESC, id);
|
||||||
|
|
||||||
|
CREATE TABLE playlist_tracks (
|
||||||
|
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
position integer NOT NULL CHECK (position >= 0),
|
||||||
|
PRIMARY KEY (playlist_id, track_id),
|
||||||
|
UNIQUE (playlist_id, position)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п.
|
CREATE TABLE artist_reactions ( -- «не рекомендовать исполнителя» и т.п.
|
||||||
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
user_id uuid REFERENCES users(id) ON DELETE CASCADE,
|
||||||
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
|
artist_id uuid REFERENCES artists(id) ON DELETE CASCADE,
|
||||||
@@ -324,7 +343,7 @@ CREATE TABLE play_events (
|
|||||||
context_type text, -- wave | album | artist | search | collection | history | queue
|
context_type text, -- wave | album | artist | search | collection | history | queue
|
||||||
context_id text, -- id альбома / wave_session и т.п.
|
context_id text, -- id альбома / wave_session и т.п.
|
||||||
wave_session_id uuid,
|
wave_session_id uuid,
|
||||||
client_type text NOT NULL, -- web | android
|
client_type text NOT NULL, -- web | android | desktop
|
||||||
offline boolean NOT NULL DEFAULT false,
|
offline boolean NOT NULL DEFAULT false,
|
||||||
PRIMARY KEY (id, occurred_at)
|
PRIMARY KEY (id, occurred_at)
|
||||||
) PARTITION BY RANGE (occurred_at);
|
) PARTITION BY RANGE (occurred_at);
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Десктоп (`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.
|
||||||
+6
-2
@@ -29,6 +29,10 @@
|
|||||||
- Доступ к экранам ограничен через `Stack.Protected` до их отрисовки. Перенаправление из `useEffect`
|
- Доступ к экранам ограничен через `Stack.Protected` до их отрисовки. Перенаправление из `useEffect`
|
||||||
выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с `Error: no session`.
|
выполнялось слишком поздно: первый запуск без сервера открывал главную и падал с `Error: no session`.
|
||||||
Регрессия первого запуска, повторного открытия и защищённой ссылки: `.maestro/startup.yaml`.
|
Регрессия первого запуска, повторного открытия и защищённой ссылки: `.maestro/startup.yaml`.
|
||||||
|
- События прослушивания используют UUIDv7 со случайными байтами из нативного `expo.uuidv4()`.
|
||||||
|
Web Crypto (`crypto.getRandomValues`) в Hermes отсутствует: браузерный генератор ронял приложение
|
||||||
|
при восстановлении непустой очереди с компьютера, когда TrackPlayer сообщал о выбранном треке.
|
||||||
|
Генератор передаётся в общий `EventBuffer`; веб продолжает использовать Web Crypto.
|
||||||
|
|
||||||
## Экраны
|
## Экраны
|
||||||
|
|
||||||
@@ -38,12 +42,12 @@
|
|||||||
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
|
приложения), список сохранённых серверов, затем логин, пароль, язык. Далее те же setup-экраны, что и в вебе.
|
||||||
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
|
- **Нижняя навигация**: Главная · Поиск · Коллекция · Профиль. Мини-плеер над навигацией; свайп вверх — полноэкранный
|
||||||
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
|
плеер в одном из двух видов (выбор в профиле): `cover` — крупная обложка (по умолчанию) или `wave` — визуализация волны;
|
||||||
прогресс, лайк / дизлайк, скачать, очередь (нижний лист).
|
прогресс, лайк / дизлайк, скачать, поделиться ссылкой на `/track/{id}`, очередь (нижний лист).
|
||||||
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
|
- **Поиск**: недавние запросы (хранятся локально), подсказка про опечатки и раскладку, значок «скачано» у треков.
|
||||||
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
|
- **Главная**: кнопка «Моя волна» (`orb` или `liquid` по настройке, тот же алгоритм, что в вебе, на Skia; пульс в такт BPM / energy из `features` трека — на Android
|
||||||
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
|
спектр недоступен из ExoPlayer без нативного модуля, поэтому анимация управляется метаданными и позицией трека),
|
||||||
выбор занятия / настроения.
|
выбор занятия / настроения.
|
||||||
- **Коллекция**: История, Лайки, Дизлайки, **Скачанное**.
|
- **Коллекция**: кнопка **Плейлисты**, затем История, Лайки, Дизлайки, **Скачанное**. Плейлисты — [playlists.md](playlists.md).
|
||||||
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
|
- **Коллекция → Скачанное**: занято на устройстве / свободно в телефоне, «Скачивать новые лайки», «Скачать все лайки»,
|
||||||
очередь загрузок с пометкой «по Wi-Fi».
|
очередь загрузок с пометкой «по Wi-Fi».
|
||||||
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
|
- **Профиль**: смена пароля (лист), язык, тема, вид волны, вид плеера, качество звука (лист: Wi-Fi / сотовая / загрузки),
|
||||||
|
|||||||
@@ -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`. Проверяет сохранение порядка, изоляцию владельцев, откат при несуществующем треке, конфликт конкурентных записей и каскадное удаление членства.
|
||||||
+4
-3
@@ -23,7 +23,7 @@
|
|||||||
apps/web/src/
|
apps/web/src/
|
||||||
├── app/ # провайдеры, роутер, глобальные стили, гарды
|
├── app/ # провайдеры, роутер, глобальные стили, гарды
|
||||||
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
|
├── pages/ # login, setup-admin, setup-password, home, search, artist, album,
|
||||||
│ # collection/{history,likes,dislikes}, settings,
|
│ # collection/{playlists,history,likes,dislikes}, playlist, track, settings,
|
||||||
│ # admin/{users,sources,library,upload,upload-queue,settings}
|
│ # admin/{users,sources,library,upload,upload-queue,settings}
|
||||||
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
|
├── widgets/ # app-shell (топбар, сайдбар), player-bar, wave-button, search-box, track-list
|
||||||
├── features/ # like-track, change-password, mood-picker, create-user, pattern-editor, ...
|
├── features/ # like-track, change-password, mood-picker, create-user, pattern-editor, ...
|
||||||
@@ -37,8 +37,8 @@ apps/web/src/
|
|||||||
|
|
||||||
### App shell
|
### App shell
|
||||||
Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
|
Каркас **Classic** ([design.md](design.md#4-каркас-и-экраны)).
|
||||||
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → История / Лайки / Дизлайки,
|
- Сетка: топбар 60 px (логотип, поиск, меню профиля) / сайдбар 240 px (Главная, Коллекция → Плейлисты / История / Лайки / Дизлайки,
|
||||||
Админка по роли) / контент / плеер.
|
Админка по роли) / контент / плеер. Личные плейлисты — [playlists.md](playlists.md).
|
||||||
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
|
- Плеер в двух видах, выбор в профиле: `bar` — полоса 88 px внизу (по умолчанию), `island` — плавающий остров.
|
||||||
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
|
- Ambient-фон за всем интерфейсом: три размытых дрейфующих пятна, одно — в цвете обложки текущего трека.
|
||||||
- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
|
- Адаптивность: < 1024 px — сайдбар из иконок, < 768 px — нижняя навигация, как в мобильном.
|
||||||
@@ -55,6 +55,7 @@ apps/web/src/
|
|||||||
- Два элемента `<audio>` для предзагрузки следующего трека и бесшовного перехода (опционально — кроссфейд 0–6 с в настройках).
|
- Два элемента `<audio>` для предзагрузки следующего трека и бесшовного перехода (опционально — кроссфейд 0–6 с в настройках).
|
||||||
- Media Session API: метаданные, обложка, кнопки ОС / наушников.
|
- Media Session API: метаданные, обложка, кнопки ОС / наушников.
|
||||||
- Горячие клавиши: пробел — пауза, ←/→ — перемотка 10 с, Shift+←/→ — трек, L — лайк.
|
- Горячие клавиши: пробел — пауза, ←/→ — перемотка 10 с, Shift+←/→ — трек, L — лайк.
|
||||||
|
- «Поделиться» копирует адрес страницы трека (`/track/{id}`) без токенов стрима; на телефоне в браузере открывается системный лист.
|
||||||
- Логика очереди, событий, сохранения позиции — из `packages/core` (общая с мобильным); веб реализует
|
- Логика очереди, событий, сохранения позиции — из `packages/core` (общая с мобильным); веб реализует
|
||||||
только адаптер `AudioEngine`.
|
только адаптер `AudioEngine`.
|
||||||
|
|
||||||
|
|||||||
@@ -1331,10 +1331,72 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/me/playlists": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["listPlaylists"];
|
||||||
|
put?: never;
|
||||||
|
post: operations["createPlaylist"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/me/playlists/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get: operations["getPlaylist"];
|
||||||
|
put: operations["updatePlaylist"];
|
||||||
|
post?: never;
|
||||||
|
delete: operations["deletePlaylist"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
PlaylistInput: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
track_ids: string[];
|
||||||
|
};
|
||||||
|
PlaylistUpdate: components["schemas"]["PlaylistInput"] & {
|
||||||
|
revision: number;
|
||||||
|
};
|
||||||
|
PlaylistSummary: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
revision: number;
|
||||||
|
track_count: number;
|
||||||
|
/** Format: int64 */
|
||||||
|
duration_ms: number;
|
||||||
|
/** Format: date-time */
|
||||||
|
created_at: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
updated_at: string;
|
||||||
|
};
|
||||||
|
Playlist: components["schemas"]["PlaylistSummary"] & {
|
||||||
|
tracks: components["schemas"]["Track"][];
|
||||||
|
};
|
||||||
|
PlaylistList: {
|
||||||
|
items: components["schemas"]["PlaylistSummary"][];
|
||||||
|
};
|
||||||
Onboarding: {
|
Onboarding: {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
artists: {
|
artists: {
|
||||||
@@ -1596,7 +1658,7 @@ export interface components {
|
|||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
Role: "admin" | "moderator" | "user";
|
Role: "admin" | "moderator" | "user";
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
ClientType: "web" | "android";
|
ClientType: "web" | "android" | "desktop";
|
||||||
LoginRequest: {
|
LoginRequest: {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -1605,7 +1667,7 @@ export interface components {
|
|||||||
device_name?: string;
|
device_name?: string;
|
||||||
};
|
};
|
||||||
RefreshRequest: {
|
RefreshRequest: {
|
||||||
/** @description Только Android; веб передаёт токен в cookie */
|
/** @description Android и desktop; веб передаёт токен в cookie */
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
};
|
};
|
||||||
AuthTokens: {
|
AuthTokens: {
|
||||||
@@ -1614,7 +1676,7 @@ export interface components {
|
|||||||
token_type: "Bearer";
|
token_type: "Bearer";
|
||||||
/** @description Срок жизни access-токена, секунды */
|
/** @description Срок жизни access-токена, секунды */
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
/** @description Только для `client_type: android` */
|
/** @description Для `client_type: android` и `client_type: desktop` */
|
||||||
refresh_token?: string;
|
refresh_token?: string;
|
||||||
user: components["schemas"]["Me"];
|
user: components["schemas"]["Me"];
|
||||||
};
|
};
|
||||||
@@ -1705,6 +1767,8 @@ export interface components {
|
|||||||
temporary_password: string;
|
temporary_password: string;
|
||||||
};
|
};
|
||||||
UpdateUserRequest: {
|
UpdateUserRequest: {
|
||||||
|
/** @description Новый логин. Может менять только администратор, в том числе у себя */
|
||||||
|
username?: string;
|
||||||
/** @description Пустая строка — сбросить имя */
|
/** @description Пустая строка — сбросить имя */
|
||||||
display_name?: string;
|
display_name?: string;
|
||||||
role?: components["schemas"]["Role"];
|
role?: components["schemas"]["Role"];
|
||||||
@@ -4169,4 +4233,121 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
listPlaylists: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlaylistList"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
createPlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlaylistInput"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Playlist"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
getPlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Playlist"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
updatePlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["PlaylistUpdate"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Success */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Playlist"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
deletePlaylist: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Deleted */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -93,8 +93,8 @@ export class AuthSession {
|
|||||||
private readonly listeners = new Set<(s: AuthState) => void>();
|
private readonly listeners = new Set<(s: AuthState) => void>();
|
||||||
|
|
||||||
constructor(opts: AuthSessionOptions) {
|
constructor(opts: AuthSessionOptions) {
|
||||||
if (opts.clientType === 'android' && !opts.refreshStore) {
|
if (opts.clientType !== 'web' && !opts.refreshStore) {
|
||||||
throw new Error('AuthSession: android client needs a refreshStore');
|
throw new Error(`AuthSession: ${opts.clientType} client needs a refreshStore`);
|
||||||
}
|
}
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
|
||||||
|
|||||||
@@ -22,5 +22,6 @@ export {
|
|||||||
type Track,
|
type Track,
|
||||||
} from './player';
|
} from './player';
|
||||||
export { normalizeGain, TARGET_LUFS } from './gain';
|
export { normalizeGain, TARGET_LUFS } from './gain';
|
||||||
|
export { trackShareUrl } from './share';
|
||||||
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
|
||||||
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
|
||||||
@@ -188,6 +188,7 @@ export class EventBuffer {
|
|||||||
private readonly sink: EventSink,
|
private readonly sink: EventSink,
|
||||||
private readonly now: () => number = Date.now,
|
private readonly now: () => number = Date.now,
|
||||||
private readonly maxRetryMs = 5 * 60_000,
|
private readonly maxRetryMs = 5 * 60_000,
|
||||||
|
private readonly createId: (now: number) => string = uuidv7,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get pending(): readonly PlayEvent[] {
|
get pending(): readonly PlayEvent[] {
|
||||||
@@ -196,7 +197,7 @@ export class EventBuffer {
|
|||||||
|
|
||||||
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
|
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
|
||||||
const t = this.now();
|
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();
|
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": {
|
"users": {
|
||||||
|
"change_username": "Change username",
|
||||||
"title": "Users",
|
"title": "Users",
|
||||||
"subtitle": "Create users and moderators, manage roles, upload review, reset passwords, block accounts",
|
"subtitle": "Create users and moderators, manage roles, upload review, reset passwords, block accounts",
|
||||||
"create": "Create user",
|
"create": "Create user",
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
"empty": "No entries yet",
|
"empty": "No entries yet",
|
||||||
"more": "Show more",
|
"more": "Show more",
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"user.username_change": "Username changed",
|
||||||
"user.bootstrap_create": "Initial administrator created",
|
"user.bootstrap_create": "Initial administrator created",
|
||||||
"user.bootstrap_credentials": "Administrator credentials set",
|
"user.bootstrap_credentials": "Administrator credentials set",
|
||||||
"user.create": "User created",
|
"user.create": "User created",
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"share": "Share track",
|
||||||
|
"link_copied": "Track link copied",
|
||||||
|
"share_error": "Could not share the link. Please try again.",
|
||||||
|
"track_page": "Track",
|
||||||
"empty": "Nothing is playing",
|
"empty": "Nothing is playing",
|
||||||
"label": "Player",
|
"label": "Player",
|
||||||
"play": "Play",
|
"play": "Play",
|
||||||
@@ -302,5 +306,27 @@
|
|||||||
"pick_more_few": "Pick {{count}} more artists",
|
"pick_more_few": "Pick {{count}} more artists",
|
||||||
"pick_more_many": "Pick {{count}} more artists",
|
"pick_more_many": "Pick {{count}} more artists",
|
||||||
"pick_more_other": "Pick {{count}} more artists"
|
"pick_more_other": "Pick {{count}} more artists"
|
||||||
|
},
|
||||||
|
"playlists": {
|
||||||
|
"name": "Name",
|
||||||
|
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
|
||||||
|
"manage": "Manage tracks",
|
||||||
|
"title": "Playlists",
|
||||||
|
"move_down": "Move {{name}} down",
|
||||||
|
"edit": "Edit playlist",
|
||||||
|
"description": "Description",
|
||||||
|
"add": "Add to playlist",
|
||||||
|
"search": "Search playlists",
|
||||||
|
"create": "Create playlist",
|
||||||
|
"find_tracks": "Find music",
|
||||||
|
"remove_track": "Remove {{name}} from playlist",
|
||||||
|
"save": "Save",
|
||||||
|
"private": "Private playlist",
|
||||||
|
"empty": "Your playlists will appear here. Create your first and add your favourite music.",
|
||||||
|
"empty_tracks": "No tracks yet. Use Add to playlist in search, albums or your collection.",
|
||||||
|
"loading": "Loading playlists…",
|
||||||
|
"move_up": "Move {{name}} up",
|
||||||
|
"delete": "Delete playlist",
|
||||||
|
"no_results": "No playlists found"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,5 +93,12 @@
|
|||||||
"size_mismatch": "The file size does not match the declared one",
|
"size_mismatch": "The file size does not match the declared one",
|
||||||
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
"hash_mismatch": "The file was corrupted in transit (SHA-256 mismatch)",
|
||||||
"import_failed": "Could not write the file to the storage"
|
"import_failed": "Could not write the file to the storage"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"not_found": "Playlist not found",
|
||||||
|
"invalid_name": "Enter a name between 1 and 120 characters",
|
||||||
|
"invalid_tracks": "Tracks must be unique and exist in the library",
|
||||||
|
"too_large": "Maximum 2000 tracks and 2000 description characters",
|
||||||
|
"conflict": "This playlist changed on another device. Close the editor and try again with the refreshed data."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"users": {
|
"users": {
|
||||||
|
"change_username": "Изменить логин",
|
||||||
"title": "Пользователи",
|
"title": "Пользователи",
|
||||||
"subtitle": "Создание пользователей и модераторов, роли, аудит загрузок, сброс пароля, блокировка",
|
"subtitle": "Создание пользователей и модераторов, роли, аудит загрузок, сброс пароля, блокировка",
|
||||||
"create": "Создать пользователя",
|
"create": "Создать пользователя",
|
||||||
@@ -60,6 +61,7 @@
|
|||||||
"empty": "Записей пока нет",
|
"empty": "Записей пока нет",
|
||||||
"more": "Показать ещё",
|
"more": "Показать ещё",
|
||||||
"actions": {
|
"actions": {
|
||||||
|
"user.username_change": "Изменён логин",
|
||||||
"user.bootstrap_create": "Создан стартовый администратор",
|
"user.bootstrap_create": "Создан стартовый администратор",
|
||||||
"user.bootstrap_credentials": "Заданы учётные данные администратора",
|
"user.bootstrap_credentials": "Заданы учётные данные администратора",
|
||||||
"user.create": "Создан пользователь",
|
"user.create": "Создан пользователь",
|
||||||
|
|||||||
@@ -54,6 +54,10 @@
|
|||||||
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
|
||||||
},
|
},
|
||||||
"player": {
|
"player": {
|
||||||
|
"share": "Поделиться треком",
|
||||||
|
"link_copied": "Ссылка на трек скопирована",
|
||||||
|
"share_error": "Не удалось поделиться ссылкой. Попробуйте ещё раз.",
|
||||||
|
"track_page": "Трек",
|
||||||
"empty": "Ничего не играет",
|
"empty": "Ничего не играет",
|
||||||
"label": "Плеер",
|
"label": "Плеер",
|
||||||
"play": "Слушать",
|
"play": "Слушать",
|
||||||
@@ -302,5 +306,27 @@
|
|||||||
"pick_more_few": "Выберите ещё {{count}} исполнителя",
|
"pick_more_few": "Выберите ещё {{count}} исполнителя",
|
||||||
"pick_more_many": "Выберите ещё {{count}} исполнителей",
|
"pick_more_many": "Выберите ещё {{count}} исполнителей",
|
||||||
"pick_more_other": "Выберите ещё {{count}} исполнителя"
|
"pick_more_other": "Выберите ещё {{count}} исполнителя"
|
||||||
|
},
|
||||||
|
"playlists": {
|
||||||
|
"name": "Название",
|
||||||
|
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
|
||||||
|
"manage": "Управлять треками",
|
||||||
|
"title": "Плейлисты",
|
||||||
|
"move_down": "Переместить «{{name}}» ниже",
|
||||||
|
"edit": "Изменить плейлист",
|
||||||
|
"description": "Описание",
|
||||||
|
"add": "Добавить в плейлист",
|
||||||
|
"search": "Поиск плейлистов",
|
||||||
|
"create": "Создать плейлист",
|
||||||
|
"find_tracks": "Найти музыку",
|
||||||
|
"remove_track": "Убрать «{{name}}» из плейлиста",
|
||||||
|
"save": "Сохранить",
|
||||||
|
"private": "Личный плейлист",
|
||||||
|
"empty": "Здесь будут ваши плейлисты. Создайте первый и добавьте любимую музыку.",
|
||||||
|
"empty_tracks": "В плейлисте пока нет треков. Добавляйте их кнопкой «Добавить в плейлист» в поиске, альбомах или коллекции.",
|
||||||
|
"loading": "Загрузка плейлистов…",
|
||||||
|
"move_up": "Переместить «{{name}}» выше",
|
||||||
|
"delete": "Удалить плейлист",
|
||||||
|
"no_results": "Плейлисты не найдены"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,5 +93,12 @@
|
|||||||
"size_mismatch": "Размер файла не совпал с заявленным",
|
"size_mismatch": "Размер файла не совпал с заявленным",
|
||||||
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
"hash_mismatch": "Файл повредился при загрузке (не совпал SHA-256)",
|
||||||
"import_failed": "Не удалось записать файл в хранилище"
|
"import_failed": "Не удалось записать файл в хранилище"
|
||||||
|
},
|
||||||
|
"playlist": {
|
||||||
|
"not_found": "Плейлист не найден",
|
||||||
|
"invalid_name": "Введите название от 1 до 120 символов",
|
||||||
|
"invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке",
|
||||||
|
"too_large": "Максимум 2000 треков и 2000 символов описания",
|
||||||
|
"conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Generated
+133
-4
@@ -18,6 +18,12 @@ importers:
|
|||||||
specifier: 5.8.3
|
specifier: 5.8.3
|
||||||
version: 5.8.3
|
version: 5.8.3
|
||||||
|
|
||||||
|
apps/desktop:
|
||||||
|
devDependencies:
|
||||||
|
'@tauri-apps/cli':
|
||||||
|
specifier: ^2.9.0
|
||||||
|
version: 2.12.3
|
||||||
|
|
||||||
apps/mobile:
|
apps/mobile:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@expo/vector-icons':
|
'@expo/vector-icons':
|
||||||
@@ -116,7 +122,7 @@ importers:
|
|||||||
version: 19.0.14
|
version: 19.0.14
|
||||||
vitest:
|
vitest:
|
||||||
specifier: ^3.2.7
|
specifier: ^3.2.7
|
||||||
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2)
|
version: 3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||||
|
|
||||||
apps/web:
|
apps/web:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -1458,6 +1464,82 @@ packages:
|
|||||||
'@tanstack/virtual-core@3.19.6':
|
'@tanstack/virtual-core@3.19.6':
|
||||||
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==}
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.12.3':
|
||||||
|
resolution: {integrity: sha512-xmVNoJOOKzKLj7GLHXmXLimRby9M42aBIJa0sB80N7o24eKV5JiVmToKTRLVH9yhL05GZ/B6rSSOmbzNFJwBAA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.12.3':
|
||||||
|
resolution: {integrity: sha512-URd+0usfmUQQyb5kNPiKt2OT8gp7oGU7QR+qOppB6e35mpVBFFVNhWc9oWb3p0u71fDJ09DpF4T6m9xYxoIB8g==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
|
||||||
|
resolution: {integrity: sha512-0kwUVx2LbmOoTRlwF1oDww7th4/NSluv4ZBQ6KRZsFKg4g0TlTcIiJ0VehqkQYUeC9xIT1Mk6XtF52mI155VSQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-gKSGDx6fx+HBLQmG6guChFdisbrCSdx9Yli+j5teOa64gm1CeM+C6NLru5Pj3ZkkEep05BmCFjxvX+cjqAycZQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
|
||||||
|
resolution: {integrity: sha512-Yr59qMy/gcvAfBuFdCGlAmGiLsYWMFDbsP4bAI6iNBzcJTGrYWZGeiQhGDo29MQwfF/NUBqyM2qljXu5tZ6wjQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-jKr0/REBMnLKC0OwPQZkyge0mKDaNZ0b/AbfGwVyCgFfwS730EVLfVDo3nt7MUu6jJaytRq08+xSj5qfy+U1+Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
|
||||||
|
resolution: {integrity: sha512-gTkEqJAGCMROGanxHnzaJzQ+/rXl39HWVg0/hDODS1wDWBGqoceqGC6Qm1+mQbyT11xnsB8mKu/gyVID/YpFIw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.12.3':
|
||||||
|
resolution: {integrity: sha512-T2dTxL1svRym71fZtaW4pZYXbthbfFsEJr1NeOUg/p7laWXZbUt/8DMSZf8recu9f7+1F/Lv00HtzsEu6vDO9w==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-eoiQwpEZWbpsVHcM9BAzqqe4TCHszvr3rsM7pUQ4ofLK0lF1i1d/jX0Qo5DYMqcrUtwQuDdDuOCbcT0Izf1VwQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-kE+KUg6Dbbkgvcw0m99wJVIToFQzJqpMBAXah74WqxaLzssNwSTUccrHp6+QVzMhjQP330tvekFmnQF+0NbE6Q==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
|
||||||
|
resolution: {integrity: sha512-L7wndWAgX2mLW7alswpL3d1R0P9FN2WYBgHaQkAYEBxHHGkY4vFX9CjcRQ1LAxDAMIJxrY7/aDziFJeChS84NQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.12.3':
|
||||||
|
resolution: {integrity: sha512-f7/LsY74SVb1ChmOPw1ajOpxii0GSzDAgmWHRci8JUYuuai04rXsSbFobpmB0sP2DdlvMD/B5ZotEn8GOGMRUg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
'@testing-library/dom@10.4.2':
|
'@testing-library/dom@10.4.2':
|
||||||
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
@@ -5560,6 +5642,53 @@ snapshots:
|
|||||||
|
|
||||||
'@tanstack/virtual-core@3.19.6': {}
|
'@tanstack/virtual-core@3.19.6': {}
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-arm64@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-darwin-x64@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-linux-x64-musl@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc@2.12.3':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@tauri-apps/cli@2.12.3':
|
||||||
|
optionalDependencies:
|
||||||
|
'@tauri-apps/cli-darwin-arm64': 2.12.3
|
||||||
|
'@tauri-apps/cli-darwin-x64': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm-gnueabihf': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-arm64-musl': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-riscv64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-x64-gnu': 2.12.3
|
||||||
|
'@tauri-apps/cli-linux-x64-musl': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-arm64-msvc': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-ia32-msvc': 2.12.3
|
||||||
|
'@tauri-apps/cli-win32-x64-msvc': 2.12.3
|
||||||
|
|
||||||
'@testing-library/dom@10.4.2':
|
'@testing-library/dom@10.4.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
@@ -8118,7 +8247,7 @@ snapshots:
|
|||||||
|
|
||||||
vary@1.1.2: {}
|
vary@1.1.2: {}
|
||||||
|
|
||||||
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2):
|
vite-node@3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3(supports-color@10.2.2)
|
debug: 4.4.3(supports-color@10.2.2)
|
||||||
@@ -8188,7 +8317,7 @@ snapshots:
|
|||||||
lightningcss: 1.27.0
|
lightningcss: 1.27.0
|
||||||
terser: 5.51.2
|
terser: 5.51.2
|
||||||
|
|
||||||
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(terser@5.51.2):
|
vitest@3.2.7(@types/node@22.20.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/chai': 5.2.3
|
'@types/chai': 5.2.3
|
||||||
'@vitest/expect': 3.2.7
|
'@vitest/expect': 3.2.7
|
||||||
@@ -8211,7 +8340,7 @@ snapshots:
|
|||||||
tinypool: 1.1.1
|
tinypool: 1.1.1
|
||||||
tinyrainbow: 2.0.0
|
tinyrainbow: 2.0.0
|
||||||
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
vite: 6.4.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
||||||
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(terser@5.51.2)
|
vite-node: 3.2.4(@types/node@22.20.5)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
|
||||||
why-is-node-running: 2.3.0
|
why-is-node-running: 2.3.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 22.20.5
|
'@types/node': 22.20.5
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -150,13 +150,14 @@ func displayName(s string) (*string, error) {
|
|||||||
|
|
||||||
// UserPatch — изменения пользователя из админки. nil — не менять.
|
// UserPatch — изменения пользователя из админки. nil — не менять.
|
||||||
type UserPatch struct {
|
type UserPatch struct {
|
||||||
|
Username *string
|
||||||
DisplayName *string
|
DisplayName *string
|
||||||
Role *Role
|
Role *Role
|
||||||
IsBlocked *bool
|
IsBlocked *bool
|
||||||
ReviewsUploads *bool
|
ReviewsUploads *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateUser меняет имя, роль и блокировку с проверкой прав по каждому изменению.
|
// UpdateUser меняет логин, имя, роль и блокировку с проверкой прав по каждому изменению.
|
||||||
func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, patch UserPatch) (User, error) {
|
func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, patch UserPatch) (User, error) {
|
||||||
if patch.Role != nil && !patch.Role.Valid() {
|
if patch.Role != nil && !patch.Role.Valid() {
|
||||||
return User{}, apperr.Validation("role", CodeRoleInvalid, "unknown role", nil)
|
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 patch.DisplayName != nil {
|
||||||
if !CanManageUser(actor, tgt, UserEdit) {
|
if !CanManageUser(actor, tgt, UserEdit) {
|
||||||
return forbidden()
|
return forbidden()
|
||||||
@@ -247,6 +261,9 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
if errors.Is(err, ErrUsernameTaken) {
|
||||||
|
return User{}, apperr.Validation("username", CodeUsernameTaken, "username is taken", nil)
|
||||||
|
}
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObjec
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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 {
|
if req.Body.Role != nil {
|
||||||
r := Role(*req.Body.Role)
|
r := Role(*req.Body.Role)
|
||||||
patch.Role = &r
|
patch.Role = &r
|
||||||
|
|||||||
@@ -76,6 +76,34 @@ func TestAdminCreatesModeratorWhoCreatesUser(t *testing.T) {
|
|||||||
assert.Equal(t, auth.CodeForbidden, e.createUser(listener, "another", "user").code())
|
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) {
|
func TestCreateUserValidation(t *testing.T) {
|
||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
e.addUser("root", "root-password-1", auth.RoleAdmin)
|
||||||
|
|||||||
@@ -293,7 +293,18 @@ func (s *MemStore) LockActiveAdmins(ctx context.Context) (int, error) { return s
|
|||||||
|
|
||||||
// UpdateUser реализует auth.Store.
|
// UpdateUser реализует auth.Store.
|
||||||
func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUpdate) (auth.User, error) {
|
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) {
|
err := s.update(id, func(u *auth.User) {
|
||||||
|
if upd.Username != nil {
|
||||||
|
u.Username = *upd.Username
|
||||||
|
}
|
||||||
if upd.DisplayName != nil {
|
if upd.DisplayName != nil {
|
||||||
u.DisplayName = *upd.DisplayName
|
u.DisplayName = *upd.DisplayName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,10 +27,11 @@ type ClientType string
|
|||||||
const (
|
const (
|
||||||
ClientWeb ClientType = "web"
|
ClientWeb ClientType = "web"
|
||||||
ClientAndroid ClientType = "android"
|
ClientAndroid ClientType = "android"
|
||||||
|
ClientDesktop ClientType = "desktop"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Valid сообщает, известен ли тип клиента.
|
// Valid сообщает, известен ли тип клиента.
|
||||||
func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid }
|
func (c ClientType) Valid() bool { return c == ClientWeb || c == ClientAndroid || c == ClientDesktop }
|
||||||
|
|
||||||
// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента.
|
// UIPrefs — настройки интерфейса пользователя (users.ui_prefs). Пустые поля — значения по умолчанию клиента.
|
||||||
type UIPrefs struct {
|
type UIPrefs struct {
|
||||||
|
|||||||
@@ -340,8 +340,9 @@ func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate)
|
|||||||
r := string(*upd.Role)
|
r := string(*upd.Role)
|
||||||
role = &r
|
role = &r
|
||||||
}
|
}
|
||||||
return scanUser(s.q.QueryRow(ctx, `
|
u, err := scanUser(s.q.QueryRow(ctx, `
|
||||||
UPDATE users SET
|
UPDATE users SET
|
||||||
|
username = COALESCE($8, username),
|
||||||
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
|
display_name = CASE WHEN $2 THEN $3 ELSE display_name END,
|
||||||
role = COALESCE($4::user_role, role),
|
role = COALESCE($4::user_role, role),
|
||||||
is_blocked = COALESCE($5, is_blocked),
|
is_blocked = COALESCE($5, is_blocked),
|
||||||
@@ -349,7 +350,11 @@ func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate)
|
|||||||
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
|
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads))
|
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username))
|
||||||
|
if isUniqueViolation(err) {
|
||||||
|
return User{}, ErrUsernameTaken
|
||||||
|
}
|
||||||
|
return u, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetPassword реализует Store.
|
// ResetPassword реализует Store.
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type UserFilter struct {
|
|||||||
|
|
||||||
// UserUpdate — изменения из админки. nil — не менять.
|
// UserUpdate — изменения из админки. nil — не менять.
|
||||||
type UserUpdate struct {
|
type UserUpdate struct {
|
||||||
|
Username *string
|
||||||
DisplayName **string
|
DisplayName **string
|
||||||
Role *Role
|
Role *Role
|
||||||
IsBlocked *bool
|
IsBlocked *bool
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
)
|
||||||
|
|
||||||
|
func validatePlaylist(name, description string, ids []uuid.UUID) error {
|
||||||
|
if n := utf8.RuneCountInString(strings.TrimSpace(name)); n < 1 || n > 120 {
|
||||||
|
return apperr.Validation("name", "playlist.invalid_name", "name must contain 1–120 characters", nil)
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(description) > 2000 || len(ids) > 2000 {
|
||||||
|
return apperr.Validation("playlist", "playlist.too_large", "maximum 2000 description characters and tracks", nil)
|
||||||
|
}
|
||||||
|
seen := map[uuid.UUID]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == uuid.Nil || seen[id] {
|
||||||
|
return apperr.Validation("track_ids", "playlist.invalid_tracks", "track IDs must be unique and nonzero", nil)
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
const playlistSelect = `SELECT p.id, p.name, p.description, p.revision, p.created_at, p.updated_at,
|
||||||
|
(SELECT count(*) FROM playlist_tracks pt WHERE pt.playlist_id=p.id),
|
||||||
|
COALESCE((SELECT sum(t.duration_ms) FROM playlist_tracks pt JOIN tracks t ON t.id=pt.track_id WHERE pt.playlist_id=p.id),0)
|
||||||
|
FROM playlists p`
|
||||||
|
|
||||||
|
func scanPlaylist(row pgx.Row) (gen.PlaylistSummary, error) {
|
||||||
|
var p gen.PlaylistSummary
|
||||||
|
err := row.Scan(&p.Id, &p.Name, &p.Description, &p.Revision, &p.CreatedAt, &p.UpdatedAt, &p.TrackCount, &p.DurationMs)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return p, apperr.NotFound("playlist.not_found", "playlist not found")
|
||||||
|
}
|
||||||
|
return p, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) ListPlaylists(ctx context.Context, _ gen.ListPlaylistsRequestObject) (gen.ListPlaylistsResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := h.store.pool.Query(ctx, playlistSelect+` WHERE p.user_id=$1 ORDER BY p.updated_at DESC, p.id`, user.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := gen.PlaylistList{Items: []gen.PlaylistSummary{}}
|
||||||
|
for rows.Next() {
|
||||||
|
p, err := scanPlaylist(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, p)
|
||||||
|
}
|
||||||
|
return gen.ListPlaylists200JSONResponse(out), rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) playlist(ctx context.Context, user, id uuid.UUID) (gen.Playlist, error) {
|
||||||
|
var out gen.Playlist
|
||||||
|
tx, err := h.store.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.RepeatableRead, AccessMode: pgx.ReadOnly})
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
p, err := scanPlaylist(tx.QueryRow(ctx, playlistSelect+` WHERE p.user_id=$1 AND p.id=$2`, user, id))
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
rows, err := tx.Query(ctx, `SELECT track_id FROM playlist_tracks WHERE playlist_id=$1 ORDER BY position`, id)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err = tx.Commit(ctx); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
tracks, err := h.catalog.TracksByIDs(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
dtos, err := h.dto().Tracks(ctx, tracks)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if dtos == nil {
|
||||||
|
dtos = []gen.Track{}
|
||||||
|
}
|
||||||
|
out = gen.Playlist{Id: p.Id, Name: p.Name, Description: p.Description, Revision: p.Revision, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, TrackCount: len(dtos), Tracks: dtos}
|
||||||
|
for _, t := range dtos {
|
||||||
|
out.DurationMs += int64(t.DurationMs)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetPlaylist(ctx context.Context, req gen.GetPlaylistRequestObject) (gen.GetPlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := h.playlist(ctx, user.UserID, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.GetPlaylist200JSONResponse(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A row lock plus revision check prevents stale clients from overwriting each other.
|
||||||
|
// Replacing ordered membership is atomic, including validation of all foreign keys.
|
||||||
|
func (st *Store) savePlaylist(ctx context.Context, user, id uuid.UUID, name, description string, ids []uuid.UUID, revision int, create bool) error {
|
||||||
|
if err := validatePlaylist(name, description, ids); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tx, err := st.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
if create {
|
||||||
|
_, err = tx.Exec(ctx, `INSERT INTO playlists(id,user_id,name,description) VALUES($1,$2,$3,$4)`, id, user, strings.TrimSpace(name), description)
|
||||||
|
} else {
|
||||||
|
var current int
|
||||||
|
err = tx.QueryRow(ctx, `SELECT revision FROM playlists WHERE id=$1 AND user_id=$2 FOR UPDATE`, id, user).Scan(¤t)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return apperr.NotFound("playlist.not_found", "playlist not found")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if current != revision {
|
||||||
|
return apperr.Conflict("playlist.conflict", "playlist changed; reload before editing")
|
||||||
|
}
|
||||||
|
_, err = tx.Exec(ctx, `UPDATE playlists SET name=$3, description=$4, revision=revision+1, updated_at=now() WHERE id=$1 AND user_id=$2`, id, user, strings.TrimSpace(name), description)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err = tx.Exec(ctx, `DELETE FROM playlist_tracks WHERE playlist_id=$1`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(ids) > 0 {
|
||||||
|
_, err = tx.Exec(ctx, `INSERT INTO playlist_tracks(playlist_id,track_id,position) SELECT $1, id, ord-1 FROM unnest($2::uuid[]) WITH ORDINALITY AS x(id,ord)`, id, ids)
|
||||||
|
if err != nil {
|
||||||
|
var pgerr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgerr) && pgerr.Code == "23503" {
|
||||||
|
return apperr.Validation("track_ids", "playlist.invalid_tracks", "track no longer exists", nil)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) CreatePlaylist(ctx context.Context, req gen.CreatePlaylistRequestObject) (gen.CreatePlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Body == nil {
|
||||||
|
return nil, apperr.Validation("body", "playlist.invalid_name", "body required", nil)
|
||||||
|
}
|
||||||
|
id := uuid.New()
|
||||||
|
b := req.Body
|
||||||
|
if err = h.store.savePlaylist(ctx, user.UserID, id, b.Name, b.Description, b.TrackIds, 0, true); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := h.playlist(ctx, user.UserID, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.CreatePlaylist201JSONResponse(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) UpdatePlaylist(ctx context.Context, req gen.UpdatePlaylistRequestObject) (gen.UpdatePlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if req.Body == nil {
|
||||||
|
return nil, apperr.Validation("body", "playlist.invalid_name", "body required", nil)
|
||||||
|
}
|
||||||
|
b := req.Body
|
||||||
|
if err = h.store.savePlaylist(ctx, user.UserID, req.Id, b.Name, b.Description, b.TrackIds, b.Revision, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p, err := h.playlist(ctx, user.UserID, req.Id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.UpdatePlaylist200JSONResponse(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeletePlaylist(ctx context.Context, req gen.DeletePlaylistRequestObject) (gen.DeletePlaylistResponseObject, error) {
|
||||||
|
user, err := principal(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result, err := h.store.pool.Exec(ctx, `DELETE FROM playlists WHERE id=$1 AND user_id=$2`, req.Id, user.UserID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result.RowsAffected() == 0 {
|
||||||
|
return nil, apperr.NotFound("playlist.not_found", "playlist not found")
|
||||||
|
}
|
||||||
|
return gen.DeletePlaylist204Response{}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"pipu/api/internal/api/gen"
|
||||||
|
"pipu/api/internal/auth"
|
||||||
|
"pipu/api/internal/library"
|
||||||
|
"pipu/api/internal/platform/apperr"
|
||||||
|
"pipu/api/internal/platform/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlaylistPersistenceAndIsolation(t *testing.T) {
|
||||||
|
url := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if url == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL is not set")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.Connect(ctx, url)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Close()
|
||||||
|
require.NoError(t, db.Migrate(ctx, pool))
|
||||||
|
owner, other, artist, album, first, second, id := uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||||
|
_, err = pool.Exec(ctx, `INSERT INTO users(id,username,password_hash,role) VALUES($1,$2,'x','user'),($3,$4,'x','user')`, owner, owner.String(), other, other.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM users WHERE id=ANY($1)`, []uuid.UUID{owner, other})
|
||||||
|
_, err = pool.Exec(ctx, `INSERT INTO artists(id,name,sort_name,name_norm) VALUES($1,'Artist','artist',$2)`, artist, artist.String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM artists WHERE id=$1`, artist)
|
||||||
|
_, err = pool.Exec(ctx, `INSERT INTO albums(id,title,title_norm,album_artist_id) VALUES($1,'Album','album',$2)`, album, artist)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM albums WHERE id=$1`, album)
|
||||||
|
_, err = pool.Exec(ctx, `INSERT INTO tracks(id,title,title_norm,album_id,duration_ms) VALUES($1,'First','first',$3,1000),($2,'Second','second',$3,2000)`, first, second, album)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer pool.Exec(ctx, `DELETE FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second})
|
||||||
|
st := NewStore(pool)
|
||||||
|
h := &Handler{store: st, catalog: library.NewStore(pool), dto: func() library.DTO { return library.DTO{} }}
|
||||||
|
require.NoError(t, st.savePlaylist(ctx, owner, id, " Mix ", "Description", []uuid.UUID{first, second}, 0, true))
|
||||||
|
got, err := h.playlist(ctx, owner, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Mix", got.Name)
|
||||||
|
require.Equal(t, int64(3000), got.DurationMs)
|
||||||
|
require.Equal(t, first, got.Tracks[0].Id)
|
||||||
|
_, err = h.playlist(ctx, other, id)
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
require.ErrorIs(t, st.savePlaylist(ctx, other, id, "stolen", "", nil, 1, false), apperr.NotFound("playlist.not_found", ""))
|
||||||
|
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: other}), gen.DeletePlaylistRequestObject{Id: id})
|
||||||
|
require.ErrorIs(t, err, apperr.NotFound("playlist.not_found", ""))
|
||||||
|
require.NoError(t, st.savePlaylist(ctx, owner, id, "Mix", "", []uuid.UUID{second, first}, 1, false))
|
||||||
|
require.ErrorIs(t, st.savePlaylist(ctx, owner, id, "stale", "", nil, 1, false), apperr.Conflict("playlist.conflict", ""))
|
||||||
|
// A missing track rolls back both metadata and membership.
|
||||||
|
require.Error(t, st.savePlaylist(ctx, owner, id, "broken", "", []uuid.UUID{uuid.New()}, 2, false))
|
||||||
|
got, err = h.playlist(ctx, owner, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 2, got.Revision)
|
||||||
|
require.Equal(t, "Mix", got.Name)
|
||||||
|
require.Equal(t, second, got.Tracks[0].Id)
|
||||||
|
// Exactly one concurrent write may consume a revision.
|
||||||
|
results := make(chan error, 2)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range 2 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
results <- st.savePlaylist(ctx, owner, id, "Updated", "", []uuid.UUID{first}, 2, false)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(results)
|
||||||
|
successes := 0
|
||||||
|
for e := range results {
|
||||||
|
if e == nil {
|
||||||
|
successes++
|
||||||
|
} else {
|
||||||
|
require.ErrorIs(t, e, apperr.Conflict("playlist.conflict", ""))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, 1, successes)
|
||||||
|
_, err = h.DeletePlaylist(auth.WithPrincipal(ctx, auth.Principal{UserID: owner}), gen.DeletePlaylistRequestObject{Id: id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
var remaining int
|
||||||
|
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM playlist_tracks WHERE playlist_id=$1`, id).Scan(&remaining))
|
||||||
|
require.Zero(t, remaining)
|
||||||
|
// Deleting a playlist never deletes catalog tracks.
|
||||||
|
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM tracks WHERE id=ANY($1)`, []uuid.UUID{first, second}).Scan(&remaining))
|
||||||
|
require.Equal(t, 2, remaining)
|
||||||
|
rows, err := pool.Query(ctx, `SELECT id FROM playlists WHERE id=$1`, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ids, err := pgx.CollectRows(rows, pgx.RowTo[uuid.UUID])
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, ids)
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package playback
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidatePlaylist(t *testing.T) {
|
||||||
|
id := uuid.New()
|
||||||
|
require.NoError(t, validatePlaylist(" В дорогу ", "", []uuid.UUID{id}))
|
||||||
|
require.NoError(t, validatePlaylist(strings.Repeat("я", 120), strings.Repeat("я", 2000), nil))
|
||||||
|
for _, name := range []string{"", " \t\n", strings.Repeat("я", 121)} {
|
||||||
|
require.Error(t, validatePlaylist(name, "", nil))
|
||||||
|
}
|
||||||
|
require.Error(t, validatePlaylist("Mix", strings.Repeat("я", 2001), nil))
|
||||||
|
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{id, id}))
|
||||||
|
require.Error(t, validatePlaylist("Mix", "", []uuid.UUID{uuid.Nil}))
|
||||||
|
require.Error(t, validatePlaylist("Mix", "", make([]uuid.UUID, 2001)))
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
-- +goose Up
|
||||||
|
CREATE TABLE playlists (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL CHECK (char_length(name) BETWEEN 1 AND 120),
|
||||||
|
description text NOT NULL DEFAULT '' CHECK (char_length(description) <= 2000),
|
||||||
|
revision integer NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX playlists_owner ON playlists(user_id, updated_at DESC, id);
|
||||||
|
CREATE TABLE playlist_tracks (
|
||||||
|
playlist_id uuid NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||||
|
track_id uuid NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
|
position integer NOT NULL CHECK (position >= 0),
|
||||||
|
PRIMARY KEY (playlist_id, track_id),
|
||||||
|
UNIQUE (playlist_id, position)
|
||||||
|
);
|
||||||
|
CREATE INDEX playlist_tracks_track ON playlist_tracks(track_id);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DROP TABLE playlist_tracks;
|
||||||
|
DROP TABLE playlists;
|
||||||
Reference in new issue
Block a user