Compare commits

...
10 Commits
Author SHA1 Message Date
mrleo1nid ab49e22357 feat(desktop): add offline cache and verified app updates
ci / module boundaries / boundaries (push) Successful in 7s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 3m8s
ci / desktop / rust (desktop proxy) (push) Successful in 4m21s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 4m0s
release / desktop (Linux и Windows в релиз) (push) Successful in 11m49s
release / prune (старые файлы и прогоны) (push) Successful in 5s
Clean release notes in Android and desktop update dialogs. Persist downloaded audio and offline playback events per server and user; add desktop update checks, checksum verification and installer launch.
2026-10-22 22:37:35 +03:00
mrleo1nid 75ae2092ad feat(library): exclude configured folders from scanning and analysis
ci / api / api (push) Successful in 56s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 1m8s
ci / recommender / recommender (push) Failing after 40s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m25s
ci / ts / ts (packages + web) (push) Successful in 7m20s
2026-10-22 15:51:15 +03:00
mrleo1nid 0dc0384c78 fix(library): expose scan activity and failed analysis file details
ci / api / api (push) Successful in 55s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / db / db (push) Successful in 52s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
release / android (APK в релиз) (push) Successful in 3m50s
ci / ts / ts (packages + web) (push) Successful in 3m20s
release / release (версия и заметки) (push) Successful in 5s
release / desktop (Linux и Windows в релиз) (push) Successful in 44m45s
release / prune (старые файлы и прогоны) (push) Successful in 7s
2026-10-22 15:25:48 +03:00
mrleo1nid 17b6dd2041 fix(web): clarify Genius processing progress and queue actions
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / ts / ts (packages + web) (push) Successful in 5m23s
2026-10-22 15:05:11 +03:00
mrleo1nid 0a59059738 feat(playlists): share playlists with revocable read-only links
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
2026-10-22 14:57:14 +03:00
mrleo1nid bfcf6e48b2 fix: harmonize wave colors from album covers
ci / ts / ts (packages + web) (push) Canceled after 5m28s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
2026-10-22 14:52:57 +03:00
mrleo1nid 02307802bc style(recommender): format demographic integration test
ci / recommender / recommender (push) Successful in 37s
release / release (версия и заметки) (push) Successful in 5s
release / prune (старые файлы и прогоны) (push) Successful in 5s
ci / db / db (push) Successful in 54s
release / android (APK в релиз) (push) Successful in 3m54s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m38s
2026-10-22 14:25:11 +03:00
mrleo1nid fff91c446b feat(recommendations): collect demographics and advance age cohorts
ci / ts / ts (packages + web) (push) Successful in 3m19s
ci / db / db (push) Successful in 53s
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 51s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m3s
ci / recommender / recommender (push) Failing after 16s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m15s
2026-10-22 14:19:52 +03:00
mrleo1nid e158558da3 refactor: isolate playback and upload use cases
ci / module boundaries / boundaries (push) Successful in 6s
ci / api / api (push) Successful in 47s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Successful in 1m4s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m30s
ci / ts / ts (packages + web) (push) Successful in 2m54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m14s
2026-10-22 13:52:16 +03:00
mrleo1nid c84f90e2cc fix(web): scope playlist creation locator in e2e test
ci / ts / ts (packages + web) (push) Successful in 2m1s
2026-10-22 12:57:25 +03:00
141 changed files with 7300 additions and 2655 deletions

No files matched your search

+27
View File
@@ -0,0 +1,27 @@
name: ci / module boundaries
on:
push:
branches: [main]
paths:
- 'packages/core/src/**'
- 'apps/web/src/features/uploads/**'
- 'services/api/internal/uploads/**'
- 'scripts/check-boundaries.mjs'
- '.gitea/workflows/ci-boundaries.yml'
pull_request:
paths:
- 'packages/core/src/**'
- 'apps/web/src/features/uploads/**'
- 'services/api/internal/uploads/**'
- 'scripts/check-boundaries.mjs'
- '.gitea/workflows/ci-boundaries.yml'
jobs:
boundaries:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: node scripts/check-boundaries.mjs
+10
View File
@@ -0,0 +1,10 @@
# Работа с проектом
- Начинай с ближайшего локального `AGENTS.md` и файлов конкретной возможности. Карта документов: `docs/README.md`; реализация архитектуры: `docs/architecture.md`.
- Web/desktop UI: `apps/web/src`; Android: `apps/mobile/src`; общие правила клиентов: `packages/core/src`; Go API: `services/api/internal`; рекомендации: `services/recommender/pipu_rec`.
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе.
- Общие проверки: `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+2
View File
@@ -5,6 +5,7 @@ Self-hosted музыкальный сервис: веб, десктоп и Andro
- Идея продукта: [docs/IDEA.md](docs/IDEA.md)
- Документация: [docs/README.md](docs/README.md)
- Карта работы с кодом и проверок: [AGENTS.md](AGENTS.md)
- Установка сервера на свой домен: [docs/install-guide.md](docs/install-guide.md)
## Структура
@@ -47,6 +48,7 @@ pnpm --filter @pipu/web dev
```bash
pnpm typecheck && pnpm test && pnpm i18n:check && pnpm test:scripts
pnpm check:boundaries
```
```bash
+151 -2
View File
@@ -991,6 +991,23 @@ paths:
$ref: "#/components/schemas/LibraryErrorPage"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/library/analysis/errors:
get:
operationId: listAnalysisErrors
tags: [library]
summary: Неудавшиеся задачи аудиоанализа с путями файлов (admin)
parameters:
- $ref: "#/components/parameters/Limit"
- $ref: "#/components/parameters/Cursor"
responses:
"200":
description: Страница ошибок аудиоанализа, упорядоченная по ID трека
content:
application/json:
schema:
$ref: "#/components/schemas/AnalysisErrorPage"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/library/host-dirs:
get:
operationId: listHostDirs
@@ -1427,9 +1444,10 @@ paths:
get:
operationId: getOnboarding
tags: [wave]
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
summary: Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
description: |
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
`required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
`choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
responses:
"200":
description: Варианты и состояние
@@ -1706,6 +1724,54 @@ paths:
'204':
description: Deleted
/api/v1/me/playlists/{id}/share:
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
post:
tags: [collection]
operationId: sharePlaylist
summary: Создать или вернуть ссылку только для чтения (только владелец)
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistShare'
delete:
tags: [collection]
operationId: revokePlaylistShare
summary: Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку
responses:
default:
$ref: '#/components/responses/Problem'
'204':
description: Доступ по прежней ссылке отключён
/api/v1/shared/playlists/{shareId}:
parameters:
- in: path
name: shareId
required: true
schema: { type: string, format: uuid }
get:
tags: [collection]
operationId: getSharedPlaylist
summary: Прочитать плейлист по действующей ссылке (требуется вход)
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Плейлист с реакциями и ссылками на стрим для текущего слушателя
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
components:
parameters:
SourceId:
@@ -1766,6 +1832,11 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
PlaylistShare:
type: object
required: [share_id]
properties:
share_id: { type: string, format: uuid }
PlaylistInput:
type: object
required: [name, description, track_ids]
@@ -1802,6 +1873,12 @@ components:
- type: object
required: [tracks]
properties:
share_id:
type: string
format: uuid
nullable: true
readOnly: true
description: Действующая ссылка; возвращается только владельцу через /me/playlists
tracks:
type: array
items: { $ref: '#/components/schemas/Track' }
@@ -1819,6 +1896,8 @@ components:
properties:
required:
type: boolean
choice:
$ref: "#/components/schemas/OnboardingChoice"
artists:
type: array
items:
@@ -1854,6 +1933,19 @@ components:
type: object
required: [artist_ids, genres]
properties:
gender:
type: string
nullable: true
enum: [male, female, other]
age_group:
type: string
nullable: true
enum: [under_13, "13_19", "20_29", "30_39", "40_49", "50_59", 60_plus]
description: Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
age_group_year:
type: integer
readOnly: true
description: Год последнего выбора возрастной группы.
artist_ids:
type: array
maxItems: 50
@@ -2595,6 +2687,8 @@ components:
type: object
required: [id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority, scan_interval_minutes, accepts_uploads, write_template, status, status_message, last_scan_at, created_at, scan]
properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
id:
type: string
format: uuid
@@ -2655,6 +2749,8 @@ components:
type: object
required: [name, type]
properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name:
type: string
maxLength: 100
@@ -2682,6 +2778,8 @@ components:
SourcePatch:
type: object
properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name:
type: string
maxLength: 100
@@ -2710,6 +2808,14 @@ components:
description: |
Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid`
с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`.
ExcludedDirs:
type: array
maxItems: 100
description: Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan.
items:
type: string
minLength: 1
maxLength: 1000
SourceTestResult:
type: object
required: [ok, message, audio_files]
@@ -2880,6 +2986,16 @@ components:
type: object
required: [running, full, started_at, finished_at, found, to_process, processed, added, updated, moved, missing, errors, error]
properties:
phase:
type: string
enum: [walking, preparing, processing, finalizing]
description: Current scan stage; progress percentage is available only during processing.
walked_dirs:
type: integer
description: Directories visited so far, including the source root.
current_dir:
type: string
description: Current directory relative to the source root, using forward slashes.
running:
type: boolean
full:
@@ -2981,6 +3097,39 @@ components:
next_cursor:
type: string
nullable: true
AnalysisError:
type: object
required: [track_id, title, source_id, source_name, rel_path, error]
properties:
track_id:
type: string
format: uuid
title:
type: string
source_id:
type: string
format: uuid
nullable: true
source_name:
type: string
nullable: true
rel_path:
type: string
nullable: true
description: Path relative to the source root; primary file if available, otherwise a known file of this track.
error:
type: string
AnalysisErrorPage:
type: object
required: [items, next_cursor]
properties:
items:
type: array
items:
$ref: "#/components/schemas/AnalysisError"
next_cursor:
type: string
nullable: true
HostDirList:
type: object
required: [path, dirs]
+1
View File
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
+4 -1
View File
@@ -1,6 +1,7 @@
mod media;
mod proxy;
mod store;
mod update;
use media::Media;
use proxy::Proxy;
@@ -110,6 +111,7 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
.plugin(tauri_plugin_window_state::Builder::default().build())
.manage(update::Updater::default())
.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())?;
@@ -150,7 +152,8 @@ pub fn run() {
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
bootstrap, set_server, clear_server, token_get, token_set, token_clear, user_get, user_set, user_clear, media_set,
update::update_check, update::update_install
])
.run(tauri::generate_context!())
.expect("desktop app failed to start");
+6 -2
View File
@@ -86,7 +86,7 @@ fn cors(headers: &mut HeaderMap, origin: Option<&str>) {
}
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"));
headers.insert("access-control-expose-headers", HeaderValue::from_static("content-range, accept-ranges, location, upload-offset, upload-length, tus-resumable, retry-after, x-pipu-transport-error"));
}
async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
@@ -110,7 +110,11 @@ async fn forward(State(proxy): State<Proxy>, request: Request) -> Response {
} else {
match transfer(&proxy, request, tail).await {
Ok(response) => response,
Err(_) => (StatusCode::BAD_GATEWAY, "Server unavailable").into_response(),
Err(_) => {
let mut response = (StatusCode::BAD_GATEWAY, "Server unavailable").into_response();
response.headers_mut().insert("x-pipu-transport-error", HeaderValue::from_static("1"));
response
},
}
};
cors(response.headers_mut(), origin.as_deref());
+152
View File
@@ -0,0 +1,152 @@
//! Server-proxied releases: never accept installer paths or commands from the UI.
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{io::Write, path::Path, time::Duration};
use tauri::{AppHandle, Emitter, Manager, State};
use tokio::sync::Mutex;
#[derive(Clone, Deserialize, Serialize)]
pub struct Build {
client: String,
version: String,
filename: String,
size: u64,
sha256: Option<String>,
download_url: String,
}
#[derive(Deserialize)]
struct Catalog { version: String, notes_md: String, clients: Vec<Build> }
#[derive(Clone, Serialize)]
pub struct Release { build: Build, notes: String }
#[derive(Default)]
pub struct Updater { release: Mutex<Option<(String, Release)>> }
fn client() -> &'static str {
if cfg!(target_os = "windows") { "windows" }
else if cfg!(target_os = "macos") { "macos" } else { "linux" }
}
fn version_code(raw: &str) -> Option<u64> {
let (base, rc) = raw.trim_start_matches('v').split_once("-rc.").map_or((raw.trim_start_matches('v'), "99"), |v| v);
let parts: Vec<u64> = base.split('.').map(str::parse).collect::<Result<_, _>>().ok()?;
let rc: u64 = rc.parse().ok()?;
if parts.len() != 3 || parts[0] > 1_000_000 || parts[1] > 99 || parts[2] > 99 || rc > 99 { return None; }
Some(parts[0] * 1_000_000 + parts[1] * 10_000 + parts[2] * 100 + rc)
}
fn http() -> Result<reqwest::Client, String> {
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())
}
#[tauri::command]
pub async fn update_check(app: AppHandle, updater: State<'_, Updater>) -> Result<Option<Release>, String> {
let mut saved = updater.release.lock().await;
*saved = None;
let server = crate::store::load_server(&app)?.ok_or("No server selected")?;
let catalog: Catalog = http()?.get(format!("{server}/api/v1/app/clients")).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?
.json().await.map_err(|e| e.to_string())?;
let Some(build) = catalog.clients.into_iter().find(|b| b.client == client()) else { return Ok(None); };
let current = version_code(&app.package_info().version.to_string()).ok_or("Invalid installed version")?;
if version_code(&build.version).ok_or("Invalid release version")? <= current { return Ok(None); }
let notes = if build.version == catalog.version { catalog.notes_md } else { String::new() };
let release = Release { build, notes };
validate_build(&release.build)?;
*saved = Some((server, release.clone()));
Ok(Some(release))
}
fn validate_build(build: &Build) -> Result<(), String> {
let safe_name = !build.filename.is_empty() && build.filename.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b));
let extension = Path::new(&build.filename).extension().and_then(|s| s.to_str()).unwrap_or("");
let supported = match client() { "windows" => extension == "exe" || extension == "msi", "linux" => extension == "deb" || extension == "rpm", _ => extension == "dmg" };
let checksum = build.sha256.as_deref().is_some_and(|s| s.len() == 64 && s.bytes().all(|c| c.is_ascii_hexdigit()));
if !safe_name || !supported || !checksum || build.size == 0 || build.size > 1024 * 1024 * 1024 { return Err("Unsupported installer or missing checksum".into()); }
// Restrict to the existing, public Pipu client-file route; no arbitrary URL from a release.
if build.download_url != format!("/api/v1/app/download/{}/{}", client(), build.version) {
return Err("Invalid installer URL".into());
}
Ok(())
}
#[tauri::command]
pub async fn update_install(app: AppHandle, updater: State<'_, Updater>) -> Result<(), String> {
let saved = updater.release.lock().await;
let (server, release) = saved.as_ref().ok_or("Check for an update first")?;
if crate::store::load_server(&app)?.as_ref() != Some(server) { return Err("Server changed".into()); }
let build = &release.build;
validate_build(build)?;
let dir = app.path().app_cache_dir().map_err(|e| e.to_string())?.join("updates");
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let path = dir.join(&build.filename);
let part = dir.join(format!("{}.part", uuid::Uuid::new_v4()));
let result = async {
let mut response = http()?.get(format!("{server}{}", build.download_url)).send().await
.map_err(|e| e.to_string())?.error_for_status().map_err(|e| e.to_string())?;
let mut file = std::fs::File::create(&part).map_err(|e| e.to_string())?;
let mut hash = Sha256::new();
let mut bytes = 0u64;
while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? {
bytes += chunk.len() as u64;
if bytes > build.size { return Err("Installer exceeds expected size".to_string()); }
hash.update(&chunk);
file.write_all(&chunk).map_err(|e| e.to_string())?;
let _ = app.emit("update-progress", (bytes as f64 / build.size as f64).to_string());
}
if bytes != build.size || format!("{:x}", hash.finalize()) != build.sha256.as_ref().unwrap().to_lowercase() { return Err("Installer checksum mismatch".into()); }
file.sync_all().map_err(|e| e.to_string())?;
drop(file);
if path.exists() { std::fs::remove_file(&path).map_err(|e| e.to_string())?; }
std::fs::rename(&part, &path).map_err(|e| e.to_string())?;
launch(&path)?;
Ok(())
}.await;
let _ = std::fs::remove_file(&part);
result
}
fn launch(path: &Path) -> Result<(), String> {
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
let mut command = if path.extension().and_then(|s| s.to_str()) == Some("msi") {
let mut cmd = std::process::Command::new("msiexec.exe"); cmd.arg("/i").arg(path); cmd
} else { std::process::Command::new(path) };
command.creation_flags(0x08000000).spawn().map_err(|e| e.to_string())?;
}
#[cfg(target_os = "linux")]
{ std::process::Command::new("xdg-open").arg(path).spawn().map_err(|e| e.to_string())?; }
#[cfg(target_os = "macos")]
{ std::process::Command::new("open").arg(path).spawn().map_err(|e| e.to_string())?; }
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn release_order() {
assert!(version_code("1.7.2") > version_code("1.7.2-rc.3"));
assert!(version_code("1.8.0-rc.1") > version_code("1.7.99"));
assert_eq!(version_code("1.7.1"), Some(1070199));
assert_eq!(version_code("1.7.100"), None);
assert_eq!(version_code("1.7.1;cmd"), None);
}
#[test]
fn installer_is_restricted_to_verified_server_asset() {
let extension = match client() { "windows" => "exe", "linux" => "deb", _ => "dmg" };
let mut build = Build { client: client().into(), version: "1.8.0".into(), filename: format!("pipu-1.8.0.{extension}"), size: 42,
sha256: Some("a".repeat(64)), download_url: format!("/api/v1/app/download/{}/1.8.0", client()) };
assert!(validate_build(&build).is_ok());
build.download_url = "https://other.example/installer.exe".into();
assert!(validate_build(&build).is_err());
build.download_url = format!("/api/v1/app/download/{}/1.8.0", client());
build.filename = "../installer.exe".into();
assert!(validate_build(&build).is_err());
build.filename = format!("pipu.{extension}");
build.sha256 = None;
assert!(validate_build(&build).is_err());
}
}
+7
View File
@@ -0,0 +1,7 @@
# Android
- `src/player/controller.ts` — фасад и зеркало native-очереди; `service.ts` — фоновые события. Владельцем воспроизводимой очереди остаётся TrackPlayer.
- Общая политика волны — `packages/core/src`; офлайн-адаптация — `src/player/offlineWave.ts`, `src/offline`.
- Сессия/настройки — `src/lib`; маршруты — `src/app`. Сохраняй работу при выгруженном UI и изоляцию офлайн-данных по пользователю/серверу.
- `pnpm --filter @pipu/mobile typecheck`, `pnpm --filter @pipu/mobile test`.
- Изменения native playback требуют проверки на устройстве: background, смена трека, offline→online. Unit-тесты не заменяют эту проверку.
+5
View File
@@ -9,6 +9,7 @@ import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { ErrorBox, Icon, Screen, useErrorText, type IconName } from '../../ui/kit';
import { OnboardingGate } from '../../ui/Onboarding';
import { WaveOrb } from '../../ui/wave/WaveOrb';
/** Имена из /wave/options (Material Symbols) → MaterialIcons. */
@@ -22,6 +23,10 @@ function iconFor(name: string): IconName {
* На Android спектр недоступен, поэтому пульс берётся из BPM и energy трека (docs/mobile.md).
*/
export default function HomeScreen() {
return <OnboardingGate><WaveHome /></OnboardingGate>;
}
function WaveHome() {
const colors = useColors();
const { t } = useTranslation();
const tr = t as unknown as (k: string) => string;
+3
View File
@@ -15,6 +15,7 @@ import { player } from '../../player/controller';
import { radius, useColors } from '../../theme';
import { Button, H1, Icon, Muted, Screen, Segmented } from '../../ui/kit';
import { useDownloads } from '../../ui/downloads';
import { RecommendationSettings } from '../../ui/Onboarding';
import { PasswordSheet, QualitySheet, useQualityPrefs } from '../../ui/settings';
import { useUpdateState } from '../../ui/UpdateDialog';
@@ -74,6 +75,8 @@ export default function ProfileScreen() {
@{user.username} · {t(`roles.${user.role}`)}
</Muted>
<RecommendationSettings />
<Section>
<Row label={t('language.label')}>
<Segmented<Locale>
+17 -4
View File
@@ -1,11 +1,11 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { playlistShareUrl, 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 { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
@@ -78,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
function Detail({ id }: { id: string }) {
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
@@ -89,6 +90,15 @@ function Detail({ id }: { id: string }) {
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 sharing = useMutation({ mutationFn: async () => {
if (!server || !p) throw new Error('Server unavailable');
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
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'] }); } });
@@ -105,11 +115,14 @@ function Detail({ id }: { id: string }) {
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>
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : 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.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
<Muted>{t('playlists.share_hint')}</Muted>
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
<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}),[
+40 -41
View File
@@ -1,6 +1,11 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
type WaveOutcome,
type WaveFeedback,
PlayAttempt,
normalizeGain,
unwrap,
@@ -24,12 +29,7 @@ import { eventId } from './eventId';
export { offlineWave };
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export type { WaveInfo } from '@pipu/core';
export interface PlayerState {
ready: boolean;
@@ -44,10 +44,6 @@ export interface PlayerState {
wave: WaveInfo | null;
}
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
const WAVE_REFILL_AT = 2;
const EARLY_SKIP_MS = 30_000;
const SAVE_EVERY_MS = 10_000;
const playable = (t: Track) => t.available && (!!t.stream_url || downloads.isDownloaded(t.id));
@@ -84,8 +80,13 @@ class Controller {
private attempt: PlayAttempt | null = null;
private lastPositionMs = 0;
private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private readonly wavePolicy = new WaveCoordinator({
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
await this.session!.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id } }, body: { feedback },
}),
),
});
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>();
@@ -308,18 +309,22 @@ class Controller {
if (!this.session) return;
await this.setup();
this.leaveWave();
const version = this.wavePolicy.version;
let res;
try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', { body: { activity, mood } }));
} catch (e) {
if (!this.wavePolicy.isCurrent(version)) return;
// Нет связи с сервером — офлайн-волна из скачанного.
const local = offlineWave(downloads.downloaded());
if (!local.length) throw e;
return this.playList(local, 0, { type: 'collection', id: 'downloads' });
}
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter(playable);
if (!tracks.length) return;
this.finishAttempt(this.lastPositionMs / 1000);
this.wavePolicy.activate(res.session_id);
this.update({
queue: tracks, index: 0, track: tracks[0] ?? null, context: { type: 'wave', id: res.session_id },
wave: { sessionId: res.session_id, activity: res.activity, mood: res.mood, fallback: res.fallback },
@@ -332,13 +337,16 @@ class Controller {
async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
await this.replaceUpcoming(res.tracks);
if (!this.wavePolicy.isCurrent(version)) return;
await this.replaceUpcoming(res.tracks, () => this.wavePolicy.isCurrent(version));
if (!this.wavePolicy.isCurrent(version)) return;
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
if (res.tracks.some(playable)) {
@@ -351,8 +359,8 @@ class Controller {
private leaveWave() {
const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null });
void this.session?.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -360,46 +368,36 @@ class Controller {
}
private noteWave(trackId: string, outcome: WaveOutcome) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
this.wavePolicy.note(trackId, outcome);
}
/** Убрать не начатые треки и поставить новые (после реакции или смены контекста). */
private async replaceUpcoming(tracks: Track[]) {
private async replaceUpcoming(tracks: Track[], current: () => boolean = () => true) {
const keep = this.state.queue.slice(0, this.state.index + 1);
await TrackPlayer.removeUpcomingTracks();
const add = tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
this.update({ queue: [...keep, ...add] });
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.removeUpcomingTracks();
if (!current()) return;
await TrackPlayer.add(native);
if (current()) this.update({ queue: [...keep, ...add] });
}
private async refillWave(refresh = false) {
const wave = this.state.wave;
if (!wave || !this.session || this.waveLoading) return;
if (!this.session) return;
const remaining = this.state.queue.length - this.state.index - 1;
if (!refresh && remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return;
if (refresh) await this.replaceUpcoming(res.tracks);
await this.wavePolicy.refill(remaining, refresh, async (res, current) => {
if (refresh) await this.replaceUpcoming(res.tracks, current);
else {
const add = res.tracks.filter(playable);
await TrackPlayer.add(await this.toRntp(add));
const native = await this.toRntp(add);
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
this.update({ queue: [...this.state.queue, ...add] });
}
this.update({ wave: { ...wave, fallback: res.fallback } });
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback];
} finally {
this.waveLoading = false;
}
if (current()) this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
});
}
// --- состояние ---
@@ -443,6 +441,7 @@ class Controller {
}
async reset() {
this.wavePolicy.activate(null);
this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
if (this.setupDone) await TrackPlayer.reset().catch(() => undefined);
+85
View File
@@ -0,0 +1,85 @@
import type { components } from '@pipu/api-client';
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
import { useColors } from '../theme';
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from './kit';
type Options = components['schemas']['Onboarding'];
type Choice = components['schemas']['OnboardingChoice'];
function useOnboarding() {
const session = useSession();
const user = useUser();
const { server } = useSessionCtx();
return useQuery({ queryKey: ['onboarding', server, user.id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: 0, refetchInterval: 30_000 });
}
export function OnboardingGate({ children }: { children: ReactNode }) {
const query = useOnboarding();
const client = useQueryClient();
if (query.isPending) return <Screen><ActivityIndicator /></Screen>;
if (!query.data?.required) return <>{children}</>;
return <Screen><ScrollView contentContainerStyle={{ padding: 16 }}>
<Onboarding options={query.data} onDone={() => void client.invalidateQueries({ queryKey: ['onboarding'] })} />
</ScrollView></Screen>;
}
export function RecommendationSettings() {
const { t } = useTranslation();
const query = useOnboarding();
const client = useQueryClient();
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
return <View>
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
<ErrorBox>{query.error ? errorText(query.error) : null}</ErrorBox>
</View>;
}
function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [choice, setChoice] = useState<Choice>(options.choice ?? { artist_ids: [], genres: [], gender: null, age_group: null });
const save = useMutation({ mutationFn: async (skip: boolean) => unwrap(await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : choice,
})), onSuccess: onDone });
const toggle = (key: 'artist_ids' | 'genres', value: string) => setChoice((old) => ({ ...old,
[key]: old[key].includes(value) ? old[key].filter((x) => x !== value) : [...old[key], value],
}));
return <View style={{ gap: 20 }}>
<H1>{t('onboarding.title')}</H1><Muted>{t('onboarding.subtitle')}</Muted>
<Choices title={t('onboarding.gender')} items={[{ value: '', label: t('onboarding.unspecified') }, ...GENDERS.map((g) => ({ value: g, label: t(`onboarding.genders.${g}`) }))]}
selected={[choice.gender ?? '']} onPick={(v) => setChoice((old) => ({ ...old, gender: (v || null) as Choice['gender'] }))} />
<Choices title={t('onboarding.age_group')} items={[{ value: '', label: t('onboarding.unspecified') }, ...AGE_GROUPS.map((g) => ({ value: g, label: t(`onboarding.ages.${g}`) }))]}
selected={[choice.age_group ?? '']} onPick={(v) => setChoice((old) => ({ ...old, age_group: (v || null) as Choice['age_group'] }))} />
<Muted>{t('onboarding.age_hint')}</Muted>
<Choices title={t('onboarding.artists')} items={options.artists.map((a) => ({ value: a.id, label: a.name }))} selected={choice.artist_ids} onPick={(v) => toggle('artist_ids', v)} />
<Choices title={t('onboarding.genres')} items={options.genres.map((g) => ({ value: g.name, label: g.name }))} selected={choice.genres} onPick={(v) => toggle('genres', v)} />
<ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox>
<Button title={t(editing ? 'settings.save' : 'onboarding.done')} busy={save.isPending} onPress={() => save.mutate(false)} />
<Button title={t(editing ? 'form.cancel' : 'onboarding.skip')} variant="ghost" disabled={save.isPending} onPress={() => editing ? onDone() : save.mutate(true)} />
</View>;
}
function Choices({ title, items, selected, onPick }: { title: string; items: { value: string; label: string }[]; selected: string[]; onPick: (value: string) => void }) {
const colors = useColors();
return <View style={{ gap: 10 }}><Text style={{ color: colors.text, fontWeight: '600' }}>{title}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>{items.map((item) => {
const on = selected.includes(item.value);
return <Pressable key={item.value} accessibilityRole="button" accessibilityState={{ selected: on }} onPress={() => onPick(item.value)}
style={{ padding: 12, borderRadius: 20, backgroundColor: on ? colors['accent-fill'] : colors.surface2 }}>
<Text style={{ color: on ? '#fff' : colors.text }}>{item.label}</Text>
</Pressable>;
})}</View>
</View>;
}
+5 -14
View File
@@ -1,4 +1,5 @@
import { useEffect, useSyncExternalStore } from 'react';
import { releaseNotesText } from '@pipu/core';
import { useTranslation } from 'react-i18next';
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
@@ -10,7 +11,6 @@ import { Button } from './kit';
export function useUpdateState() {
return useSyncExternalStore(updater.subscribe, () => updater.state);
}
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
export function UpdateDialog() {
const colors = useColors();
@@ -32,6 +32,7 @@ export function UpdateDialog() {
const { release, required } = st;
const busy = st.status === 'downloading' || st.status === 'verifying';
const notes = releaseNotesText(release.notes_md);
const action = () => void updater.downloadAndInstall(session, server ?? '');
return (
@@ -57,9 +58,9 @@ export function UpdateDialog() {
{t('mobile.update_title', { version: release.version })}
</Text>
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
{release.notes_md.trim() !== '' && (
<ScrollView style={{ maxHeight: 240 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text>
{notes !== '' && (
<ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
</ScrollView>
)}
<Text style={{ color: colors.text3, fontSize: 12 }}>
@@ -115,13 +116,3 @@ export function UpdateDialog() {
</Modal>
);
}
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
function plainNotes(md: string): string {
return md
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/^\s*[-*]\s+/gm, '• ')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.trim();
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core';
import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useState, type ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { AppState, Pressable, View } from 'react-native';
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
}, []),
);
const [foreground, setForeground] = useState(AppState.currentState === 'active');
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string];
const palette = useMemo(() => wavePalette(colors), [colors]);
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
const state = useSharedValue<WaveState>(initialWaveState(size));
+72
View File
@@ -0,0 +1,72 @@
import type { AuthSession, Track } from '@pipu/core';
import { beforeEach, expect, it, vi } from 'vitest';
vi.mock('@react-native-community/netinfo', () => ({ default: {
fetch: async () => ({ type: 'wifi' }), addEventListener: () => () => {},
} }));
vi.mock('../src/lib/prefs', () => ({
qualityPrefs: { load: async () => ({ wifi: 'high', cellular: 'medium' }) },
playerCache: { clearIfRequested: async () => {} },
}));
vi.mock('../src/offline/events', () => ({ PersistentEvents: class {} }));
vi.mock('../src/offline/downloads', () => ({
downloads: { isDownloaded: () => false, localTrack: () => undefined, localCover: () => undefined, downloaded: () => [] },
sizedCover: (s: string) => s,
}));
vi.mock('react-native-track-player', () => ({
default: {
isServiceRunning: async () => true, setupPlayer: async () => {}, updateOptions: async () => {},
addEventListener: vi.fn(), reset: vi.fn().mockResolvedValue(undefined),
add: vi.fn().mockResolvedValue(undefined), play: vi.fn().mockResolvedValue(undefined),
skip: vi.fn().mockResolvedValue(undefined),
},
AppKilledPlaybackBehavior: {}, Capability: {}, Event: {}, State: {},
}));
function track(id: string): Track {
return { id, title: id, duration_ms: 1000, artists: [],
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
genres: [], disc_no: 1, track_no: 1, available: true, stream_url: `/s/${id}?sig=x`, reaction: null, features: null };
}
function deferred<T>() {
let resolve!: (v: T) => void;
const promise = new Promise<T>((yes) => { resolve = yes; });
return { promise, resolve };
}
const reply = (id: string) => ({ data: { session_id: id, activity: null, mood: null, fallback: false, tracks: [track(id)] }, response: new Response() });
beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); });
it('ignores a wave creation response after the player is reset', async () => {
const { player } = await import('../src/player/controller');
const native = (await import('react-native-track-player')).default;
const pending = deferred<ReturnType<typeof reply>>();
const post = vi.fn().mockReturnValue(pending.promise);
player.attach({ client: { POST: post } } as unknown as AuthSession, 'http://localhost', null);
const start = player.startWave(null, null);
await vi.waitFor(() => expect(post).toHaveBeenCalled());
await player.reset();
pending.resolve(reply('old'));
await start;
expect(player.getState().wave).toBeNull();
expect(player.getState().queue).toEqual([]);
expect(native.add).not.toHaveBeenCalled();
});
it('does not let a late context response replace a manually selected playlist', async () => {
const { player } = await import('../src/player/controller');
const pending = deferred<ReturnType<typeof reply>>();
const session = { client: {
POST: vi.fn().mockResolvedValue(reply('wave')),
PATCH: vi.fn().mockReturnValue(pending.promise), DELETE: vi.fn().mockResolvedValue({}),
} };
player.attach(session as unknown as AuthSession, 'http://localhost', null);
await player.startWave(null, null);
const change = player.setWaveContext('focus', null);
await player.playList([track('manual')], 0, { type: 'album', id: 'album' });
pending.resolve(reply('stale'));
await change;
expect(player.getState().wave).toBeNull();
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
expect(player.getState().context?.type).toBe('album');
});
+8
View File
@@ -0,0 +1,8 @@
# Web и desktop UI
- Маршруты: `src/app/router.tsx`; сессия: `src/app/session.tsx`; страницы собирают возможности, `src/features` владеет сценариями.
- Воспроизведение: `src/player/player.ts` — фасад; адаптеры рядом. Общие правила очереди, событий и волны — `packages/core/src`.
- Загрузки: `src/features/uploads`; страница очереди — `src/pages/admin/UploadQueuePage.tsx`. Сохраняй query keys/invalidation, права и поведение диалогов.
- `pnpm --filter @pipu/web typecheck`; `pnpm --filter @pipu/web test`. Для локальной проверки можно передать путь теста после `test`.
- UI/навигация/диалоги: `pnpm --filter @pipu/web test:e2e`. Проверки используют fake API; реальная серверная авторизация покрывается Go отдельно.
- Restore не запускает музыку и не создаёт событие прослушивания. Ошибка preload не пропускает трек. Dispose должен освобождать обработчики.
+63
View File
@@ -0,0 +1,63 @@
import { expect, test } from '@playwright/test';
test('desktop files survive restart, isolate owners and clear only recent cache', async ({ page }) => {
await page.goto('/login');
const saved = await page.evaluate(async () => {
const source = '/src/features/desktop/offline.ts';
const { DesktopOffline } = await import(source);
const track = { id: 'cached-track', title: 'Offline song', available: true, stream_url: 'data:audio/wav;base64,UklGRg==', duration_ms: 1000, artists: [], album: { id: 'album', title: 'Album', cover_url: null }, genres: [], reaction: null };
const session = { client: { POST: async () => ({ data: { items: [track] }, response: new Response('{}') }) } };
const offline = new DesktopOffline('test-server/alice', session);
await offline.ready;
await offline.save(track, true);
await offline.save({ ...track, id: 'recent-track' });
const tracks = offline.tracks();
const result = { count: tracks.length, blob: tracks[0].stream_url.startsWith('blob:'), pinned: offline.pinned(track.id) };
offline.dispose();
return result;
});
expect(saved).toEqual({ count: 2, blob: true, pinned: true });
await page.reload();
const restored = await page.evaluate(async () => {
const source = '/src/features/desktop/offline.ts';
const { DesktopOffline } = await import(source);
const session = { client: { POST: async () => { throw new TypeError('offline'); } } };
const alice = new DesktopOffline('test-server/alice', session);
const bob = new DesktopOffline('test-server/bob', session);
await Promise.all([alice.ready, bob.ready]);
const before = alice.tracks().length;
const body = await (await fetch(alice.tracks()[0].stream_url)).blob();
await alice.clearRecent();
const result = { before, bytes: body.size, after: alice.tracks().map((t: { id: string }) => t.id), other: bob.tracks().length };
alice.dispose(); bob.dispose();
return result;
});
expect(restored).toEqual({ before: 2, bytes: 4, after: ['cached-track'], other: 0 });
});
test('desktop update dialog hides build reports and launches installer', async ({ page }) => {
await page.addInitScript(() => {
localStorage.setItem('pipu.locale', 'ru');
(window as Window & { __TAURI__?: unknown }).__TAURI__ = {
core: { invoke: async (command: string) => {
if (command === 'bootstrap') return { proxyBase: location.origin, server: 'https://music.example' };
if (command === 'update_check') return { build: { version: '1.8.0', size: 1048576 }, notes: '<!-- android-status -->\n> APK SHA-256 abc\n<!-- /android-status -->\n## Изменения\n- **Офлайн-музыка**' };
if (command === 'update_install') document.documentElement.dataset.installer = 'opened';
return null;
} },
event: { listen: async () => () => undefined },
};
});
await page.goto('/login');
await page.getByLabel('Логин').fill('alice');
await page.getByRole('textbox', { name: 'Пароль' }).fill('alice-password');
await page.getByRole('button', { name: 'Войти' }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Доступна версия 1.8.0');
await expect(dialog).toContainText('• Офлайн-музыка');
await expect(dialog).not.toContainText('android-status');
await expect(dialog).not.toContainText('SHA-256');
await dialog.getByRole('button', { name: 'Скачать и установить' }).click();
await expect(dialog).toContainText('Установщик открыт');
await expect(page.locator('html')).toHaveAttribute('data-installer', 'opened');
});
+17 -1
View File
@@ -3,6 +3,7 @@ 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.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
@@ -10,7 +11,10 @@ for (const width of [320, 1280]) {
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();
const playlistsHeader = page.locator('header').filter({
has: page.getByRole('heading', { name: 'Плейлисты', exact: true }),
});
await playlistsHeader.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();
@@ -27,10 +31,22 @@ for (const width of [320, 1280]) {
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).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') });
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
});
}
+9
View File
@@ -48,6 +48,15 @@ test('settings expose crossfade and loudness normalization', async ({ page }) =>
const slider = page.getByRole('slider', { name: /Кроссфейд/ });
await slider.fill('4');
await expect(page.getByText('Кроссфейд, 4 с')).toBeVisible();
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
await page.getByRole('button', { name: 'Мужской', exact: true }).click();
await page.getByRole('button', { name: '13–19', exact: true }).click();
const form = page.getByRole('heading', { name: 'Что вы любите слушать?' }).locator('../..');
await form.getByRole('button', { name: 'Сохранить', exact: true }).click();
await page.getByRole('button', { name: 'Настроить рекомендации' }).click();
await expect(page.getByRole('button', { name: 'Мужской', exact: true })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByRole('button', { name: '13–19', exact: true })).toHaveAttribute('aria-pressed', 'true');
});
test('9. login page switches between Russian and English', async ({ page }) => {
await page.goto('/login');
+80 -1
View File
@@ -10,7 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App';
import { createAppRouter, safeRedirect } from './app/router';
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
@@ -479,3 +479,82 @@ describe('playlists', () => {
expect(api.playlists.get(item.id)?.name).toBe('Другое устройство');
});
});
describe('recommendation preferences', () => {
it('saves demographics, edits the age group and asks again after an admin reset', async () => {
const admin = makeUser('owner', 'admin');
const { user, api, router, session } = await renderApp([admin], '/settings', { signedInAs: admin });
await user.click(await screen.findByRole('button', { name: 'Настроить рекомендации' }));
await user.click(await screen.findByRole('button', { name: 'Мужской' }));
await user.click(screen.getByRole('button', { name: '13–19' }));
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
await screen.findByRole('button', { name: 'Настроить рекомендации' });
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toMatchObject({ gender: 'male', age_group: '13_19' });
await user.click(screen.getByRole('button', { name: 'Настроить рекомендации' }));
expect(await screen.findByRole('button', { name: '13–19' })).toHaveAttribute('aria-pressed', 'true');
await user.click(screen.getByRole('button', { name: '20–29' }));
await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!);
await screen.findByRole('button', { name: 'Настроить рекомендации' });
await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: admin.id } } });
await router.navigate({ to: '/' });
await screen.findByRole('heading', { name: 'Что вы любите слушать?' });
expect(screen.getByRole('button', { name: 'Мужской' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('button', { name: '20–29' })).toHaveAttribute('aria-pressed', 'false');
await user.click(screen.getByRole('button', { name: 'Пропустить' }));
await screen.findByRole('button', { name: 'Запустить «Мою волну»' });
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
});
});
describe('playlist sharing', () => {
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
const alice = makeUser('alice', 'user');
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
const playlist = created.data!;
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await screen.findByRole('heading', { name: 'Общий микс' });
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
const shareId = api.playlists.get(playlist.id)!.share_id!;
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('heading', { name: 'Общий микс' });
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
await screen.findByRole('contentinfo', { name: 'Плеер' });
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('alert');
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
});
it('returns to the shared playlist after login without starting playback', async () => {
const bob = makeUser('bob', 'user');
const shareId = '00000000-0000-4000-9001-000000000001';
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
await screen.findByRole('heading', { name: 'Вход' });
await user.type(screen.getByLabelText('Логин'), bob.username);
await user.type(screen.getByLabelText('Пароль'), bob.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'От друга' });
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
});
});
+7
View File
@@ -33,6 +33,7 @@ import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
export interface RouterContext {
session: AuthSession;
@@ -145,7 +146,11 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> });
const sharedPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/shared/playlist/$shareId', component: () => <PlaylistPage key={sharedPlaylistRoute.useParams().shareId} id={sharedPlaylistRoute.useParams().shareId} shared /> });
const playlistsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/playlists', component: PlaylistsPage });
const downloadsRoute = createRoute({ getParentRoute: () => appRoute, path: '/collection/downloads', component: DesktopDownloadsPage,
beforeLoad: ({ context }) => { if (!context.desktop) throw redirect({ to: '/settings' }); },
});
const playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
@@ -280,6 +285,8 @@ const routeTree = rootRoute.addChildren([
homeRoute,
trackRoute,
playlistsRoute,
downloadsRoute,
sharedPlaylistRoute,
playlistRoute,
settingsRoute,
searchRoute,
+18 -1
View File
@@ -1,12 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AuthSession, type AuthUser } from '@pipu/core';
import { createDesktop, isDesktop } from './desktop';
import { createDesktop, desktopFetch, isDesktop } from './desktop';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('desktop bridge', () => {
it('restores the last desktop user when the proxy cannot reach the server', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
const user = { id: 'alice', username: 'alice' } as AuthUser;
const clear = vi.fn();
const session = new AuthSession({ clientType: 'desktop', fetch: desktopFetch, baseUrl: 'http://local', refreshStore: {
get: async () => 'refresh', set: vi.fn(), clear, getUser: async () => user,
} });
expect(await session.restore()).toEqual({ status: 'signed_in', offline: true, user });
expect(clear).not.toHaveBeenCalled();
});
it('reports a failed proxy hop as a network failure, preserving real HTTP errors', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('offline', { status: 502, headers: { 'x-pipu-transport-error': '1' } })));
await expect(desktopFetch('http://local/api')).rejects.toThrow('Server unavailable');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('denied', { status: 401 })));
expect((await desktopFetch('http://local/api')).status).toBe(401);
});
it('is inactive in the browser', () => {
expect(isDesktop()).toBe(false);
});
+9 -1
View File
@@ -14,11 +14,19 @@ export function isDesktop(): boolean {
return api() !== undefined;
}
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
/** A failed loopback hop must behave like a network failure to offline auth restore. */
export const desktopFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (response.headers.get('x-pipu-transport-error') === '1') throw new TypeError('Server unavailable');
return response;
};
export function desktopInvoke<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>;
}
const invoke = desktopInvoke;
export type DesktopHandle = {
proxyBase: string;
+2
View File
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists';
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
@@ -218,6 +219,7 @@ export function TrackList({
</Link>
)}
<span className={s.end} data-playlist-actions={actions ? true : undefined}>
{!actions && <SaveOfflineButton track={tr} compact />}
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
{disliked ? (
<button
+7 -4
View File
@@ -3,7 +3,7 @@ import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useSession, useUser } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { playlistAddIcon } from '../shared/ui/icons';
@@ -18,10 +18,13 @@ export function usePlaylists(enabled = true) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
}
export function usePlaylist(id: string) {
export function usePlaylist(id: string, shared = false) {
const session = useSession();
return useQuery({ queryKey: ['playlists', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
const user = useUser();
return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
queryFn: async () => shared
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
}
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
@@ -0,0 +1,147 @@
import { releaseNotesText, type Track } from '@pipu/core';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession } from '../../app/session';
import { desktopInvoke, useDesktop } from '../../desktop';
import { TrackList } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import { formatBytes } from '../../shared/lib/format';
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
import s from '../../pages/page.module.css';
export function useOffline() {
const offline = usePlayer().offline;
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
return offline;
}
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
const offline = useOffline();
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
if (!offline) return null;
return <span>
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
setBusy(true); setError(false);
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
.catch(() => setError(true)).finally(() => setBusy(false));
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
{error && <span role="alert">{t('desktop.download_failed')}</span>}
</span>;
}
export function DesktopOfflineSection() {
const offline = useOffline();
const { track } = usePlayerState();
const { t, i18n } = useTranslation();
const [error, setError] = useState(false);
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
if (!offline) return null;
const tracks = offline.tracks();
return <section className={s.section}>
<h2>{t('desktop.offline')}</h2>
<p className={s.note}>{t('desktop.cache_hint')}</p>
<div className={s.toolbar}>
<span>{formatBytes(offline.bytes, i18n.language)}</span>
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
{track && <SaveOfflineButton track={track} />}
</div>
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
</section>;
}
export function DesktopDownloadsPage() {
const { t } = useTranslation();
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
}
export function DesktopOfflineBanner() {
const state = useAuthState();
const desktop = useDesktop();
const { t } = useTranslation();
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
}
type Release = { build: { version: string; size: number }; notes: string };
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
let update: Update = { status: 'idle' };
let generation = 0;
const listeners = new Set<() => void>();
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
async function checkUpdate(server: string, manual = false) {
if (update.status === 'checking' || update.status === 'installing') return;
const key = `pipu.update.checked.${server}`;
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
setUpdate({ status: 'checking' });
const requestGeneration = generation;
try {
const release = await desktopInvoke<Release | null>('update_check');
if (requestGeneration !== generation) return;
localStorage.setItem(key, String(Date.now()));
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
}
export function DesktopUpdateButton() {
const desktop = useDesktop();
const state = useUpdate();
const { t } = useTranslation();
if (!desktop?.server) return null;
return <section className={s.section}>
<h2>{t('desktop.updates')}</h2>
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
</section>;
}
export function DesktopUpdateDialog() {
const desktop = useDesktop();
const session = useSession();
const state = useUpdate();
const { t } = useTranslation();
useEffect(() => {
if (!desktop?.server) return;
const check = () => void checkUpdate(desktop.server!);
check();
window.addEventListener('focus', check);
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
}, [desktop, session]);
useEffect(() => {
if (!desktop) return;
let stop: (() => void) | undefined;
let disposed = false;
// The native updater emits progress through the same Tauri event surface as media controls.
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
void api?.event.listen('update-progress', (e) => {
const progress = Number(e.payload);
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
}).then((fn) => { if (disposed) fn(); else stop = fn; });
return () => { disposed = true; stop?.(); };
}, [desktop]);
if (!desktop || !state.release || state.dismissed) return null;
const release = state.release;
const busy = state.status === 'installing';
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
const notes = releaseNotesText(release.notes);
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
<Button busy={busy} onClick={() => {
setUpdate({ status: 'installing', release, progress: 0 });
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
}}>{t('mobile.update_install')}</Button>
</>}>
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
</Dialog>;
}
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AuthSession, PlayEvent } from '@pipu/core';
import { desktopEventSink, evictRecent, offlineOwner } from './offline';
import { createDesktop } from '../../desktop';
import { albumTracks } from '../../test/fakeApi';
afterEach(() => {
localStorage.clear();
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
});
describe('desktop cache policy', () => {
it('evicts oldest recent files while keeping manual downloads outside the limit', () => {
const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob });
expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']);
});
});
it('persists offline events across sink restarts and deduplicates retries', async () => {
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } };
await createDesktop();
const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') });
const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession;
const owner = offlineOwner(session)!;
const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' };
await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline');
await desktopEventSink(session, owner)([event]);
expect(post.mock.calls[1]?.[1].body.events).toEqual([event]);
expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]);
});
+181
View File
@@ -0,0 +1,181 @@
import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core';
import { desktopHandle } from '../../desktop';
export const RECENT_LIMIT = 500 * 1024 * 1024;
type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number };
const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0);
export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] {
let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0);
const removed: string[] = [];
for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) {
if (total <= limit) break;
removed.push(e.id);
total -= size(e);
}
return removed;
}
export function offlineOwner(session: AuthSession): string | null {
const desktop = desktopHandle();
return desktop?.server && session.state.status === 'signed_in'
? JSON.stringify([desktop.server, session.state.user.id]) : null;
}
/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */
export class DesktopOffline {
private db: Promise<IDBDatabase> | null = null;
private loaded: Promise<void> | null = null;
private entries: Entry[] = [];
private urls = new Map<string, Track>();
private objects = new Map<string, string[]>();
private inUse = new Set<string>();
private listeners = new Set<() => void>();
private revision = 0;
private writes: Promise<unknown> = Promise.resolve();
private disposed = false;
get ready(): Promise<void> {
this.loaded ??= this.reload();
return this.loaded;
}
activate() { this.disposed = false; }
constructor(readonly owner: string, private session: AuthSession) {}
private database(): Promise<IDBDatabase> {
return this.db ??= new Promise((resolve, reject) => {
const request = indexedDB.open(`pipu.offline.${this.owner}`, 1);
request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
snapshot = () => this.revision;
private async reload() {
const db = await this.database();
const entries = await new Promise<Entry[]>((resolve, reject) => {
const req = db.transaction('tracks').objectStore('tracks').getAll();
req.onsuccess = () => resolve(req.result as Entry[]);
req.onerror = () => reject(req.error);
});
if (this.disposed) return;
this.entries = entries;
for (const e of entries) {
if (this.urls.has(e.id)) continue;
const audio = URL.createObjectURL(e.audio);
const cover = e.cover ? URL.createObjectURL(e.cover) : null;
this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]);
this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } });
}
for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) {
this.urls.delete(id);
if (!this.inUse.has(id)) this.release(id);
}
this.revision++;
for (const fn of this.listeners) fn();
}
tracks() { return this.entries.map((e) => this.local(e.track)); }
private release(id: string) {
for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url);
this.objects.delete(id);
}
/** Keep only files currently playing/crossfading alive after cache deletion. */
keep(ids: string[]) {
this.inUse = new Set(ids);
for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id);
}
local(track: Track): Track { return this.urls.get(track.id) ?? track; }
has(id: string) { return this.entries.some((e) => e.id === id); }
pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); }
get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); }
private mutate(fn: (store: IDBObjectStore) => void): Promise<void> {
const work = this.writes.catch(() => undefined).then(async () => {
const db = await this.database();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('tracks', 'readwrite');
fn(tx.objectStore('tracks'));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
await this.reload();
});
this.writes = work;
return work;
}
private downloads = new Map<string, Promise<void>>();
private downloadTail: Promise<unknown> = Promise.resolve();
async save(track: Track, pinned = false): Promise<void> {
await this.ready;
const pending = this.downloads.get(track.id);
if (pending) { await pending; return this.save(track, pinned); }
const existing = this.entries.find((e) => e.id === track.id);
if (existing) {
await this.mutate((store) => {
const latest = this.entries.find((e) => e.id === track.id);
if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() });
});
return;
}
const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned));
this.downloadTail = job;
this.downloads.set(track.id, job);
try { await job; } finally { this.downloads.delete(track.id); }
}
private async download(track: Track, pinned: boolean) {
if (this.disposed) return;
const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0];
if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable');
const response = await fetch(fresh.stream_url);
if (!response.ok) throw new Error('Download failed');
const audio = await response.blob();
if (!audio.size) throw new Error('Empty download');
let cover: Blob | undefined;
if (fresh.album.cover_url) {
try {
const response = await fetch(`${fresh.album.cover_url}&size=300`);
if (response.ok) cover = await response.blob();
} catch { /* Artwork is optional. */ }
}
if (this.disposed) return;
const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() };
await this.mutate((store) => {
const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]);
for (const id of removed) store.delete(id);
if (!removed.includes(entry.id)) store.put(entry);
});
}
async remove(id: string) { await this.mutate((store) => store.delete(id)); }
async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); }
dispose() {
this.disposed = true;
queueMicrotask(() => {
if (!this.disposed) return;
for (const id of this.objects.keys()) this.release(id);
void this.downloadTail.catch(() => undefined).then(() => this.writes)
.finally(() => this.db?.then((db) => db.close())).catch(() => undefined);
});
}
}
/** Persist before sending; retries keep event IDs for server-side deduplication. */
export function desktopEventSink(session: AuthSession, owner: string) {
const key = `pipu.events.${owner}`;
let sending = Promise.resolve();
return async (events: PlayEvent[]) => {
const work = sending.catch(() => undefined).then(async () => {
const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[];
const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()];
localStorage.setItem(key, JSON.stringify(merged));
if (offlineOwner(session) !== owner) return;
while (merged.length) {
if (offlineOwner(session) !== owner) return;
const batch = merged.slice(0, 100);
unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } }));
merged.splice(0, batch.length);
localStorage.setItem(key, JSON.stringify(merged));
}
});
sending = work;
await work;
};
}
@@ -0,0 +1,69 @@
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useErrorText } from '../../shared/lib/errors';
import { useUploadsApi } from './queries';
import { useTranslation } from 'react-i18next';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
export function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const errorText = useErrorText();
const [artist, setArtist] = useState('');
const [album, setAlbum] = useState('');
const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined);
const save = useMutation({
mutationFn: () => api.updateBatch(batchId, {
...(artist.trim() ? { artist: artist.trim() } : {}),
...(album.trim() ? { album: album.trim() } : {}),
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
...(cover !== undefined ? { cover_base64: cover } : {}),
}),
onSuccess: onSaved,
});
const pickCover = (file: File | undefined) => {
if (!file) {
setCover(undefined);
return;
}
const reader = new FileReader();
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
};
return (
<Dialog
title={t('uploads.batch_edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
</label>
<Button variant="ghost" onClick={() => setCover(null)}>
{t('uploads.batch_cover_clear')}
</Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</div>
</Dialog>
);
}
@@ -0,0 +1,56 @@
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { mobileFixtures } from '../../test/mobileFixtures';
import { EditDialog } from './EditDialog';
import type { UploadFile } from './common';
afterEach(cleanup);
async function fixture(fail = false) {
const file = (mobileFixtures['/api/v1/admin/uploads'] as { items: UploadFile[] }).items[0]!;
const requests: unknown[] = [];
const fetchImpl: typeof fetch = async (input) => {
const req = input as Request;
if (req.method === 'PATCH') {
requests.push(await req.json());
return new Response(JSON.stringify(fail ? { status: 409, code: 'uploads.wrong_status' } : file), { status: fail ? 409 : 200 });
}
return new Response(JSON.stringify(mobileFixtures['/api/v1/admin/sources']));
};
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: fetchImpl, clientType: 'web' });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
const onSaved = vi.fn();
const onClose = vi.fn();
const i18n = await createI18n('ru');
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={queryClient}>
<EditDialog file={file} onSaved={onSaved} onClose={onClose} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
const user = userEvent.setup();
await user.type(screen.getByLabelText('Исполнитель', { exact: true }), 'Кино');
await user.type(screen.getByLabelText('Название', { exact: true }), 'Перемен');
return { requests, onSaved, onClose, user };
}
it('saves edited metadata with the target source and closes only after success', async () => {
const f = await fixture();
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(f.onSaved).toHaveBeenCalledOnce());
expect(f.onClose).toHaveBeenCalledOnce();
expect(f.requests).toEqual([expect.objectContaining({ metadata: expect.objectContaining({ artist: 'Кино', title: 'Перемен' }), target_source_id: expect.any(String) })]);
});
it('keeps the editor and input when a concurrent review rejects the save', async () => {
const f = await fixture(true);
await f.user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.getByRole('alert')).toBeVisible());
expect(screen.getByRole('dialog')).toBeVisible();
expect(screen.getByLabelText('Название', { exact: true })).toHaveValue('Перемен');
expect(f.onSaved).not.toHaveBeenCalled();
expect(f.onClose).not.toHaveBeenCalled();
});
@@ -0,0 +1,85 @@
import type { components } from '@pipu/api-client';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError } from '../../shared/ui/ui';
import { useUploadsApi, useUploadSources } from './queries';
import type { UploadFile } from './common';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
type Meta = components['schemas']['UploadMeta'];
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
export function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const errorText = useErrorText();
const fieldError = useFieldError();
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
const [source, setSource] = useState(file.target_source_id ?? '');
const sources = useUploadSources();
const save = useMutation({
mutationFn: () => api.updateFile(file.id, { metadata: m, ...(source ? { target_source_id: source } : {}) }),
onSuccess: () => {
onSaved();
onClose();
},
});
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
);
const num = (k: 'year' | 'track' | 'disc') => (
<Field
label={t(`uploads.meta_${k}`)}
value={m[k] === null ? '' : String(m[k])}
inputMode="numeric"
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
/>
);
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
return (
<Dialog
title={t('uploads.edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
<div className={u.grid}>
{text('artist')}
{text('albumartist')}
{text('album')}
{text('title')}
{text('genre')}
{num('year')}
{num('track')}
{num('disc')}
</div>
<label className={s.sessions}>
<span>{t('uploads.target_source')}</span>
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">{t('uploads.target_default')}</option>
{accepting.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<p className={s.note}>{t('uploads.tags_note')}</p>
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
</div>
</Dialog>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { editIcon, refreshIcon } from '../../shared/ui/icons';
import { needsFormatAttention } from './files';
import { UploadStatusBadge, useReasonText, type UploadFile } from './common';
import { useTranslation } from 'react-i18next';
import { Button, cx, Icon } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export function BatchCard({
files,
selectable,
selected,
onToggle,
onEdit,
onEditBatch,
onRetry,
}: {
files: UploadFile[];
selectable: boolean;
selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void;
onEditBatch: (batchId: string) => void;
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
<span style={{ flex: 1 }} />
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')}
</Button>
)}
</div>
{files.map((f) => (
<QueueRow
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
selectable,
checked,
onCheck,
onEdit,
onRetry,
}: {
file: UploadFile;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation('admin');
const reason = useReasonText()(f.status_reason);
const m = f.metadata;
const quality = [
f.format?.toUpperCase(),
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
f.bit_depth ? `${f.bit_depth} bit` : null,
f.duration_ms ? formatDuration(f.duration_ms) : null,
formatBytes(f.size_bytes, i18n.language),
]
.filter(Boolean)
.join(' · ');
const editable = f.status === 'pending_review' || f.status === 'import_failed';
return (
<div className={u.queueRow}>
{selectable ? (
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
) : (
<span />
)}
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
{m && (
<span className={u.sub}>
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
.filter(Boolean)
.join(' · ')}
{f.metadata_edited && ` · ${t('uploads.edited')}`}
</span>
)}
<span className={u.sub}>{quality}</span>
<span className={u.sub} title={f.client_rel_path ?? undefined}>
{f.client_rel_path ?? f.original_name}
</span>
</div>
<div className={u.meta}>
<div className={u.flags}>
<UploadStatusBadge status={f.status} />
{needsFormatAttention(f.format, f.original_name) && (
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
{t('uploads.flag_attention')}
</span>
)}
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
{f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : (
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</div>
<div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
)}
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} />
</Button>
)}
{f.status === 'import_failed' && (
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
{t('uploads.retry')}
</Button>
)}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dialog } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export function RejectDialog({
count,
busy,
onClose,
onReject,
}: {
count: number;
busy: boolean;
onClose: () => void;
onReject: (reason: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const [reason, setReason] = useState('');
return (
<Dialog
title={t('uploads.reject_title', { count })}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
{t('uploads.reject')}
</Button>
</>
}
>
<label className={s.sessions}>
<span>{t('uploads.reject_reason')}</span>
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
</label>
</Dialog>
);
}
@@ -0,0 +1,180 @@
import { ApiError } from '@pipu/core';
import type { components } from '@pipu/api-client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { checkIcon, closeIcon, inboxIcon } from '../../shared/ui/icons';
import { useErrorText } from '../../shared/lib/errors';
import { QuotaMeter, type UploadFile, type UploadStatus } from './common';
import { BatchCard } from './QueueRows';
import { BatchEditDialog } from './BatchEditDialog';
import { EditDialog } from './EditDialog';
import { RejectDialog } from './RejectDialog';
import { useUploadQueue, useUploadsApi } from './queries';
import { useTranslation } from 'react-i18next';
import { Button, EmptyState, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
const TABS = {
review: ['pending_review'],
failed: ['import_failed'],
progress: ['uploading', 'processing', 'approved', 'importing'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[]>;
type Tab = keyof typeof TABS;
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
export function UploadQueue() {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [tab, setTab] = useState<Tab>('review');
const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null);
const [batchEdit, setBatchEdit] = useState<string | null>(null);
const [rejecting, setRejecting] = useState(false);
const list = useUploadQueue(TABS[tab], tab, onlyPossible);
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
const refresh = async () => {
setSelected(new Set());
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
};
const review = useMutation({
mutationFn: (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) => api.review(v),
onSuccess: refresh,
});
const retry = useMutation({
mutationFn: api.retry,
onSuccess: refresh,
});
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
const batches = useMemo(() => {
const m = new Map<string, UploadFile[]>();
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
return [...m.values()];
}, [items]);
const toggle = (ids: string[], on: boolean) =>
setSelected((cur) => {
const next = new Set(cur);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
});
const selectable = tab === 'review' || tab === 'failed';
const ids = [...selected];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.queue_title')}</h1>
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(TABS) as Tab[]).map((k) => (
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
{t(`uploads.tab_${k}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
{t('uploads.only_possible')}
</label>
</div>
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
{batches.map((files) => (
<BatchCard
key={files[0]!.batch_id}
files={files}
selectable={selectable}
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)}
/>
))}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
{ids.length > 0 && (
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
<span>{t('uploads.selected', { count: ids.length })}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setSelected(new Set())}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
{t('uploads.reject')}
</Button>
{tab === 'review' && (
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
{t('uploads.approve')}
</Button>
)}
</div>
)}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && (
<EditDialog
file={editing}
onClose={() => setEditing(null)}
onSaved={() => {
review.reset();
void refresh();
}}
/>
)}
{rejecting && (
<RejectDialog
count={ids.length}
busy={review.isPending}
onClose={() => setRejecting(false)}
onReject={(reason) => {
setRejecting(false);
review.mutate({ decision: 'reject', ids, reason });
}}
/>
)}
</div>
);
}
/** Problem из ответа массового решения → объект, понятный useErrorText. */
function problemError(p: components['schemas']['Problem']) {
return new ApiError(p.status, p.code, p.params ?? {});
}
@@ -0,0 +1,101 @@
import type { components } from '@pipu/api-client';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Field, FormError } from '../../shared/ui/ui';
import { useUploadsApi, useUploadSources, useUploadSettings } from './queries';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
type Settings = components['schemas']['UploadSettings'];
const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
export function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']);
const api = useUploadsApi();
const queryClient = useQueryClient();
const errorText = useErrorText();
const settings = useUploadSettings();
const sources = useUploadSources();
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
const save = useMutation({
mutationFn: async () => {
const f = form!;
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
return api.updateSettings({
queue_limit_bytes: gb(f.queue), max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null, allow_quality_upgrade: f.upgrade,
});
},
onSuccess: async (data) => {
queryClient.setQueryData(['upload-settings'], data);
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
if (!form) return null;
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<div className={u.settingsGrid}>
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
<Field
label={t('uploads.set_per_user')}
value={form.perUser}
hint={t('uploads.set_per_user_hint')}
onChange={(v) => setForm({ ...form, perUser: v })}
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<label>
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
<option value="">{t('uploads.set_source_auto')}</option>
{sources.data
?.filter((x) => x.accepts_uploads)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<div>
<label className={u.settingsCheck}>
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
<span>{t('uploads.set_upgrade')}</span>
</label>
</div>
</div>
<p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</div>
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</section>
);
}
function toForm(st: Settings) {
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
return {
queue: gb(st.queue_limit_bytes),
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '',
upgrade: st.allow_quality_upgrade,
};
}
+26
View File
@@ -0,0 +1,26 @@
import type { ApiClient, components, paths } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
type FilePatch = paths['/api/v1/admin/uploads/files/{id}']['patch']['requestBody']['content']['application/json'];
type BatchPatch = paths['/api/v1/admin/uploads/batches/{id}']['patch']['requestBody']['content']['application/json'];
type SettingsPatch = paths['/api/v1/admin/settings/uploads']['patch']['requestBody']['content']['application/json'];
/** The feature owns transport mapping; views only provide form values. */
export function uploadsApi(client: ApiClient) {
return {
list: async (status: components['schemas']['UploadStatus'][], possible: boolean, cursor?: string) => unwrap(
await client.GET('/api/v1/admin/uploads', {
params: { query: { status, possible_duplicate: possible || undefined, cursor, limit: 100 } },
}),
),
review: async (v: { ids: string[]; decision: 'approve' | 'reject'; reason?: string }) => unwrap(
await client.POST('/api/v1/admin/uploads/review', { body: { file_ids: v.ids, decision: v.decision, reason: v.reason } }),
),
retry: async (id: string) => unwrap(await client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
updateFile: async (id: string, body: FilePatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/files/{id}', { params: { path: { id } }, body })),
updateBatch: async (id: string, body: BatchPatch) => unwrap(await client.PATCH('/api/v1/admin/uploads/batches/{id}', { params: { path: { id } }, body })),
sources: async () => unwrap(await client.GET('/api/v1/admin/sources')).items,
settings: async () => unwrap(await client.GET('/api/v1/admin/settings/uploads')),
updateSettings: async (body: SettingsPatch) => unwrap(await client.PATCH('/api/v1/admin/settings/uploads', { body })),
};
}
+84
View File
@@ -0,0 +1,84 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { formatBytes } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import u from '../../pages/uploads/uploads.module.css';
export type UploadStatus = components['schemas']['UploadStatus'];
export type UploadFile = components['schemas']['UploadFile'];
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
uploading: 'info',
processing: 'info',
pending_review: 'warn',
approved: 'info',
importing: 'info',
imported: 'ok',
import_failed: 'bad',
duplicate: 'muted',
invalid: 'bad',
rejected: 'bad',
withdrawn: 'muted',
expired: 'muted',
};
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
}
export function useUploadQuota() {
const session = useSession();
return useQuery({
queryKey: ['upload-quota'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
});
}
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
export function QuotaMeter() {
const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null;
const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language);
return (
<div className={u.quota}>
<span className={u.quotaText} id={id}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span>
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
export function useReasonText(): (reason: string | null) => string | null {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
return (reason) => {
if (!reason) return null;
const code = reason.split(':')[0]!.trim();
const key = `errors:${code}`;
if (i18n.exists(key)) {
const rest = reason.slice(code.length + 1).trim();
return rest ? `${tr(key)}: ${rest}` : tr(key);
}
return reason;
};
}
+27
View File
@@ -0,0 +1,27 @@
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { useSession } from '../../app/session';
import { uploadsApi } from './api';
import { MOVING, type UploadStatus } from './common';
export function useUploadsApi() { return uploadsApi(useSession().client); }
export function useUploadQueue(status: UploadStatus[], tab: string, onlyPossible: boolean) {
const api = useUploadsApi();
return useInfiniteQuery({
queryKey: ['admin-uploads', tab, onlyPossible],
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam }) => api.list(status, onlyPossible, pageParam),
getNextPageParam: (p) => p.next_cursor ?? undefined,
refetchInterval: (q) => q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
}
export function useUploadSources() {
const api = useUploadsApi();
return useQuery({ queryKey: ['sources'], queryFn: api.sources });
}
export function useUploadSettings() {
const api = useUploadsApi();
return useQuery({ queryKey: ['upload-settings'], queryFn: api.settings });
}
+2 -1
View File
@@ -9,7 +9,7 @@ import { I18nextProvider } from 'react-i18next';
import { App } from './App';
import { createAppRouter } from './app/router';
import { applyTheme } from './app/session';
import { createDesktop, DesktopProvider } from './desktop';
import { createDesktop, DesktopProvider, desktopFetch } from './desktop';
const LOCALE_KEY = 'pipu.locale';
@@ -70,6 +70,7 @@ const session = new AuthSession({
deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
baseUrl: desktop?.proxyBase,
refreshStore: desktop?.refreshStore,
fetch: desktop ? desktopFetch : undefined,
});
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
await session.restore();
+3 -6
View File
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
rainy: waveRainyIcon,
};
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string;
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
@@ -52,7 +50,8 @@ export function HomePage() {
const onboarding = useQuery({
queryKey: ['onboarding'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: Infinity,
staleTime: 0,
refetchInterval: 30_000,
});
if (onboarding.isPending) {
return (
@@ -96,8 +95,6 @@ function WaveHome() {
const waveOn = st.wave !== null;
const playing = waveOn && st.playing;
const track = waveOn ? st.track : null;
const colors: [string, string, string] =
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
const run = (fn: () => Promise<void> | void) => {
setError(null);
@@ -128,7 +125,7 @@ function WaveHome() {
readAudio={player.readAudio}
bpm={track?.features?.bpm ?? undefined}
energy={track?.features?.energy ?? undefined}
colors={colors}
colors={track?.album.cover_colors}
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
size={320}
label={t(playing ? 'wave.pause' : 'wave.start')}
+27 -10
View File
@@ -1,5 +1,5 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -20,17 +20,19 @@ const MIN_ARTISTS = 3;
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
*/
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
export function Onboarding({ options, onDone, editing = false }: { options: Options; onDone: () => void; editing?: boolean }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set());
const [genres, setGenres] = useState<Set<string>>(new Set());
const [artists, setArtists] = useState<Set<string>>(new Set(options.choice?.artist_ids));
const [genres, setGenres] = useState<Set<string>>(new Set(options.choice?.genres));
const [gender, setGender] = useState(options.choice?.gender ?? null);
const [ageGroup, setAgeGroup] = useState(options.choice?.age_group ?? null);
const save = useMutation({
mutationFn: async (skip: boolean) =>
unwrap(
await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
body: skip ? { artist_ids: [], genres: [], gender: null, age_group: null } : { artist_ids: [...artists], genres: [...genres], gender, age_group: ageGroup },
}),
),
onSuccess: onDone,
@@ -43,7 +45,7 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
else next.add(key);
return next;
});
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
const enough = editing || artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0) || !!gender || !!ageGroup || options.artists.length === 0;
return (
<div className={s.page}>
@@ -52,6 +54,21 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
<p>{t('onboarding.subtitle')}</p>
</header>
<section aria-label={t('onboarding.gender')}>
<h2 className={s.label}>{t('onboarding.gender')}</h2>
<div className={s.genres}>
<button type="button" className={s.genre} aria-pressed={!gender} onClick={() => setGender(null)}>{t('onboarding.unspecified')}</button>
{GENDERS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={gender === g} onClick={() => setGender(g)}>{t(`onboarding.genders.${g}`)}</button>)}
</div>
</section>
<section aria-label={t('onboarding.age_group')}>
<h2 className={s.label}>{t('onboarding.age_group')}</h2>
<p>{t('onboarding.age_hint')}</p>
<div className={s.genres}>
<button type="button" className={s.genre} aria-pressed={!ageGroup} onClick={() => setAgeGroup(null)}>{t('onboarding.unspecified')}</button>
{AGE_GROUPS.map((g) => <button type="button" key={g} className={s.genre} aria-pressed={ageGroup === g} onClick={() => setAgeGroup(g)}>{t(`onboarding.ages.${g}`)}</button>)}
</div>
</section>
<section aria-label={t('onboarding.artists')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}>
@@ -101,11 +118,11 @@ export function Onboarding({ options, onDone }: { options: Options; onDone: () =
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
{t('onboarding.skip')}
<Button variant="ghost" disabled={save.isPending} busy={save.isPending && save.variables} onClick={() => editing ? onDone() : save.mutate(true)}>
{t(editing ? 'form.cancel' : 'onboarding.skip')}
</Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough || save.isPending} onClick={() => save.mutate(false)}>
{enough ? t(editing ? 'settings.save' : 'onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button>
</div>
</div>
+32 -9
View File
@@ -9,9 +9,10 @@ import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist
import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { sharePlaylist } from '../shared/lib/share';
import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
import c from './catalog.module.css';
import s from './page.module.css';
import p from './playlists.module.css';
@@ -42,19 +43,19 @@ export function PlaylistsPage() {
</div>;
}
export function PlaylistPage({ id }: { id: string }) {
export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) {
const { t } = useTranslation();
const query = usePlaylist(id);
const query = usePlaylist(id, shared);
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 && <PageSkeleton />}
{query.data && <PlaylistDetail key={id} playlist={query.data} />}
{query.data && !query.isError && <PlaylistDetail key={id} playlist={query.data} readOnly={shared} shareId={shared ? id : undefined} />}
</div>;
}
function PlaylistDetail({ playlist }: { playlist: Playlist }) {
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
@@ -64,6 +65,21 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false);
const [copied, setCopied] = useState(false);
const sharing = useMutation({ mutationFn: async () => {
setCopied(false);
let token = shareId ?? playlist.share_id;
if (!readOnly) {
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
}
if (!token) throw new Error('Share link unavailable');
const result = await sharePlaylist(token, playlist.name);
if (result === 'copied') setCopied(true);
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
onSuccess: () => { setCopied(false); cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
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 },
@@ -84,7 +100,7 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
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>
<div className={c.heroText}><span className={c.kicker}>{t(readOnly || playlist.share_id ? 'playlists.shared' : '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>
@@ -92,10 +108,17 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<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 icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
{!readOnly && <>
<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>
{playlist.share_id && <Button variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onClick={() => revoke.mutate()}>{t('playlists.revoke_share')}</Button>}
</>}
</div>
{!readOnly && <p>{t('playlists.share_hint')}</p>}
{copied && <p role="status">{t('playlists.link_copied')}</p>}
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && (
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
@@ -103,13 +126,13 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
</EmptyState>
)}
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
actions={manage ? (track, i) => <span className={p.actions}>
actions={!readOnly && 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 && (
{!readOnly && edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
{!readOnly && remove && (
<Dialog
title={t('playlists.delete')}
onClose={() => {
+19
View File
@@ -12,6 +12,8 @@ import { formatDateTime } from '../shared/lib/format';
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
import { Onboarding } from './Onboarding';
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
@@ -24,7 +26,10 @@ export function SettingsPage() {
<h1>{t('settings.title')}</h1>
</div>
<ProfileSection />
<RecommendationSettings />
<AppearanceSection />
<DesktopOfflineSection />
<DesktopUpdateButton />
<PasswordSection />
<SessionsSection />
</div>
@@ -357,3 +362,17 @@ function SessionsSection() {
</section>
);
}
function RecommendationSettings() {
const { t } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
return <section className={s.section}>
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
<FormError>{options.error ? errorText(options.error) : null}</FormError>
</section>;
}
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import s from '../page.module.css';
import { UploadSettingsSection } from './UploadQueuePage';
import { UploadSettingsSection } from '../../features/uploads/UploadSettingsSection';
/** «Настройки» админки: параметры сервера, которые меняет администратор (пока — очередь на добавление). */
export function AdminSettingsPage() {
@@ -0,0 +1,59 @@
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
afterEach(cleanup);
async function fixture(mode: 'pages' | 'empty' | 'error' = 'pages') {
const cursors: (string | null)[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
const req = new Request(input, init);
const cursor = new URL(req.url).searchParams.get('cursor');
cursors.push(cursor);
if (mode === 'error') return new Response(JSON.stringify({ code: 'server.error' }), { status: 500 });
return new Response(JSON.stringify({
items: mode === 'empty' ? [] : cursor ? [{ track_id: 'second', title: 'Без файла', source_id: null, source_name: null, rel_path: null, error: '' }]
: [{ track_id: 'first', title: 'Короткий трек', source_id: 'source', source_name: 'Base', rel_path: 'Исполнитель/Альбом/track.mp3', error: 'audio is shorter than 1 s' }],
next_cursor: mode === 'pages' && !cursor ? 'next-page' : null,
}), { headers: { 'Content-Type': 'application/json' } });
} });
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const i18n = await createI18n('ru');
const close = vi.fn();
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<AnalysisErrorsDialog onClose={close} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { close, cursors, user: userEvent.setup() };
}
it('shows storage, path and error, loads the next page and closes with Escape', async () => {
const { user, cursors, close } = await fixture();
expect(screen.getByRole('dialog', { name: 'Ошибки аудиоанализа' })).toBeVisible();
expect(await screen.findByText('Исполнитель/Альбом/track.mp3')).toBeVisible();
expect(screen.getByText('Хранилище: Base')).toBeVisible();
expect(screen.getByText('audio is shorter than 1 s')).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Показать ещё' }));
expect(await screen.findByText('В библиотеке больше нет файла этого трека.')).toBeVisible();
expect(screen.getByText('Короткий трек')).toBeVisible();
expect(cursors).toEqual([null, 'next-page']);
expect(screen.queryByRole('button', { name: 'Показать ещё' })).not.toBeInTheDocument();
await user.keyboard('{Escape}');
expect(close).toHaveBeenCalledOnce();
});
it('shows an empty state after failed jobs have been retried', async () => {
await fixture('empty');
expect(await screen.findByText('Неудавшихся задач анализа нет.')).toBeVisible();
});
it('shows a request failure without claiming the list is empty', async () => {
await fixture('error');
expect(await screen.findByRole('alert')).toBeVisible();
expect(screen.queryByText('Неудавшихся задач анализа нет.')).not.toBeInTheDocument();
});
@@ -0,0 +1,40 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import s from './library.module.css';
export function AnalysisErrorsDialog({ onClose }: { onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const errors = useInfiniteQuery({
queryKey: ['analysis-errors'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) => unwrap(await session.client.GET('/api/v1/admin/library/analysis/errors', {
params: { query: { limit: 50, cursor: pageParam } },
})),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const items = errors.data?.pages.flatMap((page) => page.items) ?? [];
return <Dialog title={t('library.analysis_errors_title')} onClose={onClose}
footer={<Button onClick={onClose}>{t('common:form.close')}</Button>}>
<p>{t('library.analysis_errors_hint')}</p>
{errors.isPending && <p role="status">{t('common:loading')}</p>}
<FormError>{errors.error ? errorText(errors.error) : null}</FormError>
{errors.isSuccess && items.length === 0 && <p>{t('library.analysis_errors_empty')}</p>}
{items.length > 0 && <ul className={s.analysisErrors}>
{items.map((item) => <li key={item.track_id}>
<strong>{item.title}</strong>
{item.source_name && <span className={s.scanSummary}>{t('library.analysis_error_source', { name: item.source_name })}</span>}
<code className={s.analysisPath}>{item.rel_path ?? t('library.analysis_error_no_file')}</code>
<span className={s.scanError}>{item.error || t('library.analysis_error_unknown')}</span>
</li>)}
</ul>}
{errors.hasNextPage && <Button busy={errors.isFetchingNextPage} onClick={() => void errors.fetchNextPage()}>{t('library.analysis_errors_more')}</Button>}
</Dialog>;
}
@@ -0,0 +1,67 @@
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it } from 'vitest';
import { SessionProvider } from '../../app/session';
import { GeniusPanel } from './GeniusPanel';
afterEach(cleanup);
const initial = {
configured: true, paused: false, running: true,
total: 100, pending: 80, matched: 17, lyrics: 15, annotations: 5, images: 17,
unmatched: 2, errors: 1, last_error: '', cooldown_until: '2000-01-01T00:00:00Z',
};
async function fixture(overrides: Partial<typeof initial> = {}) {
let data = { ...initial, ...overrides };
const actions: string[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (url, init) => {
const request = new Request(url, init);
if (request.method === 'POST') {
const { action } = await request.json() as { action: string };
actions.push(action);
data = { ...data, paused: action === 'pause' };
}
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
} });
const client = new QueryClient({ defaultOptions: { queries: { retry: false, staleTime: Infinity } } });
client.setQueryData(['genius-stats'], data);
const i18n = await createI18n('ru');
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<GeniusPanel />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { actions, user: userEvent.setup() };
}
it.each<[Partial<typeof initial>, string]>([
[{}, 'Получение данных из Genius…'],
[{ running: false }, 'Очередь ожидает обработки'],
[{ paused: true }, 'Обработка приостановлена'],
[{ cooldown_until: '2099-01-01T00:00:00Z' }, 'Ожидание после ограничения Genius'],
[{ running: false, pending: 0 }, 'Ожидание новых треков'],
])('shows queue state and progress for %j', async (overrides, status) => {
await fixture(overrides);
expect(screen.getByRole('status')).toHaveTextContent(status);
const progress = screen.getByRole('progressbar', { name: 'Прогресс обработки Genius' });
expect(progress).toHaveAttribute('max', '100');
expect(progress).toHaveAttribute('value', String(100 - (overrides.pending ?? 80)));
});
it('pauses and resumes the existing queue without requesting a retry', async () => {
const { user, actions } = await fixture();
await user.click(screen.getByRole('button', { name: 'Приостановить обработку' }));
await user.click(await screen.findByRole('button', { name: 'Продолжить обработку' }));
expect(actions).toEqual(['pause', 'start']);
expect(screen.getByRole('progressbar')).toHaveAttribute('value', '20');
});
it('explains the retry scope and sends the retry action', async () => {
const { user, actions } = await fixture();
expect(screen.getByText(/Повтор добавит в очередь только/)).toBeVisible();
await user.click(screen.getByRole('button', { name: 'Добавить проблемные треки в очередь' }));
expect(actions).toEqual(['retry']);
});
+12 -3
View File
@@ -8,7 +8,7 @@ import s from '../page.module.css';
import g from '../genius.module.css';
export function GeniusPanel() {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const session = useSession();
const queries = useQueryClient();
const errorText = useErrorText();
@@ -24,16 +24,23 @@ export function GeniusPanel() {
});
const data = stats.data;
const cooling = data && Date.parse(data.cooldown_until) > Date.now();
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : t('genius.waiting');
const state = !data ? t('genius.loading') : !data.configured ? t('genius.disabled') : data.paused ? t('genius.paused') : cooling ? t('genius.cooldown') : data.running ? t('genius.running') : data.pending > 0 ? t('genius.queued') : t('genius.waiting');
const processed = data ? Math.max(0, data.total - data.pending) : 0;
const formatNumber = (value: number) => value.toLocaleString(i18n.language);
return <section className={s.section} aria-label="Genius">
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
<p className={s.subtitle}>{t('genius.description')}</p>
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
{data && <>
{!data.configured && <p>{t('genius.configure')}</p>}
{data.total > 0 && <div className={g.progress}>
<span>{t('genius.progress', { processed: formatNumber(processed), total: formatNumber(data.total) })}</span>
<progress aria-label={t('genius.progress_label')} value={processed} max={data.total} />
<p className={s.subtitle}>{t('genius.progress_hint')}</p>
</div>}
<dl className={g.stats}>
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) =>
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{data[key].toLocaleString()}</dd></div>)}
<div key={key}><dt>{t(`genius.${key}`)}</dt><dd>{formatNumber(data[key])}</dd></div>)}
</dl>
{data.last_error && <p role="alert">{t('genius.last_error')}: {data.last_error === 'http_401' ? t('genius.token_error') : data.last_error === 'http_403' ? t('genius.access_error') : data.last_error === 'http_429' ? t('genius.cooldown') : data.last_error === 'lyrics_unavailable' ? t('genius.no_lyrics') : t('genius.request_error')}</p>}
{cooling && <p>{t('genius.resume_at')}: {new Date(data.cooldown_until).toLocaleString()}</p>}
@@ -41,6 +48,8 @@ export function GeniusPanel() {
<Button disabled={!data.configured || control.isPending} onClick={() => control.mutate(data.paused ? 'start' : 'pause')}>{t(data.paused ? 'genius.start' : 'genius.pause')}</Button>
<Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
</div>
<p className={s.subtitle}>{t('genius.controls_hint')}</p>
<p className={s.subtitle}>{t('genius.retry_hint')}</p>
</>}
</section>;
}
+12 -2
View File
@@ -1,6 +1,6 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
@@ -10,6 +10,7 @@ import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
export function LibraryPage() {
@@ -18,6 +19,7 @@ export function LibraryPage() {
const user = useUser();
const errorText = useErrorText();
const admin = user.role === 'admin';
const [showAnalysisErrors, setShowAnalysisErrors] = useState(false);
const status = useQuery({
queryKey: ['library-status'],
@@ -27,6 +29,7 @@ export function LibraryPage() {
const errors = useQuery({
queryKey: ['library-errors'],
enabled: admin,
refetchInterval: status.data?.sources.some((source) => source.scan?.running) ? 2000 : 15000,
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
});
const reindex = useMutation({
@@ -35,7 +38,10 @@ export function LibraryPage() {
const queryClient = useQueryClient();
const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['library-status'] });
void queryClient.invalidateQueries({ queryKey: ['analysis-errors'] });
},
});
const d = status.data;
const tiles = d
@@ -89,13 +95,17 @@ export function LibraryPage() {
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span>
{admin && (
<div className={l.analysisActions}>
<Button onClick={() => setShowAnalysisErrors(true)}>{t('library.analysis_show_errors')}</Button>
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
</div>
)}
</div>
)}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
{showAnalysisErrors && <AnalysisErrorsDialog onClose={() => setShowAnalysisErrors(false)} />}
</section>
)}
{d && (
@@ -0,0 +1,42 @@
import type { components } from '@pipu/api-client';
import { createI18n } from '@pipu/i18n';
import { cleanup, render, screen } from '@testing-library/react';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it } from 'vitest';
import { ScanLine } from './ScanStatus';
afterEach(cleanup);
const scan: components['schemas']['ScanProgress'] = {
running: true, full: false, started_at: '2026-10-22T12:00:00Z', finished_at: null,
found: 12, to_process: 0, processed: 0, added: 0, updated: 0, moved: 0, missing: 0, errors: 0, error: null,
phase: 'walking', walked_dirs: 4, current_dir: 'Artist/Album',
};
async function fixture(overrides: Partial<typeof scan> = {}) {
const i18n = await createI18n('ru');
render(<I18nextProvider i18n={i18n}><ScanLine scan={{ ...scan, ...overrides }} lastScanAt={null} /></I18nextProvider>);
}
it('shows changing folder and file counts during the walk without a misleading percentage', async () => {
await fixture();
expect(screen.getByText(/Проверено папок: 4 · найдено аудиофайлов: 12/)).toBeVisible();
expect(screen.getByText('Текущая папка: Artist/Album')).toBeVisible();
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
});
it('shows a percentage only once file processing has a known total', async () => {
await fixture({ phase: 'processing', to_process: 12, processed: 3 });
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '25');
expect(screen.queryByText(/Текущая папка/)).not.toBeInTheDocument();
});
it.each([['preparing', 'Сопоставление файлов с библиотекой…'], ['finalizing', 'Завершение сканирования…']] as const)('labels the %s stage', async (phase, label) => {
await fixture({ phase });
expect(screen.getByText(label)).toBeVisible();
expect(screen.getByRole('progressbar')).not.toHaveAttribute('aria-valuenow');
});
it('supports servers without the new progress fields', async () => {
await fixture({ phase: undefined, walked_dirs: undefined, current_dir: undefined, found: 0 });
expect(screen.getByText('Обход папок…')).toBeVisible();
});
+11 -3
View File
@@ -19,14 +19,22 @@ export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; last
const { t, i18n } = useTranslation('admin');
const id = useId();
if (scan?.running) {
const phase = scan.phase ?? (scan.found === 0 ? 'walking' : scan.to_process > 0 ? 'processing' : 'preparing');
const determinate = phase === 'processing' && scan.to_process > 0;
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
const state = phase === 'walking'
? scan.walked_dirs === undefined ? t('sources.scan_walking') : t('sources.scan_walk_progress', { dirs: scan.walked_dirs.toLocaleString(i18n.language), found: scan.found.toLocaleString(i18n.language) })
: phase === 'preparing' ? t('sources.scan_preparing')
: phase === 'finalizing' ? t('sources.scan_finalizing')
: t('sources.scanning', { processed: scan.processed, total: scan.to_process });
return (
<div className={s.scan}>
<div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
{state}
</div>
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} />
{phase === 'walking' && scan.current_dir && <div className={s.scanPath} title={scan.current_dir}>{t('sources.scan_current_dir', { path: scan.current_dir })}</div>}
<div className={cx(s.bar, !determinate && s.indeterminate)} role="progressbar" aria-labelledby={id} aria-valuenow={determinate ? pct : undefined} aria-valuemin={0} aria-valuemax={100}>
<span style={determinate ? { width: `${pct}%` } : undefined} />
</div>
</div>
);
@@ -0,0 +1,63 @@
import type { components } from '@pipu/api-client';
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, expect, it, vi } from 'vitest';
import { SessionProvider } from '../../app/session';
import { mobileFixtures } from '../../test/mobileFixtures';
import { SourceDialog } from './SourcesPage';
afterEach(cleanup);
type Source = components['schemas']['Source'];
const base = (mobileFixtures['/api/v1/admin/sources'] as { items: Source[] }).items[0]!;
async function fixture(source: Source | null = { ...base, excluded_dirs: ['Downloads_PumpEars'] }, reject = false) {
const bodies: Record<string, unknown>[] = [];
const session = new AuthSession({ baseUrl: 'http://localhost', clientType: 'web', fetch: async (input, init) => {
const req = new Request(input, init);
if (req.method === 'PATCH' || req.method === 'POST') {
const body = await req.json() as Record<string, unknown>;
bodies.push(body);
return new Response(JSON.stringify(reject
? { code: 'validation.excluded_dirs_invalid', errors: [{ field: 'excluded_dirs', code: 'validation.excluded_dirs_invalid' }] }
: { ...base, ...body }), { status: reject ? 422 : 200, headers: { 'Content-Type': 'application/json' } });
}
return new Response(JSON.stringify({ path: '', dirs: [] }), { headers: { 'Content-Type': 'application/json' } });
} });
const i18n = await createI18n('ru');
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const close = vi.fn();
render(<I18nextProvider i18n={i18n}><SessionProvider session={session}><QueryClientProvider client={client}>
<SourceDialog source={source} onClose={close} />
</QueryClientProvider></SessionProvider></I18nextProvider>);
return { bodies, close, user: userEvent.setup() };
}
it('loads and saves one excluded path per line', async () => {
const { bodies, close, user } = await fixture();
const input = screen.getByLabelText('Не сканировать папки');
expect(input).toHaveValue('Downloads_PumpEars');
await user.type(input, '\nArtist/Samples\n');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(bodies[0]?.excluded_dirs).toEqual(['Downloads_PumpEars', 'Artist/Samples']);
});
it('clears the list explicitly so excluded folders can be scanned again', async () => {
const { bodies, close, user } = await fixture();
await user.clear(screen.getByLabelText('Не сканировать папки'));
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(close).toHaveBeenCalledOnce());
expect(bodies[0]?.excluded_dirs).toEqual([]);
});
it('keeps invalid paths in the editor and displays the server validation', async () => {
const { user, close } = await fixture({ ...base, excluded_dirs: ['../outside'] }, true);
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await waitFor(() => expect(screen.getByLabelText('Не сканировать папки')).toHaveAttribute('aria-invalid', 'true'));
expect(screen.getByLabelText('Не сканировать папки')).toHaveValue('../outside');
expect(close).not.toHaveBeenCalled();
});
+22 -3
View File
@@ -2,7 +2,7 @@ import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { useId, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
@@ -120,6 +120,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
</div>
<span className={l.location}>{location(source)}</span>
{!!source.excluded_dirs?.length && <span className={s.note}>{t('sources.excluded_count', { count: source.excluded_dirs.length })}</span>}
{source.status === 'error' && source.status_message && (
<span className={l.message}>{source.status_message}</span>
)}
@@ -185,7 +186,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
const INTERVALS = [0, 60, 360, 1440];
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
export function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
@@ -199,12 +200,15 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n'));
const excludedDirsId = useId();
const save = useMutation({
mutationFn: async () => {
const common = {
name: name.trim(),
tag_priority: priority,
excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean),
...(type === 'local'
? { local_path: localPath }
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
@@ -224,11 +228,14 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['sources'] });
for (const key of ['library-status', 'library-errors', 'analysis-errors']) {
void queryClient.invalidateQueries({ queryKey: [key] });
}
onClose();
},
});
const err = save.error;
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options'];
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options', 'excluded_dirs'];
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
const submit = (e: FormEvent) => {
@@ -297,6 +304,18 @@ function SourceDialog({ source, onClose }: { source: Source | null; onClose: ()
/>
</>
)}
<div className={l.field}>
<label htmlFor={excludedDirsId} className={l.label}>{t('sources.excluded_dirs')}</label>
<textarea id={excludedDirsId} className={l.excludedDirs} rows={5}
value={excludedDirs} onChange={(event) => setExcludedDirs(event.target.value)}
disabled={source?.scan?.running ?? false}
placeholder="Downloads_PumpEars"
aria-invalid={!!fieldError(err, 'excluded_dirs')}
aria-describedby={`${excludedDirsId}-hint`} />
<span id={`${excludedDirsId}-hint`} className={s.note}>
{fieldError(err, 'excluded_dirs') ?? t(source?.scan?.running ? 'sources.excluded_running' : 'sources.excluded_hint')}
</span>
</div>
<div className={l.field}>
<span className={l.label}>{t('sources.tag_priority')}</span>
<Segmented
+2 -639
View File
@@ -1,640 +1,3 @@
import type { components } from '@pipu/api-client';
import { ApiError, unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { UploadQueue } from '../../features/uploads/UploadQueue';
import { useSession } from '../../app/session';
import { needsFormatAttention } from '../../features/uploads/files';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatBytes, formatDateTime, formatDuration } from '../../shared/lib/format';
import { checkIcon, closeIcon, editIcon, inboxIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, cx, Dialog, EmptyState, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import { MOVING, QuotaMeter, UploadStatusBadge, useReasonText, type UploadFile, type UploadStatus } from '../uploads/common';
import u from '../uploads/uploads.module.css';
type Meta = components['schemas']['UploadMeta'];
type Settings = components['schemas']['UploadSettings'];
const TABS = {
review: ['pending_review'],
failed: ['import_failed'],
progress: ['uploading', 'processing', 'approved', 'importing'],
imported: ['imported'],
declined: ['rejected', 'duplicate', 'invalid', 'withdrawn', 'expired'],
} satisfies Record<string, UploadStatus[]>;
type Tab = keyof typeof TABS;
/** «Очередь на добавление» (docs/uploads.md#8): рассмотрение по пачкам, правка метаданных, массовые решения. */
export function UploadQueuePage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [tab, setTab] = useState<Tab>('review');
const [onlyPossible, setOnlyPossible] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [editing, setEditing] = useState<UploadFile | null>(null);
const [batchEdit, setBatchEdit] = useState<string | null>(null);
const [rejecting, setRejecting] = useState(false);
const list = useInfiniteQuery({
queryKey: ['admin-uploads', tab, onlyPossible],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/admin/uploads', {
params: {
query: { status: [...TABS[tab]], possible_duplicate: onlyPossible || undefined, cursor: pageParam, limit: 100 },
},
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
refetchInterval: (q) =>
q.state.data?.pages.some((p) => p.items.some((it) => MOVING.includes(it.status))) ? 5000 : false,
});
const items = useMemo(() => list.data?.pages.flatMap((p) => p.items) ?? [], [list.data]);
useEffect(() => setSelected(new Set()), [tab, onlyPossible]);
const refresh = async () => {
setSelected(new Set());
await queryClient.invalidateQueries({ queryKey: ['admin-uploads'] });
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
};
const review = useMutation({
mutationFn: async (v: { decision: 'approve' | 'reject'; ids: string[]; reason?: string }) =>
unwrap(
await session.client.POST('/api/v1/admin/uploads/review', {
body: { file_ids: v.ids, decision: v.decision, reason: v.reason },
}),
),
onSuccess: refresh,
});
const retry = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.POST('/api/v1/admin/uploads/files/{id}/retry', { params: { path: { id } } })),
onSuccess: refresh,
});
const failed = review.data?.items.filter((r) => !r.ok) ?? [];
// Группировка по пачке: файлы одной загрузки (обычно альбом) рассматриваются вместе.
const batches = useMemo(() => {
const m = new Map<string, UploadFile[]>();
for (const f of items) m.set(f.batch_id, [...(m.get(f.batch_id) ?? []), f]);
return [...m.values()];
}, [items]);
const toggle = (ids: string[], on: boolean) =>
setSelected((cur) => {
const next = new Set(cur);
for (const id of ids) {
if (on) next.add(id);
else next.delete(id);
}
return next;
});
const selectable = tab === 'review' || tab === 'failed';
const ids = [...selected];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('uploads.queue_title')}</h1>
<p className={s.subtitle}>{t('uploads.queue_subtitle')}</p>
</div>
<QuotaMeter />
</div>
<div className={s.toolbar}>
<div className={s.chips} role="group" aria-label={t('uploads.filter')}>
{(Object.keys(TABS) as Tab[]).map((k) => (
<button key={k} type="button" className={s.chip} aria-pressed={tab === k} onClick={() => setTab(k)}>
{t(`uploads.tab_${k}`)}
</button>
))}
</div>
<span style={{ flex: 1 }} />
<label className={u.sub} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="checkbox" checked={onlyPossible} onChange={(e) => setOnlyPossible(e.target.checked)} />
{t('uploads.only_possible')}
</label>
</div>
<FormError>{(list.error ?? review.error ?? retry.error) ? errorText(list.error ?? review.error ?? retry.error) : null}</FormError>
{failed.length > 0 && (
<FormError>{t('uploads.review_failed', { count: failed.length, reason: failed[0]?.error ? errorText(problemError(failed[0].error)) : '' })}</FormError>
)}
{list.isSuccess && items.length === 0 && <EmptyState icon={inboxIcon}>{t('uploads.queue_empty')}</EmptyState>}
{batches.map((files) => (
<BatchCard
key={files[0]!.batch_id}
files={files}
selectable={selectable}
selected={selected}
onToggle={toggle}
onEdit={setEditing}
onEditBatch={setBatchEdit}
onRetry={(id) => retry.mutate(id)}
/>
))}
{list.hasNextPage && (
<Button busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('uploads.load_more')}
</Button>
)}
{ids.length > 0 && (
<div className={u.bulk} role="region" aria-label={t('uploads.bulk')}>
<span>{t('uploads.selected', { count: ids.length })}</span>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setSelected(new Set())}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" icon={closeIcon} onClick={() => setRejecting(true)}>
{t('uploads.reject')}
</Button>
{tab === 'review' && (
<Button variant="primary" icon={checkIcon} busy={review.isPending} onClick={() => review.mutate({ decision: 'approve', ids })}>
{t('uploads.approve')}
</Button>
)}
</div>
)}
{batchEdit && (
<BatchEditDialog
batchId={batchEdit}
onClose={() => setBatchEdit(null)}
onSaved={() => {
setBatchEdit(null);
void refresh();
}}
/>
)}
{editing && (
<EditDialog
file={editing}
onClose={() => setEditing(null)}
onSaved={() => {
review.reset();
void refresh();
}}
/>
)}
{rejecting && (
<RejectDialog
count={ids.length}
busy={review.isPending}
onClose={() => setRejecting(false)}
onReject={(reason) => {
setRejecting(false);
review.mutate({ decision: 'reject', ids, reason });
}}
/>
)}
</div>
);
}
/** Problem из ответа массового решения → объект, понятный useErrorText. */
function problemError(p: components['schemas']['Problem']) {
return new ApiError(p.status, p.code, p.params ?? {});
}
function BatchCard({
files,
selectable,
selected,
onToggle,
onEdit,
onEditBatch,
onRetry,
}: {
files: UploadFile[];
selectable: boolean;
selected: Set<string>;
onToggle: (ids: string[], on: boolean) => void;
onEdit: (f: UploadFile) => void;
onEditBatch: (batchId: string) => void;
onRetry: (id: string) => void;
}) {
const { t, i18n } = useTranslation('admin');
const first = files[0]!;
const ids = files.map((f) => f.id);
const all = ids.every((id) => selected.has(id));
const size = files.reduce((n, f) => n + f.size_bytes, 0);
return (
<section className={u.batch} aria-label={t('uploads.batch_of', { author: first.author.username })}>
<div className={u.batchHead}>
{selectable && (
<input type="checkbox" aria-label={t('uploads.select_batch')} checked={all} onChange={(e) => onToggle(ids, e.target.checked)} />
)}
<b>{first.author.username}</b>
<span>{formatDateTime(first.created_at, i18n.language)}</span>
<span>{t('uploads.batch_size', { count: files.length, size: formatBytes(size, i18n.language) })}</span>
{first.batch_comment && <span className={u.comment}>«{first.batch_comment}»</span>}
<span style={{ flex: 1 }} />
{selectable && (
<Button variant="ghost" onClick={() => onEditBatch(first.batch_id)}>
{t('uploads.edit_batch')}
</Button>
)}
</div>
{files.map((f) => (
<QueueRow
key={f.id}
file={f}
selectable={selectable}
checked={selected.has(f.id)}
onCheck={(on) => onToggle([f.id], on)}
onEdit={() => onEdit(f)}
onRetry={() => onRetry(f.id)}
/>
))}
</section>
);
}
function QueueRow({
file: f,
selectable,
checked,
onCheck,
onEdit,
onRetry,
}: {
file: UploadFile;
selectable: boolean;
checked: boolean;
onCheck: (on: boolean) => void;
onEdit: () => void;
onRetry: () => void;
}) {
const { t, i18n } = useTranslation('admin');
const reason = useReasonText()(f.status_reason);
const m = f.metadata;
const quality = [
f.format?.toUpperCase(),
f.bitrate ? `${Math.round(f.bitrate / 1000)} kbps` : null,
f.sample_rate ? `${(f.sample_rate / 1000).toFixed(1)} kHz` : null,
f.bit_depth ? `${f.bit_depth} bit` : null,
f.duration_ms ? formatDuration(f.duration_ms) : null,
formatBytes(f.size_bytes, i18n.language),
]
.filter(Boolean)
.join(' · ');
const editable = f.status === 'pending_review' || f.status === 'import_failed';
return (
<div className={u.queueRow}>
{selectable ? (
<input type="checkbox" aria-label={t('uploads.select_file', { name: f.original_name })} checked={checked} onChange={(e) => onCheck(e.target.checked)} />
) : (
<span />
)}
<div className={u.meta}>
<span className={u.metaTitle}>{m ? `${m.artist} — ${m.title}` : f.original_name}</span>
{m && (
<span className={u.sub}>
{[m.album, m.year, m.disc && m.disc > 1 ? t('uploads.disc', { n: m.disc }) : null, m.track ? `#${m.track}` : null, m.genre]
.filter(Boolean)
.join(' · ')}
{f.metadata_edited && ` · ${t('uploads.edited')}`}
</span>
)}
<span className={u.sub}>{quality}</span>
<span className={u.sub} title={f.client_rel_path ?? undefined}>
{f.client_rel_path ?? f.original_name}
</span>
</div>
<div className={u.meta}>
<div className={u.flags}>
<UploadStatusBadge status={f.status} />
{needsFormatAttention(f.format, f.original_name) && (
<span className={cx(s.badge, u.tone_warn)} title={t('uploads.format_attention')}>
{t('uploads.flag_attention')}
</span>
)}
{f.possible_duplicate && <span className={cx(s.badge, u.tone_warn)}>{t('uploads.flag_possible')}</span>}
{f.quality_upgrade && <span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_quality')}</span>}
{f.matched_album_id ? (
<span className={cx(s.badge, u.tone_info)}>{t('uploads.flag_existing_album')}</span>
) : (
m && <span className={cx(s.badge, u.tone_muted)}>{t('uploads.flag_new_album')}</span>
)}
</div>
{f.target_rel_path && <span className={u.target}>{f.target_rel_path}</span>}
{reason && <span className={f.status === 'pending_review' ? u.warn : u.bad}>{reason}</span>}
{f.duplicate_level && f.status === 'duplicate' && (
<span className={u.sub}>{t(`uploads.dup_level_${f.duplicate_level}`)}</span>
)}
</div>
<div className={u.rowActions}>
{f.stream_url && (
<audio controls preload="none" src={f.stream_url} aria-label={t('uploads.listen', { name: f.original_name })} />
)}
{editable && (
<Button variant="ghost" aria-label={t('uploads.edit', { name: f.original_name })} onClick={onEdit}>
<Icon src={editIcon} size={18} />
</Button>
)}
{f.status === 'import_failed' && (
<Button variant="ghost" icon={refreshIcon} onClick={onRetry}>
{t('uploads.retry')}
</Button>
)}
</div>
</div>
);
}
const EMPTY_META: Meta = { artist: '', albumartist: '', album: '', title: '', genre: '', year: null, track: null, disc: null };
function BatchEditDialog({ batchId, onClose, onSaved }: { batchId: string; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const [artist, setArtist] = useState('');
const [album, setAlbum] = useState('');
const [year, setYear] = useState('');
const [cover, setCover] = useState<string | null | undefined>(undefined);
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/batches/{id}', {
params: { path: { id: batchId } },
body: {
...(artist.trim() ? { artist: artist.trim() } : {}),
...(album.trim() ? { album: album.trim() } : {}),
...(year.trim() ? { year: Number.parseInt(year, 10) } : {}),
...(cover !== undefined ? { cover_base64: cover } : {}),
},
}),
),
onSuccess: onSaved,
});
const pickCover = (file: File | undefined) => {
if (!file) {
setCover(undefined);
return;
}
const reader = new FileReader();
reader.onload = () => {
const data = String(reader.result ?? '');
const comma = data.indexOf(',');
setCover(comma >= 0 ? data.slice(comma + 1) : data);
};
reader.readAsDataURL(file);
};
return (
<Dialog
title={t('uploads.batch_edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{t('uploads.batch_edit_hint')}</p>
<Field label={t('uploads.meta_artist')} value={artist} onChange={setArtist} maxLength={500} />
<Field label={t('uploads.meta_album')} value={album} onChange={setAlbum} maxLength={500} />
<Field label={t('uploads.meta_year')} value={year} inputMode="numeric" onChange={setYear} />
<label className={s.sessions}>
<span>{t('uploads.batch_cover')}</span>
<input type="file" accept="image/jpeg,image/png,image/webp" onChange={(e) => pickCover(e.target.files?.[0])} />
</label>
<Button variant="ghost" onClick={() => setCover(null)}>
{t('uploads.batch_cover_clear')}
</Button>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</div>
</Dialog>
);
}
function EditDialog({ file, onClose, onSaved }: { file: UploadFile; onClose: () => void; onSaved: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const [m, setM] = useState<Meta>(file.metadata ?? EMPTY_META);
const [source, setSource] = useState(file.target_source_id ?? '');
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PATCH('/api/v1/admin/uploads/files/{id}', {
params: { path: { id: file.id } },
body: { metadata: m, ...(source ? { target_source_id: source } : {}) },
}),
),
onSuccess: () => {
onSaved();
onClose();
},
});
const text = (k: 'artist' | 'albumartist' | 'album' | 'title' | 'genre') => (
<Field label={t(`uploads.meta_${k}`)} value={m[k]} onChange={(v) => setM({ ...m, [k]: v })} maxLength={500} />
);
const num = (k: 'year' | 'track' | 'disc') => (
<Field
label={t(`uploads.meta_${k}`)}
value={m[k] === null ? '' : String(m[k])}
inputMode="numeric"
onChange={(v) => setM({ ...m, [k]: v.trim() === '' ? null : Number.parseInt(v, 10) || null })}
/>
);
const accepting = sources.data?.filter((x) => x.accepts_uploads) ?? [];
return (
<Dialog
title={t('uploads.edit_title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="primary" busy={save.isPending} disabled={!m.artist.trim() || !m.title.trim()} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</>
}
>
<div className={s.sessions}>
<p className={s.note}>{file.client_rel_path ?? file.original_name}</p>
<div className={u.grid}>
{text('artist')}
{text('albumartist')}
{text('album')}
{text('title')}
{text('genre')}
{num('year')}
{num('track')}
{num('disc')}
</div>
<label className={s.sessions}>
<span>{t('uploads.target_source')}</span>
<select className={s.select} value={source} onChange={(e) => setSource(e.target.value)}>
<option value="">{t('uploads.target_default')}</option>
{accepting.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<p className={s.note}>{t('uploads.tags_note')}</p>
<FormError>{save.error ? (fieldError(save.error, 'metadata') ?? errorText(save.error)) : null}</FormError>
</div>
</Dialog>
);
}
function RejectDialog({
count,
busy,
onClose,
onReject,
}: {
count: number;
busy: boolean;
onClose: () => void;
onReject: (reason: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const [reason, setReason] = useState('');
return (
<Dialog
title={t('uploads.reject_title', { count })}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button variant="danger" busy={busy} onClick={() => onReject(reason)}>
{t('uploads.reject')}
</Button>
</>
}
>
<label className={s.sessions}>
<span>{t('uploads.reject_reason')}</span>
<textarea className={u.textarea} value={reason} maxLength={1000} onChange={(e) => setReason(e.target.value)} />
</label>
</Dialog>
);
}
const GB = 1024 ** 3;
/** Настройки очереди: общий лимит, лимит на модератора, размер файла, хранилище по умолчанию. */
export function UploadSettingsSection() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const settings = useQuery({
queryKey: ['upload-settings'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/settings/uploads')),
});
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
});
const [form, setForm] = useState<{ queue: string; perUser: string; maxFile: string; source: string; upgrade: boolean } | null>(null);
useEffect(() => {
if (settings.data) setForm(toForm(settings.data));
}, [settings.data]);
const save = useMutation({
mutationFn: async () => {
const f = form!;
const gb = (v: string) => Math.round(Number.parseFloat(v.replace(',', '.')) * GB);
return unwrap(
await session.client.PATCH('/api/v1/admin/settings/uploads', {
body: {
queue_limit_bytes: gb(f.queue),
max_file_bytes: gb(f.maxFile),
per_user_limit_bytes: f.perUser.trim() ? gb(f.perUser) : null,
default_source_id: f.source || null,
allow_quality_upgrade: f.upgrade,
},
}),
);
},
onSuccess: async (data) => {
queryClient.setQueryData(['upload-settings'], data);
await queryClient.invalidateQueries({ queryKey: ['upload-quota'] });
},
});
if (!form) return null;
const valid = [form.queue, form.maxFile].every((v) => Number.parseFloat(v.replace(',', '.')) > 0) &&
(!form.perUser.trim() || Number.parseFloat(form.perUser.replace(',', '.')) > 0);
const dirty = settings.data ? JSON.stringify(toForm(settings.data)) !== JSON.stringify(form) : false;
return (
<section className={s.section}>
<h2>{t('uploads.settings_title')}</h2>
<div className={u.settingsGrid}>
<Field label={t('uploads.set_queue')} value={form.queue} onChange={(v) => setForm({ ...form, queue: v })} inputMode="decimal" />
<Field
label={t('uploads.set_per_user')}
value={form.perUser}
hint={t('uploads.set_per_user_hint')}
onChange={(v) => setForm({ ...form, perUser: v })}
inputMode="decimal"
/>
<Field label={t('uploads.set_max_file')} value={form.maxFile} onChange={(v) => setForm({ ...form, maxFile: v })} inputMode="decimal" />
<label>
<span className={u.settingsLabel}>{t('uploads.set_source')}</span>
<select className={s.select} value={form.source} onChange={(e) => setForm({ ...form, source: e.target.value })}>
<option value="">{t('uploads.set_source_auto')}</option>
{sources.data
?.filter((x) => x.accepts_uploads)
.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</label>
<div>
<label className={u.settingsCheck}>
<input type="checkbox" checked={form.upgrade} onChange={(e) => setForm({ ...form, upgrade: e.target.checked })} />
<span>{t('uploads.set_upgrade')}</span>
</label>
</div>
</div>
<p className={s.note}>{t('uploads.set_note')}</p>
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="primary" busy={save.isPending} disabled={!dirty || !valid} onClick={() => save.mutate()}>
{t('uploads.save')}
</Button>
</div>
{save.isSuccess && !dirty && <span className={s.ok}>{t('uploads.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</section>
);
}
function toForm(st: Settings) {
const gb = (n: number) => String(Math.round((n / GB) * 100) / 100);
return {
queue: gb(st.queue_limit_bytes),
perUser: st.per_user_limit_bytes === null ? '' : gb(st.per_user_limit_bytes),
maxFile: gb(st.max_file_bytes),
source: st.default_source_id ?? '',
upgrade: st.allow_quality_upgrade,
};
}
export function UploadQueuePage() { return <UploadQueue />; }
@@ -123,8 +123,14 @@
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.analysisActions { display: flex; flex-wrap: wrap; gap: 8px; }
.analysisErrors { display: grid; gap: 16px; list-style: none; margin: 16px 0; padding: 0; }
.analysisErrors li { display: grid; gap: 6px; padding-bottom: 16px; border-bottom: 1px solid var(--line); overflow-wrap: anywhere; }
.analysisPath { font-size: 12px; white-space: pre-wrap; overflow-wrap: anywhere; }
.errorDetail {
display: block;
margin-top: 4px;
@@ -146,6 +152,26 @@
transition: width 0.4s;
}
.scanPath {
font-size: 12px;
color: var(--text3);
overflow-wrap: anywhere;
}
.indeterminate span {
width: 35%;
animation: scanActivity 1.5s ease-in-out infinite alternate;
}
@keyframes scanActivity {
from { transform: translateX(0); }
to { transform: translateX(185%); }
}
@media (prefers-reduced-motion: reduce) {
.indeterminate span { animation: none; }
}
.test {
font-size: 13px;
}
@@ -212,6 +238,20 @@
gap: 6px;
}
.excludedDirs {
width: 100%;
box-sizing: border-box;
resize: vertical;
border: 1px solid var(--line2);
border-radius: var(--radius-md);
padding: 10px 12px;
background: var(--surface2);
color: var(--text);
font: 13px var(--font-mono);
}
.excludedDirs:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.excludedDirs:disabled { opacity: 0.6; }
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
+2
View File
@@ -2,6 +2,8 @@
.stats dt { color: var(--text2); font-size: 13px; }
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; }
.progress { display: grid; gap: 8px; font-variant-numeric: tabular-nums; }
.progress progress { display: block; width: 100%; height: 8px; accent-color: var(--accent); }
.lyrics { white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.8; }
.annotation { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
.annotation summary { cursor: pointer; line-height: 1.6; }
+1 -84
View File
@@ -1,84 +1 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { formatBytes } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from '../page.module.css';
import u from './uploads.module.css';
export type UploadStatus = components['schemas']['UploadStatus'];
export type UploadFile = components['schemas']['UploadFile'];
const TONE: Record<UploadStatus, 'ok' | 'warn' | 'bad' | 'info' | 'muted'> = {
uploading: 'info',
processing: 'info',
pending_review: 'warn',
approved: 'info',
importing: 'info',
imported: 'ok',
import_failed: 'bad',
duplicate: 'muted',
invalid: 'bad',
rejected: 'bad',
withdrawn: 'muted',
expired: 'muted',
};
/** Статусы, которые ещё меняются сами (обработка, импорт) — список опрашивается. */
export const MOVING: UploadStatus[] = ['uploading', 'processing', 'approved', 'importing'];
export function UploadStatusBadge({ status }: { status: UploadStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.badge, u[`tone_${TONE[status]}`])}>{t(`uploads.status.${status}`)}</span>;
}
export function useUploadQuota() {
const session = useSession();
return useQuery({
queryKey: ['upload-quota'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/uploads/quota')),
});
}
/** «Очередь: 6,2 ГБ из 10 ГБ» — видят модераторы и админ (docs/uploads.md#3). */
export function QuotaMeter() {
const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null;
const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language);
return (
<div className={u.quota}>
<span className={u.quotaText} id={id}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span>
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
);
}
/** Понятный текст причины: код из errors:<code> или текст отклонения админа. */
export function useReasonText(): (reason: string | null) => string | null {
const { t, i18n } = useTranslation(['admin', 'errors']);
const tr = t as unknown as (k: string) => string;
return (reason) => {
if (!reason) return null;
const code = reason.split(':')[0]!.trim();
const key = `errors:${code}`;
if (i18n.exists(key)) {
const rest = reason.slice(code.length + 1).trim();
return rest ? `${tr(key)}: ${rest}` : tr(key);
}
return reason;
};
}
export * from '../../features/uploads/common';
+31
View File
@@ -0,0 +1,31 @@
export class AudioAnalysis {
constructor(private readonly getAnalyser: () => AnalyserNode | null) {}
private spectrum = new Uint8Array(512);
private audioFrame = { bass: 0, mid: 0, treble: 0 };
private sampledAt = -Infinity;
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
read = (active: boolean) => {
const now = performance.now();
if (now - this.sampledAt < 12) return this.audioFrame;
this.sampledAt = now;
const analyser = this.getAnalyser();
if (!active || !analyser) {
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
return this.audioFrame;
}
analyser.getByteFrequencyData(this.spectrum);
const band = (low: number, high: number) => {
const hz = analyser.context.sampleRate / analyser.fftSize;
const start = Math.max(1, Math.ceil(low / hz));
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
let sum = 0;
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
return Math.sqrt(sum / Math.max(1, end - start));
};
this.audioFrame.bass = band(40, 220);
this.audioFrame.mid = band(220, 2500);
this.audioFrame.treble = band(2500, 10000);
return this.audioFrame;
};
}
+28 -19
View File
@@ -1,48 +1,57 @@
import type { AuthSession } from '@pipu/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Player } from './player';
import { AudioAnalysis } from './analysis';
import { AudioEngine } from './engine';
afterEach(() => vi.restoreAllMocks());
function fixture() {
const player = new Player({} as AuthSession, document.createElement('audio'));
const sample = vi.fn((data: Uint8Array) => {
data.fill(0);
// 48 kHz / 1024: bins 1..4 cover the bass band.
data.fill(255, 1, 5);
});
Object.assign(player, {
state: { ...player.getState(), playing: true },
analyser: { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample },
});
return { player, sample };
const analyser = { context: { sampleRate: 48000 }, fftSize: 1024, getByteFrequencyData: sample } as unknown as AnalyserNode;
const analysis = new AudioAnalysis(() => analyser);
return { analysis, sample };
}
describe('audio reactivity', () => {
it('separates bass from the other frequency bands and shares the frame', () => {
vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture();
expect(player.readAudio()).toEqual({ bass: 1, mid: 0, treble: 0 });
player.readAudio();
const { analysis, sample } = fixture();
expect(analysis.read(true)).toEqual({ bass: 1, mid: 0, treble: 0 });
analysis.read(true);
expect(sample).toHaveBeenCalledTimes(1);
});
it('returns silence after pausing or muting', () => {
const clock = vi.spyOn(performance, 'now').mockReturnValue(100);
const { player, sample } = fixture();
player.readAudio();
Object.assign(player, { state: { ...player.getState(), playing: false } });
const { analysis, sample } = fixture();
analysis.read(true);
clock.mockReturnValue(120);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
Object.assign(player, { state: { ...player.getState(), playing: true, muted: true } });
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
clock.mockReturnValue(140);
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
expect(sample).toHaveBeenCalledTimes(1);
});
it('works when Web Audio is unavailable', () => {
const player = new Player({} as AuthSession, document.createElement('audio'));
expect(player.readAudio()).toEqual({ bass: 0, mid: 0, treble: 0 });
const analysis = new AudioAnalysis(() => null);
expect(analysis.read(false)).toEqual({ bass: 0, mid: 0, treble: 0 });
});
});
describe('audio engine lifetime', () => {
it('removes audio listeners on disposal', () => {
const audio = document.createElement('audio');
const engine = new AudioEngine(audio);
const playing = vi.fn();
engine.on('playing', playing);
audio.dispatchEvent(new Event('playing'));
expect(playing).toHaveBeenCalledTimes(1);
engine.dispose();
audio.dispatchEvent(new Event('playing'));
expect(playing).toHaveBeenCalledTimes(1);
});
});
+66
View File
@@ -0,0 +1,66 @@
import { AudioAnalysis } from './analysis';
/** Browser audio graph and element lifetime; playback policy belongs to Player/core. */
export class AudioEngine {
readonly audio: HTMLAudioElement;
readonly overlap: HTMLAudioElement;
private gain: GainNode | null = null;
private wired = false;
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
readonly analysis = new AudioAnalysis(() => this.analyser);
private readonly unbind: (() => void)[] = [];
constructor(audio?: HTMLAudioElement) {
this.audio = audio ?? new Audio();
this.overlap = new Audio();
this.audio.preload = this.overlap.preload = 'auto';
}
resume() {
return this.audioContext?.state === 'suspended' ? this.audioContext.resume() : undefined;
}
on<K extends keyof HTMLMediaElementEventMap>(event: K, handler: (event: HTMLMediaElementEventMap[K]) => void) {
this.audio.addEventListener(event, handler);
this.unbind.push(() => this.audio.removeEventListener(event, handler));
}
applyLoudness(level: number) {
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
this.audioContext = ctx;
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.55;
this.gain.connect(this.analyser);
// The overlap is already audible through its own output; analyse it only.
try {
const overlapSource = ctx.createMediaElementSource(this.overlap);
overlapSource.connect(ctx.destination);
overlapSource.connect(this.analyser);
} catch { /* Crossfade analysis is optional. */ }
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
}
dispose() {
for (const stop of this.unbind.splice(0)) stop();
for (const audio of [this.audio, this.overlap]) {
audio.pause();
audio.removeAttribute('src');
audio.load();
}
void this.audioContext?.close().catch(() => undefined);
}
}
+105
View File
@@ -0,0 +1,105 @@
import type { Track } from '@pipu/core';
import { desktopHandle } from '../desktop';
import type { PlayerState } from './types';
export interface PlaybackControls {
play(): void; pause(): void; toggle(): void; next(): void; prev(): void; seek(ms: number): void;
}
export class MediaControls {
private lastDesktop: { id?: string; playing?: boolean } = {};
private desktopUnlisten: (() => void) | null = null;
private disposed = false;
constructor(
private readonly controls: PlaybackControls,
private readonly getState: () => PlayerState,
private readonly getAudio: () => HTMLAudioElement,
) {
this.bindMediaSession();
this.bindDesktopMedia();
}
private bindDesktopMedia() {
void desktopHandle()?.listenMedia((action) => {
if (action === 'play') this.controls.play();
else if (action === 'pause' || action === 'stop') this.controls.pause();
else if (action === 'toggle') this.controls.toggle();
else if (action === 'next') this.controls.next();
else if (action === 'previous') this.controls.prev();
}).then((stop) => {
if (this.disposed) stop();
else this.desktopUnlisten = stop;
});
}
syncDesktopMedia() {
const desktop = desktopHandle();
const track = this.getState().track;
if (!desktop || !track) return;
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.getState().playing) return;
this.lastDesktop = { id: track.id, playing: this.getState().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.getState().playing,
});
}
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.controls.play());
set('pause', () => this.controls.pause());
set('nexttrack', () => this.controls.next());
set('previoustrack', () => this.controls.prev());
set('seekbackward', (d) => this.controls.seek(this.getState().positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.controls.seek(this.getState().positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.controls.seek(d.seekTime * 1000));
}
updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.getAudio().duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.getAudio().currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
}
dispose() {
this.disposed = true;
this.desktopUnlisten?.();
this.desktopUnlisten = null;
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
for (const action of ['play', 'pause', 'nexttrack', 'previoustrack', 'seekbackward', 'seekforward', 'seekto'] as const) {
try { ms?.setActionHandler(action, null); } catch { /* Unsupported action. */ }
}
}
}
+129
View File
@@ -0,0 +1,129 @@
import { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
import type { PlayerState } from './types';
import { offlineOwner } from '../features/desktop/offline';
const LOCAL_KEY = 'pipu.player';
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
export interface Saved {
queue: string[];
index: number;
positionMs: number;
context: PlayContext | null;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
savedAt: string;
}
function deviceId(): string {
try {
let id = localStorage.getItem('pipu.device');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('pipu.device', id);
}
return id;
} catch {
return 'web';
}
}
export class PlaybackPersistence {
private get localKey() { const owner = offlineOwner(this.session); return owner ? `${LOCAL_KEY}.${owner}` : LOCAL_KEY; }
private readonly device = deviceId();
lastServerSave = 0;
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
snapshot(state: PlayerState): Saved {
const ids = state.queue.map((t) => t.id);
const start = Math.max(0, Math.min(state.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: state.index - start,
positionMs: state.positionMs,
context: state.context,
volume: state.volume,
shuffle: state.shuffle,
repeat: state.repeat,
savedAt: new Date().toISOString(),
};
}
saveLocal() {
if (!this.getState().track) return;
try {
localStorage.setItem(this.localKey, JSON.stringify(this.snapshot(this.getState())));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.getState().track;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot(this.getState());
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: cur.id,
position_ms: s.positionMs,
queue: s.queue,
queue_index: s.index,
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
shuffle: s.shuffle,
repeat_mode: s.repeat,
volume: s.volume,
device_id: this.device,
},
keepalive,
})
.catch(() => undefined);
}
async restore(): Promise<Saved | null> {
let local: Saved | null = null;
try {
const raw = localStorage.getItem(this.localKey);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
}
let saved = local;
try {
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (
server.track_id &&
server.updated_at &&
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
) {
saved = {
queue: server.queue,
index: server.queue_index,
positionMs: server.position_ms,
context: (server.context?.type ? server.context : null) as PlayContext | null,
volume: server.volume,
shuffle: server.shuffle,
repeat: server.repeat_mode,
savedAt: server.updated_at,
};
}
} catch {
// офлайн — остаёмся с локальной копией
}
return saved;
}
dispose() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = null;
}
}
+117 -349
View File
@@ -1,5 +1,9 @@
import {
EventBuffer,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveOutcome,
type WaveFeedback,
normalizeGain,
PlayAttempt,
PlayQueue,
@@ -10,80 +14,32 @@ import {
type Track,
} from '@pipu/core';
import { desktopHandle } from '../desktop';
import { MediaControls } from './media';
import type { PlayerState } from './types';
import { AudioEngine } from './engine';
import { PlaybackPersistence } from './persistence';
import { DesktopOffline, desktopEventSink, offlineOwner } from '../features/desktop/offline';
export type { PlayContext, Track };
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
/** Рекомендатель недоступен — играет запасная волна. */
fallback: boolean;
}
export type { WaveInfo } from '@pipu/core';
type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
export type { PlayerState } from './types';
/** Когда в очереди волны остаётся столько треков, просим следующую пачку. */
const WAVE_REFILL_AT = 2;
/** Скип раньше этого — «ранний» (сильный отрицательный сигнал). */
const EARLY_SKIP_MS = 30_000;
export interface PlayerState {
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
const LOCAL_KEY = 'pipu.player';
const SAVE_EVERY_MS = 10_000;
const FLUSH_EVERY_MS = 30_000;
const PRELOAD_BEFORE_MS = 20_000;
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
interface Saved {
queue: string[];
index: number;
positionMs: number;
context: PlayContext | null;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
savedAt: string;
}
function deviceId(): string {
try {
let id = localStorage.getItem('pipu.device');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('pipu.device', id);
}
return id;
} catch {
return 'web';
}
}
/**
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private readonly audio: HTMLAudioElement;
private readonly overlap: HTMLAudioElement;
readonly offline: DesktopOffline | null;
private retryEvents: (() => Promise<void>) | null = null;
private readonly engine: AudioEngine;
private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; }
private fading = false;
private ignoreEnded = false;
private readonly queue = new PlayQueue();
@@ -92,13 +48,17 @@ export class Player {
private context: PlayContext | null = null;
private state: PlayerState;
private readonly listeners = new Set<() => void>();
private readonly device = deviceId();
private lastServerSave = 0;
private readonly persistence: PlaybackPersistence;
private preloaded: string | null = null;
private refreshedFor: string | null = null;
private timers: ReturnType<typeof setInterval>[] = [];
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private readonly wavePolicy = new WaveCoordinator({
next: async (id: string, feedback: WaveFeedback[]) => unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id } }, body: { feedback },
}),
),
});
private restoreVersion = 0;
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null;
@@ -107,12 +67,17 @@ export class Player {
private readonly session: AuthSession,
audio?: HTMLAudioElement,
) {
this.audio = audio ?? new Audio();
this.audio.preload = 'auto';
this.overlap = new Audio();
this.overlap.preload = 'auto';
this.engine = new AudioEngine(audio);
const owner = offlineOwner(session);
this.offline = owner ? new DesktopOffline(owner, session) : null;
if (owner) {
const send = desktopEventSink(session, owner);
this.retryEvents = () => send([]);
this.sendEvents = send;
}
this.events = new EventBuffer({
send: async (events) => {
if (this.sendEvents) return this.sendEvents(events);
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
},
});
@@ -121,15 +86,22 @@ export class Player {
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
};
this.persistence = new PlaybackPersistence(session, this.getState);
this.bindAudio();
this.bindMediaSession();
this.bindDesktopMedia();
this.media = new MediaControls(this, this.getState, () => this.audio);
}
/** Запуск фоновой отправки событий (парный вызов — dispose). */
start() {
this.offline?.activate();
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
if (this.retryEvents) {
void this.retryEvents().catch(() => undefined);
this.timers.push(setInterval(() => void this.retryEvents?.().catch(() => undefined), FLUSH_EVERY_MS));
}
}
private sendEvents: ((events: import('@pipu/core').PlayEvent[]) => Promise<void>) | null = null;
// --- подписка (useSyncExternalStore) ---
@@ -140,13 +112,12 @@ export class Player {
getState = () => this.state;
private lastDesktop: { id?: string; playing?: boolean } = {};
private desktopUnlisten: (() => void) | null = null;
private readonly media: MediaControls;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
this.syncDesktopMedia();
this.media.syncDesktopMedia();
}
private syncQueue() {
@@ -183,16 +154,28 @@ export class Player {
this.restoreVersion++;
this.finishAttempt('skip');
this.leaveWave();
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', {
const version = this.wavePolicy.version;
let res;
try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) },
}),
);
}));
} catch (error) {
if (!this.offline) throw error;
await this.offline.ready;
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = this.offline.tracks().filter((t) => t.reaction !== 'dislike');
if (!tracks.length) throw error;
this.playList(tracks.sort(() => Math.random() - 0.5), 0, { type: 'collection', id: 'offline' });
return;
}
if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) {
this.update({ error: 'wave.empty' });
return;
}
this.wavePolicy.activate(res.session_id);
this.context = { type: 'wave', id: res.session_id, label: seed?.label };
this.queue.shuffle = false;
this.queue.repeat = 'off';
@@ -210,12 +193,14 @@ export class Player {
const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood);
this.update({ wave: { ...wave, activity, mood } });
const version = this.wavePolicy.changeContext(wave.sessionId);
const res = unwrap(
await this.session.client.PATCH('/api/v1/wave/sessions/{id}', {
params: { path: { id: wave.sessionId } },
body: { activity, mood },
}),
);
if (!this.wavePolicy.isCurrent(version)) return;
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
this.queue.dropUpcoming();
this.queue.append(fresh);
@@ -236,8 +221,8 @@ export class Player {
private leaveWave() {
const wave = this.state.wave;
this.wavePolicy.activate(null);
if (!wave) return;
this.waveFeedback = [];
this.update({ wave: null });
void this.session.client
.DELETE('/api/v1/wave/sessions/{id}', { params: { path: { id: wave.sessionId } } })
@@ -245,7 +230,7 @@ export class Player {
}
private noteWave(trackId: string, outcome: WaveOutcome) {
if (this.state.wave) this.waveFeedback.push({ track_id: trackId, outcome });
this.wavePolicy.note(trackId, outcome);
}
/**
@@ -253,29 +238,12 @@ export class Player {
* после лайка, дизлайка или раннего скипа очередь должна сразу отразить реакцию.
*/
private async refillWave(refresh = false) {
const wave = this.state.wave;
if (!wave || this.waveLoading) return;
if (!refresh && this.queue.remaining > WAVE_REFILL_AT) return;
this.waveLoading = true;
const feedback = this.waveFeedback;
this.waveFeedback = [];
try {
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions/{id}/next', {
params: { path: { id: wave.sessionId } },
body: { feedback },
}),
);
if (this.state.wave?.sessionId !== wave.sessionId) return; // волну уже выключили
await this.wavePolicy.refill(this.queue.remaining, refresh, (res) => {
if (refresh) this.queue.dropUpcoming();
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
this.update({ wave: { ...this.state.wave, fallback: res.fallback } });
this.update({ wave: { ...this.state.wave!, fallback: res.fallback } });
this.syncQueue();
} catch {
this.waveFeedback = [...feedback, ...this.waveFeedback]; // отправим со следующим запросом
} finally {
this.waveLoading = false;
}
});
}
toggle() {
@@ -289,7 +257,7 @@ export class Player {
void this.load(true, this.state.positionMs);
return;
}
if (this.audioContext?.state === 'suspended') void this.audioContext.resume().catch(() => undefined);
void this.engine.resume()?.catch(() => undefined);
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
}
@@ -398,12 +366,20 @@ export class Player {
private async load(autoplay: boolean, startMs = 0) {
const track = this.queue.current;
if (!track?.stream_url) return;
const version = this.restoreVersion;
if (this.offline) {
await this.offline.ready.catch(() => undefined);
if (this.queue.current !== track || version !== this.restoreVersion) return;
}
this.offline?.keep([track.id]);
const source = this.offline?.local(track).stream_url ?? track.stream_url;
this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
// Keep the restored element's buffer when the user presses Play.
if (this.audio.getAttribute('src') !== track.stream_url) this.audio.src = track.stream_url;
if (this.audio.getAttribute('src') !== source) this.audio.src = source;
if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
this.updateMediaMetadata(track);
this.media.updateMediaMetadata(track);
if (!autoplay) return;
void this.offline?.save(track).catch(() => undefined);
this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null;
@@ -418,7 +394,8 @@ export class Player {
void this.refillWave();
if (autoplay) {
try {
if (this.audioContext?.state === 'suspended') await this.audioContext.resume();
const resumed = this.engine.resume();
if (resumed) await resumed;
await this.audio.play();
} catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -465,73 +442,11 @@ export class Player {
return Math.min(6, Math.max(0, this.prefs()?.crossfade_s ?? 0));
}
/** Громкость пользователя × выравнивание к −14 LUFS. GainNode, если браузер его даёт. */
private gain: GainNode | null = null;
private wired = false;
private analyser: AnalyserNode | null = null;
private audioContext: AudioContext | null = null;
private spectrum = new Uint8Array(512);
private audioFrame = { bass: 0, mid: 0, treble: 0 };
private sampledAt = -Infinity;
/** Shared spectrum snapshot; animation consumers never trigger React renders. */
readAudio = () => {
const now = performance.now();
if (now - this.sampledAt < 12) return this.audioFrame;
this.sampledAt = now;
const active = this.state.playing && !this.state.loading && !this.state.muted;
const analyser = this.analyser;
if (!active || !analyser) {
this.audioFrame.bass = this.audioFrame.mid = this.audioFrame.treble = 0;
return this.audioFrame;
}
analyser.getByteFrequencyData(this.spectrum);
const band = (low: number, high: number) => {
const hz = analyser.context.sampleRate / analyser.fftSize;
const start = Math.max(1, Math.ceil(low / hz));
const end = Math.min(this.spectrum.length, Math.ceil(high / hz));
let sum = 0;
for (let i = start; i < end; i++) sum += (this.spectrum[i]! / 255) ** 2;
return Math.sqrt(sum / Math.max(1, end - start));
};
this.audioFrame.bass = band(40, 220);
this.audioFrame.mid = band(220, 2500);
this.audioFrame.treble = band(2500, 10000);
return this.audioFrame;
};
readAudio = () => this.engine.analysis.read(this.state.playing && !this.state.loading && !this.state.muted);
private applyLoudness(mix = 1) {
const track = this.state.track;
const user = this.state.muted ? 0 : this.state.volume;
const level = user * normalizeGain(track?.features?.loudness_lufs, this.normalizeOn()) * mix;
if (!this.wired && typeof AudioContext !== 'undefined') {
try {
const ctx = new AudioContext();
this.audioContext = ctx;
const src = ctx.createMediaElementSource(this.audio);
this.gain = ctx.createGain();
src.connect(this.gain);
this.gain.connect(ctx.destination);
this.analyser = ctx.createAnalyser();
this.analyser.fftSize = 1024;
this.analyser.smoothingTimeConstant = 0.55;
this.gain.connect(this.analyser);
// The overlap is already audible through its own output; analyse it only.
try {
const overlapSource = ctx.createMediaElementSource(this.overlap);
overlapSource.connect(ctx.destination);
overlapSource.connect(this.analyser);
} catch { /* Crossfade analysis is optional. */ }
this.audio.volume = 1;
this.wired = true;
} catch {
this.wired = true;
}
}
if (this.gain) this.gain.gain.value = level;
else this.audio.volume = Math.min(1, level);
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
}
/** В конце трека накладывает следующий на crossfade_s секунд. */
@@ -545,7 +460,8 @@ export class Player {
const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return;
this.fading = true;
this.overlap.src = next.stream_url;
this.offline?.keep([this.queue.current!.id, next.id]);
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
this.overlap.volume = 0;
void this.overlap.play().catch(() => {
this.fading = false;
@@ -578,7 +494,8 @@ export class Player {
return;
}
this.syncQueue();
const src = next.stream_url;
const src = this.offline?.local(next).stream_url ?? next.stream_url;
this.offline?.keep([next.id]);
if (!src) {
this.fading = false;
this.ignoreEnded = false;
@@ -590,7 +507,8 @@ export class Player {
this.applyLoudness();
this.attempt = new PlayAttempt(next, Math.round(at * 1000));
this.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.updateMediaMetadata(next);
this.media.updateMediaMetadata(next);
void this.offline?.save(next).catch(() => undefined);
void this.audio.play().finally(() => {
this.fading = false;
this.ignoreEnded = false;
@@ -599,13 +517,13 @@ export class Player {
private bindAudio() {
const a = this.audio;
a.addEventListener('playing', () => {
this.engine.on('playing', () => {
this.update({ playing: true, loading: false });
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
}
});
a.addEventListener('pause', () => {
this.engine.on('pause', () => {
if (a.ended) return;
this.update({ playing: false });
const t = this.queue.current;
@@ -614,22 +532,22 @@ export class Player {
this.saveServer();
}
});
a.addEventListener('waiting', () => {
this.engine.on('waiting', () => {
if (this.attempt) this.update({ loading: true });
});
a.addEventListener('timeupdate', () => {
this.engine.on('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms);
this.update({ positionMs: ms });
this.updatePositionState();
this.media.updatePositionState();
if (this.state.playing) {
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.persistence.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal();
});
a.addEventListener('ended', () => {
this.engine.on('ended', () => {
if (this.ignoreEnded || this.fading || !this.attempt) return;
this.finishAttempt('play_end');
if (this.queue.next(true)) {
@@ -640,7 +558,7 @@ export class Player {
this.saveServer();
}
});
a.addEventListener('error', () => void this.onError());
this.engine.on('error', () => void this.onError());
}
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
@@ -680,141 +598,14 @@ export class Player {
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
this.preloaded = next.id;
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
}
// --- Media Session ---
private bindDesktopMedia() {
void desktopHandle()?.listenMedia((action) => {
if (action === 'play') this.play();
else if (action === 'pause' || action === 'stop') this.pause();
else if (action === 'toggle') this.toggle();
else if (action === 'next') this.next();
else if (action === 'previous') this.prev();
}).then((stop) => {
this.desktopUnlisten = stop;
});
}
private syncDesktopMedia() {
const desktop = desktopHandle();
const track = this.state.track;
if (!desktop || !track) return;
if (this.lastDesktop.id === track.id && this.lastDesktop.playing === this.state.playing) return;
this.lastDesktop = { id: track.id, playing: this.state.playing };
desktop.setMedia({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
coverUrl: track.album.cover_url,
playing: this.state.playing,
});
}
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.play());
set('pause', () => this.pause());
set('nexttrack', () => this.next());
set('previoustrack', () => this.prev());
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
}
private updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
private updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.audio.duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
void fetch(this.offline?.local(next).stream_url ?? next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
}
// --- сохранение состояния ---
private snapshot(): Saved {
const ids = this.queue.tracks.map((t) => t.id);
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: this.queue.index - start,
positionMs: this.state.positionMs,
context: this.context,
volume: this.state.volume,
shuffle: this.queue.shuffle,
repeat: this.queue.repeat,
savedAt: new Date().toISOString(),
};
}
private saveLocal() {
if (!this.queue.current) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.queue.current;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot();
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: cur.id,
position_ms: s.positionMs,
queue: s.queue,
queue_index: s.index,
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
shuffle: s.shuffle,
repeat_mode: s.repeat,
volume: s.volume,
device_id: this.device,
},
keepalive,
})
.catch(() => undefined);
}
private saveLocal() { this.persistence.saveLocal(); }
private saveSoon() { this.persistence.saveSoon(); }
saveServer(keepalive = false) { this.persistence.saveServer(keepalive); }
/** Уход со страницы: дослать события и состояние. */
onHide() {
@@ -828,40 +619,20 @@ export class Player {
*/
async restore() {
const version = ++this.restoreVersion;
let local: Saved | null = null;
try {
const raw = localStorage.getItem(LOCAL_KEY);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
}
let saved = local;
try {
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (
server.track_id &&
server.updated_at &&
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
) {
saved = {
queue: server.queue,
index: server.queue_index,
positionMs: server.position_ms,
context: (server.context?.type ? server.context : null) as PlayContext | null,
volume: server.volume,
shuffle: server.shuffle,
repeat: server.repeat_mode,
savedAt: server.updated_at,
};
}
} catch {
// офлайн — остаёмся с локальной копией
}
const saved = await this.persistence.restore();
if (version !== this.restoreVersion || !saved?.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
let items: Track[];
try {
items = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })).items;
} catch (error) {
if (!this.offline) throw error;
await this.offline.ready;
const cached = new Map(this.offline.tracks().map((t) => [t.id, t]));
items = saved.queue.flatMap((id) => cached.get(id) ?? []);
}
if (version !== this.restoreVersion) return;
const wanted = saved.queue[saved.index];
const tracks = res.items.filter((t) => t.available && t.stream_url);
const tracks = items.filter((t) => t.available && t.stream_url);
if (!tracks.length) return;
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
this.context = saved.context;
@@ -872,25 +643,22 @@ export class Player {
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
this.syncQueue();
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
this.updateMediaMetadata(tracks[idx]!);
this.media.updateMediaMetadata(tracks[idx]!);
// Prepare at the saved position without creating a listening attempt.
await this.load(false, pos);
}
dispose() {
this.restoreVersion++;
this.wavePolicy.activate(null);
this.fading = false;
this.finishAttempt('skip');
this.audio.pause();
this.audio.removeAttribute('src');
this.audio.load();
this.overlap.pause();
this.overlap.removeAttribute('src');
this.overlap.load();
this.engine.dispose();
this.offline?.dispose();
this.persistence.dispose();
for (const t of this.timers) clearInterval(t);
this.timers = [];
this.desktopUnlisten?.();
this.desktopUnlisten = null;
this.media.dispose();
void this.events.flush();
}
}
+18
View File
@@ -0,0 +1,18 @@
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
export interface PlayerState {
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
+33 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { shareTrack } from './share';
import { sharePlaylist, shareTrack } from './share';
import * as desktop from '../../desktop';
afterEach(() => vi.restoreAllMocks());
@@ -38,3 +39,34 @@ describe('shareTrack', () => {
finally { vi.unstubAllGlobals(); }
});
});
describe('sharePlaylist', () => {
it('copies the shared page URL without exposing streaming links', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
try {
expect(await sharePlaylist('token', 'Mix')).toBe('copied');
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/shared/playlist/token`);
} finally { vi.unstubAllGlobals(); }
});
it('uses the configured music server when sharing from desktop', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.spyOn(desktop, 'desktopHandle').mockReturnValue({ server: 'https://music.example' } as desktop.DesktopHandle);
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
try {
await sharePlaylist('token', 'Mix');
expect(writeText).toHaveBeenCalledWith('https://music.example/shared/playlist/token');
} finally { vi.unstubAllGlobals(); }
});
it('opens the native share sheet on mobile browsers', async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { userAgent: 'Android', share });
try {
expect(await sharePlaylist('token', 'Mix')).toBe('shared');
expect(share).toHaveBeenCalledWith({ title: 'Mix', url: `${window.location.origin}/shared/playlist/token` });
} finally { vi.unstubAllGlobals(); }
});
});
+10 -2
View File
@@ -1,8 +1,16 @@
import { trackShareUrl } from '@pipu/core';
import { playlistShareUrl, trackShareUrl } from '@pipu/core';
import { desktopHandle } from '../../desktop';
/** 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);
return shareUrl(trackShareUrl(desktopHandle()?.server ?? window.location.origin, id), title);
}
export async function sharePlaylist(shareId: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
return shareUrl(playlistShareUrl(desktopHandle()?.server ?? window.location.origin, shareId), title);
}
async function shareUrl(url: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
try {
await navigator.share({ title, url });
+29 -5
View File
@@ -70,11 +70,13 @@ export function makeUser(username: string, role: Role, extra: Partial<FakeUser>
*/
export function fakeApi(initial: FakeUser[]) {
const users = new Map(initial.map((u) => [u.id, { ...u }]));
const onboarding = new Map<string, components['schemas']['OnboardingChoice'] | null>();
const prefs = new Map<string, components['schemas']['UiPrefs']>();
let current: string | null = null; // id вошедшего (одна сессия на тест)
let token = 0;
const calls: { method: string; path: string; body: unknown }[] = [];
const reactions = new Map<string, 'like' | 'dislike'>();
const playlistOwners = new Map<string, string>();
const playlists = new Map<string, components['schemas']['Playlist']>();
const me = (u: FakeUser): Me => ({
@@ -156,20 +158,37 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
if (sharedPath && req.method === 'GET') {
const item = [...playlists.values()].find(p => p.share_id === sharedPath[1]);
return item ? json({ ...item, share_id: null }) : problem(404, 'playlist.not_found');
}
const sharePath = /^\/api\/v1\/me\/playlists\/([^/]+)\/share$/.exec(path);
if (sharePath) {
const item = playlists.get(sharePath[1]!);
if (!item || playlistOwners.get(item.id) !== user.id) return problem(404, 'playlist.not_found');
if (req.method === 'POST') {
const share_id = item.share_id ?? `00000000-0000-4000-9001-${String(++seq).padStart(12, '0')}`;
playlists.set(item.id, { ...item, share_id }); return json({ share_id });
}
if (req.method === 'DELETE') { playlists.set(item.id, { ...item, share_id: null }); return new Response(null, { status: 204 }); }
}
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()].filter(p => !playlistOwners.has(p.id) || playlistOwners.get(p.id) === user.id) });
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 (playlistPath && (!old || (playlistOwners.has(id) && playlistOwners.get(id) !== user.id))) 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,
const item = { ...old, 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);
if (!old) playlistOwners.set(id, user.id);
return json(item, old ? 200 : 201);
}
@@ -206,7 +225,10 @@ export function fakeApi(initial: FakeUser[]) {
case 'POST /api/v1/uploads/seen':
return new Response(null, { status: 204 });
case 'GET /api/v1/onboarding':
return json({ required: false, artists: [], genres: [] });
return json({ required: onboarding.get(user!.id) === null, artists: [], genres: [], choice: onboarding.get(user!.id) ?? undefined });
case 'PUT /api/v1/me/onboarding':
onboarding.set(user!.id, body as components['schemas']['OnboardingChoice']);
return new Response(null, { status: 204 });
case 'GET /api/v1/me/likes':
case 'GET /api/v1/me/dislikes':
case 'GET /api/v1/me/history':
@@ -285,8 +307,10 @@ export function fakeApi(initial: FakeUser[]) {
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
}
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path))
if (req.method === 'POST' && /^\/api\/v1\/admin\/users\/[^/]+\/reset-recommendations$/.test(path)) {
onboarding.set(path.split('/')[5]!, null);
return new Response(null, { status: 204 });
}
const userPath = /^\/api\/v1\/admin\/users\/([^/]+)$/.exec(path);
if (userPath && req.method === 'PATCH') {
if (!user || user.role === 'user') return problem(403, 'auth.forbidden');
+5
View File
@@ -37,6 +37,7 @@ import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
type NavPath =
| '/'
@@ -49,6 +50,7 @@ type NavPath =
| '/uploads/mine'
| '/admin/settings'
| '/collection/playlists'
| '/collection/downloads'
| '/collection/history'
| '/collection/likes'
| '/collection/dislikes';
@@ -147,6 +149,7 @@ function NavSection({
export function AppShell() {
return useAuthState().status === 'signed_in' ? (
<PlayerProvider>
<DesktopUpdateDialog />
<Shell />
</PlayerProvider>
) : null;
@@ -174,6 +177,7 @@ function Shell() {
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
{desktop && <NavLink to="/collection/downloads" label={t('desktop.offline')} icon={downloadIcon} activeIcon={downloadIcon} />}
<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
@@ -298,6 +302,7 @@ function ShellMain() {
const key = useRouterState({ select: (st) => st.location.pathname });
return (
<main id="content" className={s.content} tabIndex={-1}>
<DesktopOfflineBanner />
<div key={key} className={s.routeIn}>
<Suspense fallback={<PageSkeleton />}>
<Outlet />
+6 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { wavePalette, WAVE_COLORS } from '@pipu/core';
import { useEffect, useMemo, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { drawLiquid, drawOrb, step, type WaveState } from './draw';
@@ -8,7 +9,7 @@ export interface WaveButtonProps {
readAudio?: () => { bass: number; mid: number; treble: number };
bpm?: number;
energy?: number;
colors?: [string, string, string];
colors?: readonly string[];
theme?: 'dark' | 'light';
interactive?: boolean;
size?: number;
@@ -17,8 +18,6 @@ export interface WaveButtonProps {
children?: ReactNode;
}
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
}
@@ -29,6 +28,7 @@ function prefersReducedMotion(): boolean {
*/
export function WaveButton(props: WaveButtonProps) {
const size = props.size ?? 320;
const colors = useMemo(() => wavePalette(props.colors), [props.colors]);
const audioRef = useRef(props.readAudio);
audioRef.current = props.readAudio;
const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -36,7 +36,7 @@ export function WaveButton(props: WaveButtonProps) {
const wrapRef = useRef<HTMLDivElement>(null);
const st = useRef<WaveState>({
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS,
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: WAVE_COLORS,
dark: true, reduced: false, size,
});
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
@@ -45,7 +45,7 @@ export function WaveButton(props: WaveButtonProps) {
s.playing = !!props.playing;
s.bpm = props.bpm ?? 100;
s.energy = props.energy ?? 0.6;
s.colors = props.colors ?? DEFAULT_COLORS;
s.colors = colors;
s.dark = props.theme !== 'light';
s.size = size;
+28 -10
View File
@@ -31,11 +31,11 @@
| Сервис | Язык | Ответственность |
|---|---|---|
| `nginx` | — | TLS, раздача SPA, reverse proxy на `api`, отдача аудиофайлов и обложек через `X-Accel-Redirect` (internal locations), gzip/brotli, кэш статики. |
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи (сканер, транскодинг) через очередь River (на Postgres). |
| `api` | Go | Весь публичный REST API: auth, пользователи, каталог, поиск (прокси к Meilisearch), коллекция, состояние плеера, события прослушиваний, админка, фоновые задачи: сканер внутри процесса, upload-воркеры со статусами в PostgreSQL. |
| `storage-agent` | Go | Единственный привилегированный контейнер: монтирует / размонтирует NFS-шары в `/library/<source_id>`, проверяет доступность, **записывает одобренные загрузки** в принимающие хранилища (только создание новых файлов). Принимает команды только от `api` по внутренней сети. |
| `recommender` | Python | Онлайн-сервис рекомендаций: генерация кандидатов, ранжирование, пачки для «Моей волны», обработка обратной связи в реальном времени; ночное обучение моделей (ALS, LightGBM). |
| `analyzer` | Python | Воркер аудиоанализа: Essentia — BPM, энергия, танцевальность, настроение, эмбеддинги. Тот же образ, что `recommender`, другая команда запуска. |
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, очередь задач River. |
| `postgres` | — | Основное хранилище, pgvector для эмбеддингов, состояния upload/analysis jobs. |
| `redis` | — | Rate limiting, кэш, Redis Streams для событий в `recommender`, сессионные векторы волны. |
| `meilisearch` | — | Полнотекстовый поиск с опечатками и автокомплитом. |
@@ -60,7 +60,7 @@
### 3.3 Сканирование библиотеки
1. Админ добавляет хранилище → `api` → `storage-agent` монтирует NFS.
2. `api` ставит задачу `scan_source` в River → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
2. `api` запускает `Scanner.Start` в процессе → обход файлов, чтение тегов (Go, `dhowden/tag` + `ffprobe`),
применение шаблонов путей, upsert в БД, индексирование в Meilisearch.
3. Для новых / изменённых треков создаётся задача анализа (таблица `analysis_jobs`) → `analyzer`.
@@ -94,11 +94,10 @@ pipu-music/
│ │ ├── cmd/api/ # main сервиса
│ │ ├── cmd/storage-agent/ # main агента хранилищ
│ │ ├── internal/
│ │ │ ├── auth/ users/ library/ catalog/ search/
│ │ │ ├── playback/ collection/ events/ wave/ admin/ uploads/
│ │ │ ├── auth/ library/ search/ genius/
│ │ │ ├── playback/ wave/ uploads/ appupdate/ system/
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
│ │ ├── migrations/ # goose SQL-миграции
│ │ └── queries/ # sqlc SQL
│ │ └── migrations/ # goose SQL-миграции; SQL-запросы — в адаптерах internal/
│ └── recommender/ # Python (uv)
│ ├── pipu_rec/
│ │ ├── api/ # FastAPI
@@ -120,16 +119,35 @@ pipu-music/
## 5. Архитектурный стиль
- **Модульный монолит** в `api`: каждый модуль в `internal/<module>` — `handler` (HTTP) → `service`
(бизнес-логика, интерфейсы) → `repository` (sqlc). Модули общаются через интерфейсы сервисов,
не лезут в чужие таблицы напрямую.
(операции приложения) → `store` (ручной SQL через pgx). Узкие порты отделяют изменяемые операции
от адаптеров; миграция существующих модулей постепенная. Read-проекции могут делать JOIN между областями.
- **Contract-first**: `api/openapi.yaml` — источник истины. Go-сервер генерирует типы и интерфейс
хендлеров (`oapi-codegen`, strict server), TS-клиент генерируется в `packages/api-client`.
Сгенерированный код совпадает с контрактом.
- **Внутренний API** `api ↔ recommender` и `api ↔ storage-agent` — HTTP/JSON во внутренней docker-сети,
описан отдельными `internal-*.yaml` спеками.
реализован Go/Python DTO и обработчиками; отдельных `internal-*.yaml` сейчас нет.
- **Идемпотентность**: события и мутирующие запросы клиентов принимают `Idempotency-Key` / `event_id` (UUIDv7),
что важно для офлайн-синхронизации мобильного.
- **Деградация**: если `recommender` недоступен, `api` отдаёт волну по упрощённому fallback-алгоритму
(похожие по жанрам + лайки + случайное), приложение продолжает работать.
- **Наблюдаемость**: структурированные JSON-логи (`slog`, `structlog`), `/healthz` и `/readyz` у каждого сервиса,
метрики Prometheus `/metrics` (опционально подключаются Grafana / Prometheus).
## 6. Реализация и планы
Точки изменения после выделения обязанностей:
- `apps/web/src/player/player.ts` — фасад воспроизведения; `engine.ts`, `analysis.ts`, `media.ts`, `persistence.ts` — браузерные адаптеры. Типы состояния — `types.ts`.
- `packages/core/src/wave.ts` — общая политика refill/feedback и поколения сессий. Web и Android сохраняют собственные очереди.
- `apps/web/src/features/uploads` — API, queries, экран очереди, диалоги и настройки. Страница очереди только собирает feature.
- `services/api/internal/library`: `sources.go`, `templates.go`, `scans.go`, `schedule.go` — отдельные сценарии; `Service` сохраняет фасад для HTTP и сборки приложения.
- `services/api/internal/uploads`: `placement.go` планирует размещение через узкие порты, `persistence.go` владеет SQL-операциями и транзакциями.
- `services/api/internal/playback/personalizer.go` персонализирует каталог независимо от HTTP; `events.go` принимает события в транзакции.
`pnpm check:boundaries` проверяет новые границы без сборки приложений. Его отдельный CI workflow
запускается при изменении соответствующих областей; read-проекции и composition root не запрещены.
River и sqlc не используются. Сканер и расписание живут в процессе API; upload workers читают
состояния PostgreSQL и пробуждаются каналами/таймерами. Анализатор использует `analysis_jobs`.
Добавление устойчивой очереди или генератора SQL — отдельное архитектурное решение при появлении
соответствующей потребности, не обязательная часть текущего рефакторинга.
+6
View File
@@ -45,6 +45,12 @@
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
Цвета обложки адаптируются в `wavePalette` (`packages/core/src/wave-anim.ts`) одинаково для веба и Android:
основой служит оттенок с наибольшей разницей RGB-каналов (> 0.08), акценты отклоняются на ±28°,
насыщенность ограничена 72–82%, светлота — 46–58%. Так тёмные цвета остаются видимыми,
а противоположные оттенки не обесцвечивают друг друга при наложении. Для почти бесцветных обложек
и отсутствующей или некорректной палитры используется стандартная палитра волны.
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
+29
View File
@@ -15,3 +15,32 @@ pnpm --filter @pipu/desktop bundle
На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer.
Релиз по тегу собирает Linux `.deb` и Windows NSIS через `cargo-xwin` (`pipu-music-<версия>-windows-setup.exe`). Подпись Windows-установщика на Linux нет. Страница `/download` на сервере отдаёт те файлы, что есть в последнем релизе Gitea.
## Офлайн и кеш
Коллекция → **Скачанное и кеш** доступна без сервера, в том числе после перезапуска.
Полностью загруженные прослушанные треки автоматически сохраняются в IndexedDB WebView;
кеш недавних ограничен 500 МБ, при заполнении удаляются самые старые записи.
Кнопка «Скачать» закрепляет трек: такие загрузки не вытесняются кешем и удаляются вручную.
Обложки сохраняются рядом с аудио; подписанные ссылки не сохраняются.
Хранилище и локальная очередь воспроизведения разделены по адресу сервера и пользователю.
Настройки показывают размер хранилища и позволяют очистить недавние треки, сохранив ручные загрузки.
Плеер предпочитает локальное аудио (включая crossfade); при запуске без сети восстанавливает
сессию и сохранённую очередь без автозапуска. Если сервер волны недоступен, можно запустить
перемешанные локальные треки без дизлайков. События прослушивания сохраняются локально
и досылаются с исходными идентификаторами при восстановлении связи.
## Обновления
При запуске и возврате в окно (не чаще раза в 6 часов), а также вручную в настройках,
нативный клиент читает `/api/v1/app/clients` через настроенный сервер Pipu.
Выбирается сборка текущей ОС, версия сравнивается с установленной с учётом `-rc.N`.
Диалог показывает пользовательские заметки без служебных блоков CI.
«Скачать и установить» загружает сборку в каталог кеша приложения, показывает прогресс,
проверяет размер и SHA-256 и только затем открывает установщик. Без контрольной суммы
обновление не предлагается. Windows запускает EXE/MSI; Linux открывает DEB/RPM через
системный обработчик пакетов; macOS открывает DMG. Установку подтверждает пользователь
в системном интерфейсе, после неё требуется перезапуск приложения. На Linux необходим
установленный обработчик пакетов для `xdg-open`; AppImage и `.app.tar` этим потоком не поддерживаются.
+27
View File
@@ -139,6 +139,33 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
Ожидаемое время первого скана 11k файлов по NFS — 5–15 минут (bottleneck — чтение тегов).
В редакторе хранилища поле **«Не сканировать папки»** задаёт до 100 исключений:
по одному пути относительно корня хранилища в строке, например `Downloads_PumpEars`
или `Artist/Samples`. Исключается вся ветка; соседняя `Downloads_PumpEars-copy`
не затрагивается. Пути чувствительны к регистру, шаблоны и абсолютные пути не используются.
Настройка хранится в `library_sources.excluded_dirs`; пустой список снимает исключения.
Во время активного скана менять список нельзя.
Исключённые ветки не обходятся; точечный скан такого пути отклоняется. Уже записанные
файлы остаются в каталоге и не помечаются пропавшими. Сразу после сохранения исключений
ошибки чтения и задачи аудиоанализа этих файлов перестают попадать в диагностику и повтор.
Анализатор не забирает их в работу; если у трека есть другая разрешённая копия, использует её.
После снятия исключений сохранённые задачи снова учитываются; неудавшиеся можно повторить.
Файлы на диске не изменяются.
Во время обхода админка показывает число посещённых папок (включая корень), найденных
аудиофайлов и текущую папку относительно корня хранилища. До завершения обхода общее
число файлов неизвестно, поэтому индикатор показывает активность без процента.
Затем отображаются этапы сопоставления, обработки файлов с известным общим числом
и завершения скана. API передаёт их в `ScanProgress.phase`, `walked_dirs`, `current_dir`.
Ошибки в итогах скана относятся только к этому запуску. Общий счётчик и список
ошибок чтения учитывают сохранённые ошибки только присутствующих файлов:
после успешного скана пропавшие файлы исключаются из них, сохраняя запись на срок
`MissingRetention`. В блоке аудиоанализа администратор может открыть «Показать файлы»:
окно загружает ошибки анализа страницами через `GET /api/v1/admin/library/analysis/errors`
и показывает трек, хранилище, относительный путь из текущего каталога и причину сбоя.
### Обложки
Источники по приоритету: встроенная в файл → `cover.jpg|folder.jpg|front.*` в папке альбома → плейсхолдер,
+1 -1
View File
@@ -109,7 +109,7 @@
кэширует ответ на 10 мин и APK на диске. Так:
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
- версия приложения согласована с версией сервера (`min_supported` из `server-info`).
2. Если `version_code` новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже».
2. Если `version_code` новее — диалог с changelog (текст из Markdown релиза без служебных CI-блоков сборок), кнопки «Обновить» / «Позже».
Если текущая версия ниже `min_supported` — обновление обязательно.
3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза).
Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`,
+21 -2
View File
@@ -1,6 +1,6 @@
# Личные плейлисты
Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
Плейлисты хранятся в PostgreSQL. Владелец управляет ими в вебе, desktop и React Native-клиенте; по желанию может поделиться ссылкой для прослушивания. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
@@ -11,12 +11,31 @@
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
- `DELETE /api/v1/me/playlists/{id}` — удаление.
- `POST /api/v1/me/playlists/{id}/share` — создать или вернуть действующий `share_id` (только владелец).
- `DELETE /api/v1/me/playlists/{id}/share` — отключить доступ по ссылке (только владелец).
- `GET /api/v1/shared/playlists/{shareId}` — прочитать плейлист по действующей ссылке; требуется вход на этот сервер.
Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками.
Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы.
Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
Изначально плейлисты личные. Кнопка «Поделиться плейлистом» включает доступ по ссылке
`/shared/playlist/{shareId}`. В вебе/desktop ссылка копируется, в мобильном браузере и Android открывается
системное меню «Поделиться». Desktop использует адрес музыкального сервера, а не локального WebView.
Как и ссылки на треки, ссылка требует входа на тот же сервер. После входа открывается запрошенный плейлист,
без автоматического запуска музыки. Получатель может слушать в обычном или случайном порядке и переслать
ту же ссылку, но не может редактировать или удалять исходный плейлист.
Ссылка содержит отдельный случайный UUID и не раскрывает токены авторизации или подписанные URL аудио.
Список `/me/playlists` и обычное чтение по UUID плейлиста остаются доступны только владельцу. Реакции
на треки и ссылки стрима в общем плейлисте принадлежат текущему слушателю. Данные общих страниц в веб-кэше
разделяются по пользователям.
«Отключить доступ по ссылке» отзывает прежний `share_id`; следующая публикация создаёт новый.
Повторное нажатие «Поделиться» без отзыва сохраняет ссылку. Ссылка показывает актуальный порядок и метаданные;
изменение доступа не меняет `revision` и не вызывает конфликт редактора. При удалении плейлиста или блокировке
владельца ссылка возвращает `404`. Миграция `00016_playlist_sharing.sql` добавляет `share_id` и уникальный индекс.
Совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
## Проверки
+21 -4
View File
@@ -219,10 +219,27 @@ MMR-разнообразие и квоты остаются. Пока модел
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` —
не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор —
пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1,
жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны.
- Онбординг: `GET /onboarding` возвращает 40 исполнителей, 24 жанра, сохранённый `choice` и `required`.
Анкета показывается на главной в вебе/desktop и Android до первого заполнения или пропуска, независимо
от истории и наличия библиотеки. Её можно повторно открыть в настройках профиля.
- `PUT /me/onboarding` сохраняет исполнителей, жанры, необязательный пол (`male`, `female`, `other`) и
возрастную группу (`under_13`, `13_19`, `20_29`, `30_39`, `40_49`, `50_59`, `60_plus`) в
`user_profiles_rec.onboarding`. Пустые списки и `null` для пола/возраста означают пропуск.
- Вместе с исходной возрастной группой сервер сохраняет год выбора (`age_group_year`); клиент не может
подменить его. SQL-функция `rec_age_group` прибавляет прошедшие годы к середине выбранного диапазона
(для открытых диапазонов: 6 и 65 лет). Например, 13–19 лет в 2006 → примерно 30–39 лет в 2026.
API возвращает текущую приблизительную группу. При сохранении той же текущей группы исходная группа
и год остаются прежними; выбор другой группы задаёт новую точку отсчёта. Это оценка, не дата рождения.
- Пока собственных оценок меньше 30, рекомендатель добавляет псевдооценки: исполнители +1, жанры +0.5,
а треки, понравившиеся минимум двум другим незаблокированным слушателям с тем же полом и текущей
возрастной группой, — до +0.25. Если указано одно поле, сравнивается только оно; если ни одного —
демографического сигнала нет. Личные оценки не перезаписываются, дизлайки исключаются.
На каждый источник действует лимит 300 треков. Музыкальные предпочтения пола/возраста заранее не задаются.
- Запасная Go-волна учитывает выбранных исполнителей/жанры и те же демографические когорты после личных
лайков и любимых исполнителей. Случайные треки сохраняют разнообразие.
- Администраторский сброс удаляет профиль целиком, включая все ответы и год выбора. Клиенты обновляют
состояние анкеты каждые 30 секунд, а веб также при возвращении на главную/в окно. Пользователь проходит
настройку снова; текущее воспроизведение не прерывается.
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
## 8. Обучение на всех пользователях
+184
View File
@@ -0,0 +1,184 @@
# Стоимость работы агентов и план SOLID-рефакторинга — 2026-10-22
## Вывод и границы исходного исследования
Проект уже имеет полезные границы: приложения разделены, есть общий `packages/core`, контракт OpenAPI, интерфейсы auth, отдельный Python-рекомендатель. Основная проблема — не количество технологий, а широкий контекст локальных изменений: несколько крупных оркестраторов смешивают правила, IO и состояние, а часть правил повторяется на клиентах.
Проверены структура tracked-файлов, ключевые Go/TS/Python-компоненты, тесты, CI, документация и два примера изменений из git. Это архитектурное исследование с фокусом на трудоёмкости доработок, а не полный аудит безопасности, производительности или визуального UI. Непрочитанные исходники не считаются проверенными. На этапе исходного исследования код приложения не изменялся и тесты не запускались. Реализация и проверки следующего этапа описаны в конце отчёта.
Фактические токены, стоимость и повторные попытки прошлых сессий не исследованы: в материалах проверки нет соответствующей телеметрии. Ниже — подтверждённые свойства репозитория и объяснение того, как они могут увеличивать работу агента. Процент экономии без замера не установлен.
## Prior Reviews Summary
В `docs/reviews/` найден один предыдущий отчёт: `2026-10-21-review-1.md`. Все 9 задач уже отмечены `[x]`, их повторно не открывали. Открытых задач для переноса: 0. Вновь подтверждённых исправлений ранее открытых задач: 0.
## Измерения
Снимок: HEAD `c84f90e`; локальные посторонние untracked-файлы не включены и не изменены.
- 419 tracked-файлов.
- 260 исходников с расширениями `.go`, `.ts`, `.tsx`, `.py`, `.rs`, исключая каталоги `gen`, `generated` и файлы `.gen.go`: 39 605 строк, включая тесты.
- Из этих исходников 24 файла длиннее 400 строк, 9 — длиннее 600. Длина — сигнал для изучения обязанностей, не самостоятельное нарушение SOLID.
- `services/api/internal/api/gen/api.gen.go`: 11 234 строки.
- `packages/api-client/src/generated/schema.ts`: 4 599 строк.
- `pnpm-lock.yaml`: 8 523 строки.
- Итого эти три файла: 24 356 строк. Их полный вывод в контекст обычно избыточен; это не оценка числа токенов.
- `api/openapi.yaml`: 3 717 строк. Контракт нужен, но обычно достаточно конкретной операции и связанных схем.
Воспроизведение: список из `git ls-files`, подсчёт через `[System.IO.File]::ReadAllLines`, отбор расширений и указанных generated-путей. Это физические строки, без семантического анализа. Наличие файла в git не означает, что агент автоматически загружает его целиком.
## Почему работа агента может быть дорогой
### 1. Недостаточно локальной навигации
В проверенном дереве не найдены `AGENTS.md`; корневой README даёт общие команды, `docs/README.md` — карту продуктовых документов. Нет краткой карты «задача → владельцы кода → инварианты → целевые проверки». Новая сессия вынуждена восстанавливать её поиском.
Нужна короткая корневая инструкция и локальные инструкции по областям, с минимальным пересечением. Большая энциклопедия в корневом AGENTS сама увеличит постоянный контекст. Исключения для generated-кода должны означать «читать точечно при необходимости», а не запрещать проверку контракта.
### 2. Документация описывает частично проектируемую архитектуру как существующую
`docs/architecture.md:34`, `:63`, `:101`, `:123`, `:129` обещают River, sqlc, общение модулей через сервисные интерфейсы и отдельные внутренние YAML-спецификации. В проверенном `services/api/go.mod` нет River/sqlc, сканирование запускает `Scanner.Start` (`library/service.go:614`), SQL находится прямо в store/service, в `api/` найден публичный `openapi.yaml`.
Агенту приходится перепроверять описание. Документ следует разделить на «реализовано» и «планируется», описать действующие очереди/воркеры и не внедрять River/sqlc только ради соответствия старому тексту.
### 3. Слишком широкий контекст плеера
`apps/web/src/player/player.ts:84` — 896 строк: очередь, волна, события, HTMLAudioElement, crossfade, preloading, Web Audio, Media Session, Tauri media, localStorage и серверное восстановление. Изменение одной обязанности требует проверки остальных.
`apps/mobile/src/player/controller.ts:78` — 457 строк с похожей оркестрацией, но другим движком, офлайн-хранилищем и фоновым жизненным циклом. Одинаковые `WAVE_REFILL_AT`, `EARLY_SKIP_MS`, накопление feedback и `refillWave` находятся в обоих клиентах (`web:255`, `mobile:375`). Правку правила нужно сопоставлять в двух реализациях.
### 4. Границы backend непоследовательны
- `uploads/service.go:31–52` зависит от конкретных Store, library.Service и Scanner; `:328` обращается к `s.store.pool.Exec`, `:387` принимает `pgx.Tx` в бизнес-операции.
- `library/service.go:49` объединяет источники, подключение, шаблоны, preview, сканирование, расписание и health-check; всего 829 строк.
- `playback/handler.go:37` объединяет стриминг, персонализацию, события, состояние, историю и реакции; всего 609 строк, использует конкретный `library.Store`.
- `cmd/api/main.go:157–160`: DTO ссылается на playback Handler, а Handler получает DTO через замыкание. Это усложняет сборку и изолированное тестирование. Это не цикл импортов Go.
- `genius/service.go:27` сам создаёт внешний клиент, а также работает с БД и запускает worker. Прямая зависимость от внешнего клиента затрудняет его замену в тесте.
Чтение чужих таблиц в поисковой проекции само по себе допустимо. Главный приоритет — изолировать бизнес-операции и записи; не вводить универсальные репозитории для всех JOIN.
### 5. Проверки не всегда соответствуют границам изменений
Уже есть полезные unit-, integration- и E2E-тесты; CI API и PostgreSQL integration разделены. Но `apps/mobile/test/screens.test.ts` проверяет offlineWave/releaseNeed, а не Controller. В проверенном наборе `apps/mobile/test` нет сценариев жизненного цикла Controller.
`apps/web/src/player/audio.test.ts:15` подменяет внутреннее состояние Player через `Object.assign`. Это конкретный признак неудобной тестовой границы: тесту нужен маленький анализатор, но приходится собирать большой плеер и менять его внутренности.
Без быстрых поведенческих проверок изменяемого модуля агенту требуется больше рассуждений и более широкий прогон. Наличие тестов не доказывает покрытие всех гонок.
### 6. Часть ширины изменений оправдана продуктом
Добавление Genius (`73ad61e`) затронуло 34 файла, включая миграцию, оба клиента, переводы и generated-код. Это сквозная возможность, и само число файлов не доказывает плохую архитектуру. Добавление текущего трека в плейлист (`fb20258`) затронуло 8 файлов, не потребовало переделки backend.
Уменьшать нужно непредвиденную связанность и повторное открытие одних и тех же файлов, сохраняя необходимые контрактные, платформенные и локализационные изменения.
## Phase 1: Code Quality / SOLID
### S — одна причина для изменения
Выделить у веб-плеера `AudioEngine`, `AudioAnalysis`, `PlaybackPersistence`, `MediaControls`. Player сохранить как фасад с текущим публичным API. Сначала вынести наиболее независимые обязанности с сохранением поведения; не переносить весь плеер за один PR.
Разделить library по сценариям: управление источниками, шаблоны/preview, запуск сканов, расписание/проверки доступности. Простое разнесение методов того же Service по файлам улучшит поиск, но не уменьшит связанность без разделения зависимостей.
### O — расширение через ограниченные точки замены
Вынести одинаковую политику волны в `packages/core` как `WaveSession`/`WaveCoordinator`: session generation, feedback, пороги refill и отбрасывание устаревших ответов. Сетевой API передавать через небольшой `WaveGateway`.
Web и Android применяют результат к собственным очередям. Native queue, offline playback и особенности ExoPlayer остаются в Android-адаптере; единый абстрактный движок для всех платформ заранее не нужен.
### L — заменяемость
Доказанных нарушений LSP в исследованной области не обнаружено. Для новых адаптеров проверять одинаковые обещания: порядок результатов, ошибки, отмену и отсутствие воздействия устаревшей сессии. Синхронный HTMLAudioElement и асинхронный native player нельзя объявлять взаимозаменяемыми без явного контракта.
### I — узкие интерфейсы
`auth/store.go:43` объединяет пользователей, refresh-токены, администрирование, audit и настройки; `WithTx` возвращает тот же широкий Store. Это кандидат на последующее разделение по потребителям, но auth уже имеет рабочий интерфейс и тестовый memstore — приоритет ниже плеера/uploads.
Новые границы создавать по реально используемым операциям, например `SourceLookup`, `ScanStarter`, `PlacementCatalog`. Не копировать все методы конкретного Store в интерфейс.
### D — зависимость от операций, а не инфраструктуры
В uploads вынести SQL и pgx-транзакции в persistence-адаптер; сервису дать операции `UpdateMetadata`/`Approve`/`Reject` с согласованными результатами. Сохранить атомарность смены статуса, квот и метаданных решения. Интеграционный тест реальной БД обязателен для изменений транзакций.
Выделить персонализацию каталога из HTTP Handler в самостоятельный объект; DTO и playback получают его при сборке. Конкретные зависимости в composition root (`main.go`, `server/router.go`) нормальны, массовое оборачивание всех конструкторов интерфейсами пользы не даст.
### Performance / Correctness
Новые подтверждённые runtime-дефекты и измеренные проблемы производительности этим исследованием не установлены. Размер SQL-запроса или исходника не считается доказательством медленной работы. Метрики качества рекомендаций и сетевого воспроизведения не измерялись.
## Phase 2: Logical Consistency
Целевой путь backend: HTTP handler → операция приложения → узкие порты → PostgreSQL/файлы/HTTP. Границы транзакций остаются явными. Межмодульные операции используют контракты потребителя; read-модели могут сохранять обоснованные JOIN.
Целевой путь клиента: UI → фасад плеера/feature → общие правила → платформенный адаптер. Для каждого состояния нужен один владелец. На Android владельцем воспроизводимой очереди остаётся native player; общий core не становится второй конкурирующей очередью.
Generated-типы оставить на HTTP-границах. Не создавать копии всех DTO ради формального разделения слоёв. Независимую модель вводить там, где правило использует небольшой стабильный набор полей.
## Phase 3: UI/UX
`apps/web/src/pages/admin/UploadQueuePage.tsx` содержит экран, строку очереди, пакетное редактирование, редактирование файла, отклонение и настройки загрузок (`:30`, `:205`, `:353`, `:422`, `:503`, `:542`), всего 640 строк. Настройки экспортируются из файла страницы очереди, сетевые вызовы находятся в компонентах.
Вынести в `features/uploads`: API/queries, экран очереди, отдельные диалоги, настройки. Странице оставить сборку. Сохранять ключи React Query и правила invalidation; единый большой `useUploadsEverything` лишь переместит проблему.
Новых визуальных и accessibility-дефектов не заявлено: браузерная проверка не проводилась. Закрытые задачи мобильного UI из прошлого ревью не дублировались.
## Порядок внедрения и условия готовности
1. **Навигация и достоверность.** Короткие инструкции по областям, актуальная архитектура, карта локальных проверок. Для типовой задачи понятны 2–5 начальных файлов и команды без чтения всех docs. Это ориентир, а не запрет на более широкий поиск.
2. **Упростить Player, сохранив фасад.** Сначала persistence/media/analysis; отдельно engine/crossfade. Проверки: restore без autoplay, ошибка preload и повтор Play, пауза/seek, очистка обработчиков. Существующие тесты сохранить; новые нужны для поведения на новой границе.
3. **Общая политика волны.** Сначала зафиксировать сценарии, затем подключать web и Android по отдельности. Проверки: поздний ответ после смены сессии, retry feedback, refresh очереди, ранний skip. На устройстве — background playback и offline→online.
4. **Uploads.** Сначала выделить persistence-операции с сохранением транзакций, затем placement и worker-порты. Проверки: конкурирующие решения, квота, повторный import, восстановление после рестарта, очистка staging; результат на реальной БД и файловые побочные эффекты.
5. **Library / playback / catalog.** Разделить use cases и убрать DTO→HTTP Handler из персонализации. Сохранить HTTP-контракт, подписи stream URL и поведение X-Accel. Проверять персонализацию батча, реакции, history/state и fallback wave.
6. **Локализация UI и закрепление границ.** Разделить uploads feature. Добавить автоматическую проверку запрещённых зависимостей с точными исключениями для composition root и проекций. Отложить дробление auth до изменения соответствующей функциональности.
Каждый шаг — отдельное ревьюируемое изменение. Сначала выделение границ без изменения поведения, затем новые возможности. Generated-файлы обновлять штатными генераторами; публичный OpenAPI не дробить до появления измеримой проблемы и готового процесса сборки спецификации.
## Как проверить экономию
Взять сравнимые задачи до/после: изменение порога волны на обоих клиентах, изменение правила размещения upload, добавление поля настроек загрузок. Не сравнивать эти разные задачи между собой; фиксировать модель, режим рассуждения и применённые навыки.
Для каждой записывать input/cached input/output tokens, если доступны; отдельно число прочитанных файлов, объём вывода инструментов, повторные чтения, итерации исправлений, длительность проверок и затронутые модули. Сопоставлять распределение нескольких задач, а не один удачный запуск.
Немедленные меры: `rg` по символам, чтение диапазонов, ограниченный вывод, исключение generated/lock из общего исследования, целевые проверки на промежуточных шагах и необходимые полные проверки перед сдачей. Крупные patch/diff тоже читать сначала без generated, затем отдельно проверять генерацию.
Выбор workflow влияет независимо от кода: полный аудит полезен для такого исследования, но запуск всех его фаз на маленькой правке добавляет работы. Большой каталог навыков, системные инструкции и история чата также могут занимать контекст; их вклад и кэширование не измерены и не устраняются рефакторингом приложения.
## Tasks
Перенесено: 0. Новых задач: 8. Порядок — ожидаемая польза для локальности изменений; сроки и проценты экономии не оценивались.
- [x] 1. [CodeQuality] Добавить короткую карту работы агентов: области, точки входа, источники истины и целевые проверки; новые локальные AGENTS держать компактными — `README.md` line 36.
- [x] 2. [Logic] Привести архитектуру к реализации, явно отделить планы River/sqlc/internal specs — `docs/architecture.md` line 123.
- [x] 3. [SOLID] Сохранить Player как фасад, выделить persistence/media/analysis/audio с независимыми зависимостями и проверками поведения — `apps/web/src/player/player.ts` line 84.
- [x] 4. [SOLID] Вынести общую политику wave/feedback/refill в core, сохранив платформенные очереди и офлайн-адаптацию; покрыть смену сессии и retry — `apps/web/src/player/player.ts` line 255; `apps/mobile/src/player/controller.ts` line 375.
- [x] 5. [SOLID] Убрать SQL/pgx из uploads use cases, выделить атомарные persistence-операции и узкие порты библиотеки/сканера, сохранить квоты и статусные переходы — `services/api/internal/uploads/service.go` line 328.
- [x] 6. [SOLID] Разделить управление источниками, шаблоны и расписание сканов по обязанностям и зависимостям — `services/api/internal/library/service.go` line 49.
- [x] 7. [SOLID] Выделить персонализацию из playback Handler, убрать циклическую сборку DTO через замыкание, постепенно отделить playback use cases от HTTP — `services/api/cmd/api/main.go` line 157; `services/api/internal/playback/handler.go` line 37.
- [x] 8. [CodeQuality] Вынести uploads API/queries, диалоги и настройки в feature-модули с локальными тестами, оставить странице композицию — `apps/web/src/pages/admin/UploadQueuePage.tsx` line 30.
## Исправления — 2026-10-22
Реализованы все 8 задач по коду; внешняя проверка PostgreSQL и физического Android остаётся отдельным условием валидации. Исходные метрики и ссылки выше описывают состояние до исправлений.
1. Добавлены компактные корневой и локальные `AGENTS.md`; README ссылается на карту работы. Общая инструкция не загружает всю документацию.
2. Архитектура описывает реальные pgx-запросы, in-process scanner и статусные upload jobs; River/sqlc/internal YAML отделены от реализации. Добавлена актуальная карта компонентов.
3. Player сохраняет прежний интерфейс, а аудиограф/элементы, анализ, Media Session/Tauri и сохранение выделены в `engine.ts`, `analysis.ts`, `media.ts`, `persistence.ts`. Типы состояния отделены. Обработчики аудио и media освобождаются, отложенное сохранение отменяется при dispose. Player: 896 → 617 строк; crossfade и оркестрация очереди остаются в фасаде.
4. `WaveCoordinator` в core обслуживает пороги, feedback и версии запросов на обеих платформах. Поздние ответы/ошибки не меняют новую сессию; смена контекста сохраняет ещё не отправленный feedback. На Android добавлены проверки Controller после reset и ручного выбора списка, native queue остаётся владельцем воспроизведения.
5. SQL и pgx убраны из uploads service/worker. `persistence.go` предоставляет атомарные операции изменения метаданных, решений и завершения импорта; транзакционный механизм Transition сохранён. Выделен PlacementPlanner и порты SourceCatalog/RootResolver/ImportedScanner. Guarded UPDATE теперь сообщает конфликт, если статус успел измениться. Добавлены integration-проверки rollback решения, конкурирующих отказов, quota и повторного завершения импорта; без PostgreSQL они не исполнялись.
6. Library Service стал фасадом над SourceService, TemplateService, ScanService и Scheduler. Зависимости разделены интерфейсами. Результат health-check передаётся через канал, исключая общую запись из timeout и worker веток.
7. Personalizer отделён от HTTP Handler, DTO собирается напрямую без замыкания. Приём событий выделен в EventService; остальные playback-сценарии остаются в существующем handler для постепенного дальнейшего разделения. Unit-тесты проверяют батч персонализации, недоступные треки и ошибки чтения.
8. Страница очереди стала оболочкой из 3 строк; feature содержит экран (181 строка), отдельные диалоги, строки списка, настройки, API и queries. Ключи запросов и invalidation сохранены. Локальные тесты проверяют сохранение метаданных и сохранность формы при конфликте.
Дополнительно: `pnpm check:boundaries` и отдельный CI workflow проверяют введённые границы без сборки всего проекта. Проверка целевая, не заменяет полноценный анализ графа импортов.
### Проверки
- Web typecheck и production build — успешно.
- Web unit/component suite — 51 тест успешно; в том числе restore без autoplay, ошибки preload и новые диалоги.
- Playwright — 10 сценариев успешно: desktop и mobile layouts, очередь/диалоги, навигация и плеер.
- Core — 39 тестов успешно, включая 4 сценария WaveCoordinator.
- Android typecheck — успешно; unit-тесты — 5 успешно, включая 2 сценария Controller с заглушкой native-плеера.
- `go test ./...` и `go vet ./...` — успешно. Новые unit-тесты library/playback проходят.
- Integration build — успешно; `TestMigrationsUpDownUp`, `TestLibraryAndUploads`, `TestPlaylistPersistenceAndIsolation`, `TestUserJourney` явно **SKIP**: не задан `TEST_DATABASE_URL`, Docker daemon недоступен. Результат не подтверждает транзакции на реальной БД.
- Физический Android/background/offline→online не проверялся.
- Публичный OpenAPI и generated-код не менялись. Экономия токенов после рефакторинга ещё не измерена.
+1
View File
@@ -6,6 +6,7 @@
"node": ">=22"
},
"scripts": {
"check:boundaries": "node scripts/check-boundaries.mjs",
"icons": "node scripts/generate-icons.mjs",
"build": "turbo run build",
"lint": "turbo run lint",
+199 -2
View File
@@ -860,6 +860,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/library/analysis/errors": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Неудавшиеся задачи аудиоанализа с путями файлов (admin) */
get: operations["listAnalysisErrors"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/library/host-dirs": {
parameters: {
query?: never;
@@ -1225,8 +1242,9 @@ export interface paths {
cookie?: never;
};
/**
* Онбординг вкуса — исполнители и жанры библиотеки для выбора
* @description `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
* Анкета рекомендаций — варианты, сохранённые ответы и необходимость заполнения
* @description `required` — пользователь не проходил и не пропускал анкету либо администратор сбросил рекомендации.
* `choice` содержит ответы с текущей приблизительной возрастной группой; исходная группа и год хранятся на сервере.
*/
get: operations["getOnboarding"];
put?: never;
@@ -1438,10 +1456,53 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/me/playlists/{id}/share": {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
get?: never;
put?: never;
/** Создать или вернуть ссылку только для чтения (только владелец) */
post: operations["sharePlaylist"];
/** Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку */
delete: operations["revokePlaylistShare"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/shared/playlists/{shareId}": {
parameters: {
query?: never;
header?: never;
path: {
shareId: string;
};
cookie?: never;
};
/** Прочитать плейлист по действующей ссылке (требуется вход) */
get: operations["getSharedPlaylist"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
schemas: {
PlaylistShare: {
/** Format: uuid */
share_id: string;
};
PlaylistInput: {
name: string;
description: string;
@@ -1465,6 +1526,11 @@ export interface components {
updated_at: string;
};
Playlist: components["schemas"]["PlaylistSummary"] & {
/**
* Format: uuid
* @description Действующая ссылка; возвращается только владельцу через /me/playlists
*/
readonly share_id?: string | null;
tracks: components["schemas"]["Track"][];
};
PlaylistList: {
@@ -1472,6 +1538,7 @@ export interface components {
};
Onboarding: {
required: boolean;
choice?: components["schemas"]["OnboardingChoice"];
artists: {
/** Format: uuid */
id: string;
@@ -1487,6 +1554,15 @@ export interface components {
}[];
};
OnboardingChoice: {
/** @enum {string|null} */
gender?: "male" | "female" | "other" | null;
/**
* @description Текущая приблизительная возрастная группа. Сервер хранит год выбора и учитывает прошедшие годы.
* @enum {string|null}
*/
age_group?: "under_13" | "13_19" | "20_29" | "30_39" | "40_49" | "50_59" | "60_plus" | null;
/** @description Год последнего выбора возрастной группы. */
readonly age_group_year?: number;
artist_ids: string[];
genres: string[];
};
@@ -1884,6 +1960,7 @@ export interface components {
/** @enum {string} */
TagPriority: "tags_first" | "path_first";
Source: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
/** Format: uuid */
id: string;
name: string;
@@ -1912,6 +1989,7 @@ export interface components {
items: components["schemas"]["Source"][];
};
SourceInput: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name: string;
type: components["schemas"]["SourceType"];
nfs_server?: string;
@@ -1924,6 +2002,7 @@ export interface components {
write_template?: string;
};
SourcePatch: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name?: string;
nfs_server?: string;
nfs_export?: string;
@@ -1942,6 +2021,8 @@ export interface components {
*/
write_template?: string;
};
/** @description Literal folder paths relative to the storage root, including all descendants. Case sensitive; no glob matching. Empty list clears exclusions. Existing library records are retained, but their scan and analysis are skipped. Changes require no running scan. */
ExcludedDirs: string[];
SourceTestResult: {
ok: boolean;
message: string;
@@ -2019,6 +2100,15 @@ export interface components {
full?: boolean;
};
ScanProgress: {
/**
* @description Current scan stage; progress percentage is available only during processing.
* @enum {string}
*/
phase?: "walking" | "preparing" | "processing" | "finalizing";
/** @description Directories visited so far, including the source root. */
walked_dirs?: number;
/** @description Current directory relative to the source root, using forward slashes. */
current_dir?: string;
running: boolean;
full: boolean;
/** Format: date-time */
@@ -2068,6 +2158,21 @@ export interface components {
items: components["schemas"]["LibraryError"][];
next_cursor: string | null;
};
AnalysisError: {
/** Format: uuid */
track_id: string;
title: string;
/** Format: uuid */
source_id: string | null;
source_name: string | null;
/** @description Path relative to the source root; primary file if available, otherwise a known file of this track. */
rel_path: string | null;
error: string;
};
AnalysisErrorPage: {
items: components["schemas"]["AnalysisError"][];
next_cursor: string | null;
};
HostDirList: {
path: string;
dirs: string[];
@@ -3701,6 +3806,31 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
listAnalysisErrors: {
parameters: {
query?: {
limit?: components["parameters"]["Limit"];
/** @description Непрозрачный курсор из `next_cursor` предыдущей страницы */
cursor?: components["parameters"]["Cursor"];
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Страница ошибок аудиоанализа, упорядоченная по ID трека */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AnalysisErrorPage"];
};
};
default: components["responses"]["Problem"];
};
};
listHostDirs: {
parameters: {
query?: {
@@ -4596,4 +4726,71 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
sharePlaylist: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlaylistShare"];
};
};
default: components["responses"]["Problem"];
};
};
revokePlaylistShare: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Доступ по прежней ссылке отключён */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getSharedPlaylist: {
parameters: {
query?: never;
header?: never;
path: {
shareId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Плейлист с реакциями и ссылками на стрим для текущего слушателя */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Playlist"];
};
};
default: components["responses"]["Problem"];
};
};
}
+6
View File
@@ -0,0 +1,6 @@
# Общие правила клиентов
- Очередь/учёт событий: `src/player.ts`; волна: `src/wave.ts`; сессия: `src/auth.ts`.
- Не импортируй web, React Native или системные media API. Платформы передают IO через узкие порты.
- Поздние ответы старой wave-сессии не меняют новую; feedback после ошибки возвращается только в ту же сессию.
- `pnpm --filter @pipu/core test`, `pnpm --filter @pipu/core typecheck`; после изменения экспортов проверь web и mobile.
+7 -2
View File
@@ -22,6 +22,11 @@ export {
type Track,
} from './player';
export { normalizeGain, TARGET_LUFS } from './gain';
export { trackShareUrl } from './share';
export { playlistShareUrl, trackShareUrl } from './share';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
export { AGE_GROUPS, GENDERS } from './onboarding';
export { releaseNotesText } from './releases';
+2
View File
@@ -0,0 +1,2 @@
export const AGE_GROUPS = ['under_13', '13_19', '20_29', '30_39', '40_49', '50_59', '60_plus'] as const;
export const GENDERS = ['male', 'female', 'other'] as const;
+12
View File
@@ -0,0 +1,12 @@
import { describe, expect, it } from 'vitest';
import { releaseNotesText } from './releases';
describe('release notes', () => {
it('removes build reports from all platforms, preserving real changes', () => {
expect(releaseNotesText('<!-- android-status -->\n> APK `file.apk` SHA-256: abc\n<!-- /android-status -->\n\n<!-- desktop-linux-status -->\n> package.deb\n<!-- /desktop-linux-status -->\n\n## Changes\n- **Offline** [playback](https://example.com)\n- Fix `player`')).toBe('Changes\n• Offline playback\n• Fix player');
});
it('hides comments and handles releases containing only build reports', () => {
expect(releaseNotesText('<!-- android-status -->\n> build\n<!-- /android-status -->')).toBe('');
expect(releaseNotesText('<!-- internal -->\n> Hello\n\n\nWorld')).toBe('Hello\n\nWorld');
});
});
+16
View File
@@ -0,0 +1,16 @@
/** Release bodies also contain CI build reports; these are not user-facing notes. */
export function releaseNotesText(markdown: string): string {
return markdown
.replace(/<!--\s*([\w-]+-status)\s*-->[\s\S]*?<!--\s*\/\1\s*-->/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/^\s*```[^\n]*\n[\s\S]*?^\s*```\s*$/gm, '')
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
.replace(/^\s*>\s?/gm, '')
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
.replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
.replace(/(`+)(.*?)\1/g, '$2')
.replace(/\*\*(.*?)\*\*|__(.*?)__/g, '$1$2')
.replace(/^\s*[-*+]\s+/gm, '• ')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
+6 -1
View File
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest';
import { trackShareUrl } from './share';
import { playlistShareUrl, 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');
});
});
it('builds a shared playlist URL from the server without stream credentials', () => {
expect(playlistShareUrl('https://music.example/api/v1', 'ab/cd')).toBe('https://music.example/shared/playlist/ab%2Fcd');
});
+5
View File
@@ -2,3 +2,8 @@
export function trackShareUrl(server: string, trackId: string): string {
return new URL(`/track/${encodeURIComponent(trackId)}`, server).href;
}
/** Revocable read-only playlist page; requires a listener account on this server. */
export function playlistShareUrl(server: string, shareId: string): string {
return new URL(`/shared/playlist/${encodeURIComponent(shareId)}`, server).href;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { rgb, wavePalette, WAVE_COLORS } from './wave-anim';
function hsl(color: string) {
const [r, g, b] = rgb(color).map((v) => v / 255) as [number, number, number];
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const c = max - min;
const l = (max + min) / 2;
const h = c === 0 ? 0 : max === r ? (g - b) / c : max === g ? (b - r) / c + 2 : (r - g) / c + 4;
return { h: (h * 60 + 360) % 360, s: c === 0 ? 0 : c / (1 - Math.abs(2 * l - 1)), l };
}
describe('wave cover palette', () => {
it.each([
undefined, [], ['#fff'], ['#000', '#888', '#fff'],
['#010203', '#fefdfc', '#808182'], ['#ff0000', 'invalid', '#0000ff'],
])('uses the default palette for missing, neutral or malformed colors: %j', (colors) => {
expect(wavePalette(colors)).toEqual(WAVE_COLORS);
});
it('ignores neutral colors and preserves the strongest cover hue', () => {
const colors = ['#ffffff', '#007733', '#222222'];
const before = [...colors];
expect(hsl(wavePalette(colors)[0]).h).toBeCloseTo(hsl('#007733').h, 0);
expect(wavePalette(colors)).toEqual(wavePalette(['#007733', '#222222', '#ffffff']));
expect(colors).toEqual(before);
expect(wavePalette(['#fff', '#073', '#222'])).toEqual(wavePalette(colors));
});
it.each([
['#040e08', '#18281d', '#374450'], // dark, muted cover like the reported case
['#ff0000', '#00ff00', '#0000ff'], // opposing hues must not mix to gray
['#ffff00', '#ffffff', '#000000'],
['#ff0080', '#540029', '#fefefe'], // hue offsets wrap around red
])('keeps a visible, saturated, harmonious palette for %j', (...colors) => {
const palette = wavePalette(colors);
const base = hsl(palette[0]);
for (const color of palette) {
expect(color).toMatch(/^#[\da-f]{6}$/i);
const { h, s, l } = hsl(color);
expect(s).toBeGreaterThanOrEqual(0.70);
expect(s).toBeLessThanOrEqual(0.84);
expect(l).toBeGreaterThanOrEqual(0.45);
expect(l).toBeLessThanOrEqual(0.59);
const distance = Math.abs(h - base.h);
expect(Math.min(distance, 360 - distance)).toBeLessThan(29);
}
});
});
+33
View File
@@ -30,6 +30,39 @@ export interface WaveState {
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
/** Keep the cover's strongest hue, but avoid muddy or complementary additive blends. */
export function wavePalette(colors?: readonly string[]): [string, string, string] {
if (colors?.length !== 3 || !colors.every((c) => /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(c))) {
return [...WAVE_COLORS];
}
let hue = 0;
let strongest = 0;
for (const color of colors) {
const [r, g, b] = rgb(color).map((v) => v / 255) as Rgb;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const chroma = max - min;
// Absolute chroma rejects both near-black noise and nearly white/gray covers.
if (chroma <= Math.max(0.08, strongest)) continue;
strongest = chroma;
hue = max === r ? ((g - b) / chroma) % 6 : max === g ? (b - r) / chroma + 2 : (r - g) / chroma + 4;
hue = (hue * 60 + 360) % 360;
}
if (!strongest) return [...WAVE_COLORS];
// A narrow hue range retains color when the translucent layers overlap.
return [paletteColor(hue, 0.78, 0.50), paletteColor(hue + 28, 0.72, 0.46), paletteColor(hue - 28, 0.82, 0.58)];
}
function paletteColor(hue: number, saturation: number, lightness: number): string {
const h = ((hue % 360) + 360) % 360 / 60;
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
const x = c * (1 - Math.abs(h % 2 - 1));
const m = lightness - c / 2;
const channels = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x]
: h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
return `#${channels.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;
}
export function initialWaveState(size: number): WaveState {
return {
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { WaveCoordinator, type WaveFeedback } from './wave';
function deferred<T>() {
let resolve!: (v: T) => void;
let reject!: (e: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
describe('wave coordination', () => {
it('preserves unsent feedback when changing context in the same session', async () => {
const next = vi.fn().mockResolvedValue(1);
const wave = new WaveCoordinator<number>({ next });
wave.activate('a');
wave.note('track', 'like');
wave.changeContext('a');
await wave.refill(0, true, () => {});
expect(next).toHaveBeenCalledWith('a', [{ track_id: 'track', outcome: 'like' }]);
});
it('ignores late results and allows the new session to refill immediately', async () => {
const old = deferred<number>();
const next = vi.fn().mockReturnValueOnce(old.promise).mockResolvedValueOnce(2);
const wave = new WaveCoordinator<number>({ next });
const apply = vi.fn();
wave.activate('old');
const first = wave.refill(0, false, apply);
wave.activate('new');
await wave.refill(0, false, apply);
old.resolve(1);
await first;
expect(apply.mock.calls.map(([value]) => value)).toEqual([2]);
});
it('retries feedback in order and respects threshold, refresh and single in-flight request', async () => {
const request = deferred<number>();
const next = vi.fn().mockReturnValueOnce(request.promise).mockResolvedValueOnce(2);
const wave = new WaveCoordinator<number>({ next });
const apply = vi.fn();
wave.activate('a');
wave.note('one', 'skip_early');
await wave.refill(3, false, apply);
expect(next).not.toHaveBeenCalled();
const first = wave.refill(3, true, apply);
wave.note('two', 'like');
await wave.refill(0, false, apply);
expect(next).toHaveBeenCalledTimes(1);
request.reject(new Error('offline'));
await first;
await wave.refill(2, false, apply);
expect(next.mock.calls[1]).toEqual(['a', [
{ track_id: 'one', outcome: 'skip_early' }, { track_id: 'two', outcome: 'like' },
] satisfies WaveFeedback[]]);
expect(apply).toHaveBeenCalledTimes(1);
});
it('does not restore old feedback or clear a newer in-flight request after a stale failure', async () => {
const old = deferred<number>();
const fresh = deferred<number>();
const next = vi.fn().mockReturnValueOnce(old.promise).mockReturnValueOnce(fresh.promise);
const wave = new WaveCoordinator<number>({ next });
wave.activate('a');
wave.note('old', 'dislike');
const first = wave.refill(0, false, () => {});
wave.activate('a'); // same session, new context
wave.note('new', 'like');
const second = wave.refill(0, false, () => {});
old.reject(new Error('late failure'));
await first;
await wave.refill(0, false, () => {});
expect(next).toHaveBeenCalledTimes(2);
expect(next.mock.calls[1]?.[1]).toEqual([{ track_id: 'new', outcome: 'like' }]);
fresh.resolve(2);
await second;
});
});
+62
View File
@@ -0,0 +1,62 @@
/** Policy shared by web and native players; queue ownership stays with the adapter. */
export const EARLY_SKIP_MS = 30_000;
export const WAVE_REFILL_AT = 2;
export type WaveOutcome = 'complete' | 'skip' | 'skip_early' | 'like' | 'dislike';
export interface WaveInfo {
sessionId: string;
activity: string | null;
mood: string | null;
fallback: boolean;
}
export interface WaveFeedback { track_id: string; outcome: WaveOutcome }
export interface WaveGateway<T> {
next(sessionId: string, feedback: WaveFeedback[]): Promise<T>;
}
export class WaveCoordinator<T> {
private sessionId: string | null = null;
private generation = 0;
private pending: WaveFeedback[] = [];
private loading: number | null = null;
constructor(private readonly gateway: WaveGateway<T>) {}
get version() { return this.generation; }
isCurrent(version: number) { return version === this.generation; }
/** Also invalidates in-flight responses when the context changes within a session. */
activate(sessionId: string | null) {
this.sessionId = sessionId;
this.pending = [];
this.loading = null;
return ++this.generation;
}
changeContext(sessionId: string) {
const pending = this.sessionId === sessionId ? this.pending : [];
const version = this.activate(sessionId);
this.pending = pending;
return version;
}
note(trackId: string, outcome: WaveOutcome) {
if (this.sessionId) this.pending.push({ track_id: trackId, outcome });
}
async refill(remaining: number, refresh: boolean, apply: (result: T, current: () => boolean) => void | Promise<void>) {
const id = this.sessionId;
const version = this.generation;
if (!id || this.loading === version || (!refresh && remaining > WAVE_REFILL_AT)) return;
this.loading = version;
const feedback = this.pending;
this.pending = [];
try {
const result = await this.gateway.next(id, feedback);
if (this.isCurrent(version)) await apply(result, () => this.isCurrent(version));
} catch {
if (this.isCurrent(version)) this.pending = [...feedback, ...this.pending];
} finally {
if (this.loading === version) this.loading = null;
}
}
}
+18 -2
View File
@@ -98,7 +98,15 @@
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
"scanning": "Scanning: {{processed}} of {{total}}",
"scan_walking": "Walking folders…",
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors {{errors}}",
"excluded_dirs": "Folders to exclude from scanning",
"excluded_hint": "One folder per line, relative to the storage root. Example: Downloads_PumpEars. All subfolders are excluded too. Paths are case sensitive; wildcards are not supported. Analysis and its errors are excluded as soon as you save. Existing catalog records are retained. An empty list enables scanning and analysis again.",
"excluded_running": "Wait for the current scan to finish before editing exclusions.",
"excluded_count": "Folders excluded from scanning: {{count}}",
"scan_walk_progress": "Walking folders… Directories checked: {{dirs}} · audio files found: {{found}}",
"scan_current_dir": "Current folder: {{path}}",
"scan_preparing": "Matching files with the library…",
"scan_finalizing": "Finishing scan…",
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors in this scan {{errors}}",
"scan_failed": "Scan stopped: {{error}}",
"test_ok": "Reachable, audio files found: {{count}}",
"test_fail": "Not reachable: {{message}}",
@@ -172,7 +180,15 @@
"wave_likes": "Likes per 100",
"wave_model": "Model version",
"wave_by_model": "Wave model versions compared",
"analysis_retry": "Retry"
"analysis_retry": "Retry",
"analysis_show_errors": "Show files",
"analysis_errors_title": "Audio analysis errors",
"analysis_errors_hint": "Each track shows its storage, path relative to the storage root and failure reason. Paths come from the current library catalog.",
"analysis_errors_empty": "There are no failed analysis jobs.",
"analysis_errors_more": "Show more",
"analysis_error_source": "Storage: {{name}}",
"analysis_error_no_file": "This track no longer has a file in the library.",
"analysis_error_unknown": "No error reason was recorded."
},
"write_template": {
"title": "Uploads and path template",
+3 -3
View File
@@ -51,9 +51,9 @@
"copy_server": "Copy the server address",
"step_updates": {
"android": "Sign in with your account. The app offers new versions by itself.",
"windows": "Sign in with your account. Get a new version from this page.",
"linux": "Sign in with your account. Get a new version from this page.",
"macos": "Sign in with your account. Get a new version from this page."
"windows": "Sign in with your account. The app will offer new versions.",
"linux": "Sign in with your account. The app will offer new versions through your system package installer.",
"macos": "Sign in with your account. The app will offer new versions."
},
"checksum": "SHA-256 checksum",
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
+45 -3
View File
@@ -1,11 +1,29 @@
{
"desktop": {
"offline": "Downloads and cache",
"save_track": "Download",
"remove_download": "Remove download",
"download_failed": "Could not download the track",
"storage_failed": "Could not open or change the cache. Check available disk space.",
"cache_hint": "Recent tracks are cached automatically (up to 500 MB). Manual downloads remain until removed. Available offline, including after restarting the app.",
"clear_recent": "Clear recent cache",
"offline_empty": "Played and downloaded tracks will appear here.",
"updates": "App updates",
"check_failed": "Could not check for updates. Try again later.",
"installer_open": "The installer is open. Complete installation and restart the app."
},
"genius": {
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.",
"loading": "Loading…", "disabled": "Not configured", "paused": "Paused", "cooldown": "Waiting after Genius rate limit", "running": "Processing", "waiting": "Waiting for new tracks",
"loading": "Loading…", "disabled": "Not configured", "paused": "Processing paused", "cooldown": "Waiting after Genius rate limit", "running": "Fetching data from Genius…", "queued": "Queue awaiting processing", "waiting": "Waiting for new tracks",
"progress": "Processed {{processed}} of {{total}} tracks",
"progress_label": "Genius processing progress",
"progress_hint": "Processed tracks include errors and unmatched tracks. Data refreshes every 5 seconds.",
"controls_hint": "The queue is processed automatically. Pausing saves progress; resuming continues with the remaining tracks.",
"retry_hint": "Retry queues only tracks with errors, incomplete data or no match, and resumes processing. Successfully processed tracks stay unchanged.",
"configure": "Add GENIUS_ACCESS_TOKEN to deploy/.env and recreate the API container.",
"total": "Total tracks", "pending": "Queued", "matched": "Matched on Genius", "lyrics": "Lyrics", "annotations": "With annotations", "images": "With artwork", "unmatched": "No exact match", "errors": "Errors / incomplete data",
"last_error": "Last error", "token_error": "Genius rejected the token. Check it and restart the API.", "access_error": "Genius restricted access. Processing is paused.", "no_lyrics": "Lyrics are currently unavailable.", "request_error": "Some data could not be retrieved. A retry is scheduled automatically.", "resume_at": "Retry no earlier than",
"start": "Resume", "pause": "Pause", "retry": "Retry errors and unmatched", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
"start": "Resume processing", "pause": "Pause processing", "retry": "Queue tracks needing attention", "pending_text": "This track is waiting for Genius processing.", "about": "About the song", "explanations": "Song annotations", "verified": "Verified on Genius"
},
"app": {
"name": "pipu",
@@ -318,9 +336,33 @@
"pick_more_one": "Pick {{count}} more artist",
"pick_more_few": "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",
"gender": "Gender",
"age_group": "Age group",
"unspecified": "Prefer not to say",
"edit": "Recommendation preferences",
"age_hint": "Your group will advance approximately from the year selected. You can correct it in your profile.",
"genders": {
"male": "Male",
"female": "Female",
"other": "Other"
},
"ages": {
"under_13": "До 13",
"13_19": "13–19",
"20_29": "20–29",
"30_39": "30–39",
"40_49": "40–49",
"50_59": "50–59",
"60_plus": "60+"
}
},
"playlists": {
"share": "Share playlist",
"link_copied": "Playlist link copied",
"shared": "Shared by link",
"revoke_share": "Disable link access",
"share_hint": "Anyone with an account on this server and this link can listen. Only you can edit the playlist.",
"name": "Name",
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
"manage": "Manage tracks",
+1
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Pattern error: {{reason}}",
"nfs_invalid": "Invalid value",
"local_path_invalid": "Folder not found",
"excluded_dirs_invalid": "Enter up to 100 folders: relative paths without .., one per line (up to 1000 characters).",
"name_invalid": "The name must be 1 to {{max}} characters",
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type",
+18 -2
View File
@@ -98,7 +98,15 @@
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
"scanning": "Сканирование: {{processed}} из {{total}}",
"scan_walking": "Обход папок…",
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок {{errors}}",
"excluded_dirs": "Не сканировать папки",
"excluded_hint": "По одной папке в строке, путь от корня хранилища. Например: Downloads_PumpEars. Все подпапки тоже исключаются. Учитывается регистр; шаблоны со звёздочкой не поддерживаются. Анализ и его ошибки отключаются сразу после сохранения. Уже добавленные записи сохраняются. Пустой список возвращает папки в сканирование и анализ.",
"excluded_running": "Дождитесь завершения сканирования, чтобы изменить исключения.",
"excluded_count": "Исключено папок из сканирования: {{count}}",
"scan_walk_progress": "Обход папок… Проверено папок: {{dirs}} · найдено аудиофайлов: {{found}}",
"scan_current_dir": "Текущая папка: {{path}}",
"scan_preparing": "Сопоставление файлов с библиотекой…",
"scan_finalizing": "Завершение сканирования…",
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок в этом скане {{errors}}",
"scan_failed": "Скан прервался: {{error}}",
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
"test_fail": "Нет доступа: {{message}}",
@@ -172,7 +180,15 @@
"wave_likes": "Лайков на 100",
"wave_model": "Версия модели",
"wave_by_model": "Сравнение версий модели волны",
"analysis_retry": "Повторить"
"analysis_retry": "Повторить",
"analysis_show_errors": "Показать файлы",
"analysis_errors_title": "Ошибки аудиоанализа",
"analysis_errors_hint": "Для каждого трека показаны хранилище, путь относительно его корня и причина сбоя. Пути берутся из текущего каталога библиотеки.",
"analysis_errors_empty": "Неудавшихся задач анализа нет.",
"analysis_errors_more": "Показать ещё",
"analysis_error_source": "Хранилище: {{name}}",
"analysis_error_no_file": "В библиотеке больше нет файла этого трека.",
"analysis_error_unknown": "Причина ошибки не указана."
},
"write_template": {
"title": "Приём загрузок и шаблон пути",
+3 -3
View File
@@ -51,9 +51,9 @@
"copy_server": "Скопировать адрес сервера",
"step_updates": {
"android": "Войдите своим логином. Новые версии приложение предложит установить само.",
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.",
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.",
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы."
"windows": "Войдите своим логином. Приложение предложит установить новые версии.",
"linux": "Войдите своим логином. Приложение предложит установить новые версии через системный установщик пакетов.",
"macos": "Войдите своим логином. Приложение предложит установить новые версии."
},
"checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
+45 -3
View File
@@ -1,11 +1,29 @@
{
"desktop": {
"offline": "Скачанное и кеш",
"save_track": "Скачать",
"remove_download": "Удалить загрузку",
"download_failed": "Не удалось скачать трек",
"storage_failed": "Не удалось открыть или изменить кеш. Проверьте свободное место.",
"cache_hint": "Недавние треки сохраняются автоматически (до 500 МБ). Скачанные вручную остаются до удаления. Доступны без сети, в том числе после перезапуска.",
"clear_recent": "Очистить кеш недавних",
"offline_empty": "Здесь появятся прослушанные и скачанные треки.",
"updates": "Обновления приложения",
"check_failed": "Не удалось проверить обновления. Повторите позже.",
"installer_open": "Установщик открыт. Завершите установку и перезапустите приложение."
},
"genius": {
"description": "Тексты, пояснения и недостающие картинки из Genius. Локальные обложки имеют приоритет.",
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Приостановлено", "cooldown": "Ожидание после ограничения Genius", "running": "Обработка", "waiting": "Ожидание новых треков",
"loading": "Загрузка…", "disabled": "Не подключён", "paused": "Обработка приостановлена", "cooldown": "Ожидание после ограничения Genius", "running": "Получение данных из Genius…", "queued": "Очередь ожидает обработки", "waiting": "Ожидание новых треков",
"progress": "Обработано {{processed}} из {{total}} треков",
"progress_label": "Прогресс обработки Genius",
"progress_hint": "В обработанные входят треки с ошибками и без совпадения. Данные обновляются каждые 5 секунд.",
"controls_hint": "Очередь обрабатывается автоматически. Пауза сохраняет прогресс; продолжение возобновляет обработку оставшихся треков.",
"retry_hint": "Повтор добавит в очередь только треки с ошибками, неполными данными и без совпадения, а также снимет паузу. Успешно обработанные треки останутся без изменений.",
"configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.",
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
"last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше",
"start": "Продолжить", "pause": "Приостановить", "retry": "Повторить ошибки и ненайденные", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius"
"start": "Продолжить обработку", "pause": "Приостановить обработку", "retry": "Добавить проблемные треки в очередь", "pending_text": "Трек ожидает обработки Genius.", "about": "О песне", "explanations": "Пояснения к песне", "verified": "Подтверждено в Genius"
},
"app": {
"name": "pipu",
@@ -318,9 +336,33 @@
"pick_more_one": "Выберите ещё {{count}} исполнителя",
"pick_more_few": "Выберите ещё {{count}} исполнителя",
"pick_more_many": "Выберите ещё {{count}} исполнителей",
"pick_more_other": "Выберите ещё {{count}} исполнителя"
"pick_more_other": "Выберите ещё {{count}} исполнителя",
"gender": "Пол",
"age_group": "Возрастная группа",
"unspecified": "Не указывать",
"edit": "Настроить рекомендации",
"age_hint": "Группа будет обновляться приблизительно с учётом года выбора. Её можно исправить в профиле.",
"genders": {
"male": "Мужской",
"female": "Женский",
"other": "Другой"
},
"ages": {
"under_13": "До 13",
"13_19": "13–19",
"20_29": "20–29",
"30_39": "30–39",
"40_49": "40–49",
"50_59": "50–59",
"60_plus": "60+"
}
},
"playlists": {
"share": "Поделиться плейлистом",
"link_copied": "Ссылка на плейлист скопирована",
"shared": "Доступ по ссылке",
"revoke_share": "Отключить доступ по ссылке",
"share_hint": "Любой пользователь этого сервера со ссылкой сможет слушать плейлист. Изменять его можете только вы.",
"name": "Название",
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
"manage": "Управлять треками",
+1
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Ошибка в шаблоне: {{reason}}",
"nfs_invalid": "Недопустимое значение",
"local_path_invalid": "Папка не найдена",
"excluded_dirs_invalid": "Укажите до 100 папок: относительные пути без .., по одной папке в строке (до 1000 символов).",
"name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища",
Loaded 100 of 141 files, more files were not shown because too many files have changed in this diff. Show more