Compare commits

...
6 Commits
Author SHA1 Message Date
mrleo1nid a29a8b18ad feat(admin): manage server updates and local configuration
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
2026-10-22 23:01:36 +03:00
mrleo1nid e33abc948c docs(agents): limit local checks to affected areas 2026-10-22 22:46:44 +03:00
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
98 changed files with 5830 additions and 471 deletions

No files matched your search

+2
View File
@@ -20,6 +20,8 @@ __pycache__/
.ruff_cache/
# env / secrets
deploy/config.local
deploy/.management.lock
.env
*.pem
*.keystore
+2 -2
View File
@@ -5,6 +5,6 @@
- Контракт 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`.
- Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в CI/CD; не запускай все проверки проекта перед каждой сдачей без необходимости. Расширяй локальную проверку при изменении общих контрактов, межмодульных зависимостей или при обнаружении ошибок. Команды запускаются из корня, если не сказано иначе.
- Справочник полных проверок (выбирай локально только относящиеся к изменению): `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`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+263
View File
@@ -22,6 +22,104 @@ tags:
security:
- bearerAuth: []
paths:
/api/v1/admin/server/updates:
get:
operationId: getServerUpdates
tags: [admin]
parameters:
- in: query
name: source_id
schema: {type: string}
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerUpdates"
default:
$ref: "#/components/responses/Problem"
post:
operationId: startServerUpdate
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerUpdateRequest"
responses:
"202":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerJob"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/update-sources:
put:
operationId: saveServerUpdateSources
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerSources"
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerSources"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/config:
get:
operationId: getServerConfig
tags: [admin]
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfig"
default:
$ref: "#/components/responses/Problem"
patch:
operationId: saveServerConfig
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfigPatch"
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfig"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/config/apply:
post:
operationId: applyServerConfig
tags: [admin]
responses:
"202":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerJob"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/genius:
get:
operationId: getGeniusStats
@@ -991,6 +1089,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
@@ -1815,6 +1930,85 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
ServerSource:
type: object
required: [id, name, url]
properties:
id: {type: string, maxLength: 64}
name: {type: string, maxLength: 100}
url: {type: string, maxLength: 2048}
ServerSources:
type: object
required: [sources]
properties:
sources:
type: array
maxItems: 20
items: {$ref: "#/components/schemas/ServerSource"}
ServerJob:
type: object
required: [id, kind, state, started_at, finished_at, target, log]
properties:
id: {type: string}
kind: {type: string, enum: [update, config]}
state: {type: string, enum: [running, succeeded, failed]}
started_at: {type: string}
finished_at: {type: string}
target: {type: string}
log: {type: string}
ServerUpdates:
type: object
required: [sources, versions, current_version, job, source_error]
properties:
sources:
type: array
items: {$ref: "#/components/schemas/ServerSource"}
versions:
type: array
items: {type: string}
current_version: {type: string}
source_error: {type: string}
job:
allOf: [{$ref: "#/components/schemas/ServerJob"}]
nullable: true
ServerUpdateRequest:
type: object
required: [source_id, channel]
properties:
source_id: {type: string}
channel: {type: string, enum: [latest, version, main]}
version: {type: string}
ServerConfigEntry:
type: object
required: [key, group, type, secret, readonly, effective, env, local, default_value, source]
properties:
key: {type: string}
group: {type: string}
type: {type: string}
secret: {type: boolean}
readonly: {type: boolean}
effective: {type: string}
env: {type: string, nullable: true}
local: {type: string, nullable: true}
default_value: {type: string}
source: {type: string, enum: [env, local, default]}
ServerConfig:
type: object
required: [entries, revision, restart_required]
properties:
revision: {type: string}
restart_required: {type: boolean}
entries:
type: array
items: {$ref: "#/components/schemas/ServerConfigEntry"}
ServerConfigPatch:
type: object
required: [revision, values]
properties:
revision: {type: string}
values:
type: object
additionalProperties: {type: string, nullable: true}
PlaylistShare:
type: object
required: [share_id]
@@ -2478,6 +2672,9 @@ components:
nullable: true
role:
$ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads:
type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
@@ -2533,6 +2730,9 @@ components:
nullable: true
role:
$ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads:
type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
@@ -2573,6 +2773,9 @@ components:
maxLength: 64
role:
$ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads:
type: boolean
description: Сразу выдать аудит загрузок. Может задать только админ
@@ -2599,6 +2802,9 @@ components:
$ref: "#/components/schemas/Role"
is_blocked:
type: boolean
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads:
type: boolean
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
@@ -2670,6 +2876,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
@@ -2730,6 +2938,8 @@ components:
type: object
required: [name, type]
properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name:
type: string
maxLength: 100
@@ -2757,6 +2967,8 @@ components:
SourcePatch:
type: object
properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name:
type: string
maxLength: 100
@@ -2785,6 +2997,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]
@@ -2955,6 +3175,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:
@@ -3056,6 +3286,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());
}
}
+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();
}
+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');
});
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин').fill(username);
await page.getByRole('textbox', { name: 'Пароль' }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти' }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('admin selects repository and version, confirms and sees persistent job log', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Последний релиз', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('button', { name: 'Источники обновлений', exact: true }).click();
await page.getByRole('button', { name: 'Добавить источник' }).click();
await page.getByLabel('Название', { exact: true }).last().fill('Тестовая репа');
await page.getByLabel('URL Git-репозитория').last().fill('https://git.example.org/pipu/testing.git');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByRole('dialog')).not.toBeVisible();
await page.getByLabel('Источник', { exact: true }).selectOption({ label: 'Тестовая репа' });
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
await page.getByRole('button', { name: 'Установить обновление', exact: true }).click();
const dialog = page.getByRole('dialog');
await expect(dialog).toContainText('Тестовая репа');
await expect(dialog).toContainText('v1.10.0');
await dialog.getByRole('button', { name: 'Установить обновление' }).click();
await expect(page.getByRole('status').filter({ hasText: 'Выполняется' })).toBeVisible();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeDisabled();
await page.screenshot({ path: 'test-results/server-updates.png', fullPage: true });
});
test('config keeps secret unchanged, saves local override and applies separately', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveValue('');
await page.getByLabel('LOG_LEVEL · config.local', { exact: true }).fill('debug');
await expect(page.getByText('В .env есть значение:', { exact: false })).toBeVisible();
await page.getByLabel('TRAIN_HOUR · config.local', { exact: true }).fill('6');
await expect(page.getByRole('button', { name: 'Применить и перезапустить', exact: true })).toBeDisabled();
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
await expect(page.getByLabel('GITEA_TOKEN · config.local', { exact: true })).toHaveAttribute('placeholder', 'Секрет сохранён');
await expect(page.getByText('Выполняется', { exact: true })).not.toBeVisible();
await page.getByRole('main').evaluate((element) => { element.scrollTop = 0; });
await page.screenshot({ path: 'test-results/server-config.png', fullPage: true });
await page.getByRole('button', { name: 'Применить и перезапустить', exact: true }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Применить и перезапустить' }).click();
await expect(page.getByLabel('Журнал выполнения')).toContainText('build and restart');
});
test('update delegate can update but cannot open configuration', async ({ page }) => {
await login(page, 'update-manager', '/admin/updates');
await expect(page.getByRole('heading', { name: 'Обновления', exact: true })).toBeVisible();
await page.evaluate(() => { history.pushState({}, '', '/admin/config'); window.dispatchEvent(new PopStateEvent('popstate')); });
await expect(page).toHaveURL('/');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).not.toBeVisible();
});
test('ordinary user is redirected away from server management', async ({ page }) => {
await login(page, 'alice', '/admin/updates');
await expect(page).toHaveURL('/');
});
+17
View File
@@ -9,6 +9,8 @@ import {
} from '@tanstack/react-router';
import { lazy } from 'react';
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
@@ -33,6 +35,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;
@@ -147,6 +150,9 @@ const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', compo
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 });
@@ -270,6 +276,14 @@ const adminSettingsRoute = createRoute({
component: AdminSettingsPage,
});
const serverUpdatesRoute = createRoute({
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
beforeLoad: ({ context }) => {
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
},
});
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
const routeTree = rootRoute.addChildren([
downloadAppRoute,
androidAppRoute,
@@ -281,6 +295,7 @@ const routeTree = rootRoute.addChildren([
homeRoute,
trackRoute,
playlistsRoute,
downloadsRoute,
sharedPlaylistRoute,
playlistRoute,
settingsRoute,
@@ -299,6 +314,8 @@ const routeTree = rootRoute.addChildren([
myUploadsRoute,
uploadQueueRoute,
adminSettingsRoute,
serverUpdatesRoute,
serverConfigRoute,
]),
]);
+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
@@ -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,84 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError, PageSkeleton } from '../../shared/ui/ui';
import { JobPanel } from './JobPanel';
import s from './management.module.css';
export function ConfigPage() {
const { t } = useTranslation('admin');
const session = useSession();
const errorText = useErrorText();
const qc = useQueryClient();
const [draft, setDraft] = useState<Record<string, string | null>>({});
const [baseRevision, setBaseRevision] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [group, setGroup] = useState('all');
const [confirm, setConfirm] = useState(false);
const [saved, setSaved] = useState(false);
const dirty = Object.keys(draft).length > 0;
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
const running = status.data?.job?.state === 'running';
useEffect(() => {
if (!dirty) return;
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
window.addEventListener('beforeunload', prevent);
return () => window.removeEventListener('beforeunload', prevent);
}, [dirty]);
useEffect(() => {
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
}, [status.data?.job?.id, status.data?.job?.state, qc]);
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
});
const apply = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
void qc.invalidateQueries({ queryKey: ['server-updates'] });
},
});
const change = (key: string, value: string | null) => {
setBaseRevision((v) => v ?? query.data!.revision);
setDraft((v) => ({ ...v, [key]: value })); setSaved(false); save.reset();
};
if (query.isPending) return <PageSkeleton />;
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
{query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <>
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
<p className={s.note}>{t('management.config_files_hint')}</p>
<div className={s.configList}>{entries.map((entry) => {
const changed = Object.hasOwn(draft, entry.key);
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
<div className={s.values}><span>{t('management.effective')}: <code>{entry.effective || '—'}</code></span><span>ENV: <code>{entry.env ?? '—'}</code></span></div>
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} value={value} disabled={running || save.isPending} placeholder={entry.secret && entry.local ? t('management.secret_saved') : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
</div>}
</section>;
})}</div>
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
<section className={s.saveBar}>
<div role="status">{saved ? t('management.saved') : dirty ? t('management.unsaved', { count: Object.keys(draft).length }) : query.data.restart_required ? t('management.pending_restart') : t('management.no_changes')}</div>
<div className={s.actions}><Button disabled={!dirty || save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={!dirty || running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button><Button disabled={dirty || running || !query.data.restart_required} busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button></div>
</section>
{save.error && <FormError>{errorText(save.error)}</FormError>}
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
<JobPanel job={status.data?.job} reconnecting={status.isError} />
</>}
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
</div>;
}
@@ -0,0 +1,20 @@
import type { components } from '@pipu/api-client';
import { useTranslation } from 'react-i18next';
import s from './management.module.css';
export type Job = components['schemas']['ServerJob'];
export function JobPanel({ job, reconnecting }: { job: Job | null | undefined; reconnecting?: boolean }) {
const { t } = useTranslation('admin');
if (!job) return null;
return <section className={s.card} aria-label={t('management.job')}>
<div className={s.cardHead}>
<div><span className={s.eyebrow}>{t('management.job')}</span><h2>{job.target}</h2></div>
<span className={s.status} data-state={job.state} role="status">{t(`management.state.${job.state}`)}</span>
</div>
<p className={s.note}>{t('management.started')}: {new Date(job.started_at).toLocaleString()}
{job.finished_at && <> · {t('management.finished')}: {new Date(job.finished_at).toLocaleString()}</>}</p>
{job.state === 'running' && <p className={s.note}>{t(reconnecting ? 'management.reconnecting' : 'management.running_hint')}</p>}
<pre className={s.log} tabIndex={0} aria-label={t('management.log')}>{job.log || t('management.waiting')}</pre>
</section>;
}
@@ -0,0 +1,92 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
import { JobPanel } from './JobPanel';
import s from './management.module.css';
type Source = components['schemas']['ServerSource'];
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
export function UpdatesPage() {
const { t } = useTranslation('admin');
const session = useSession();
const qc = useQueryClient();
const errorText = useErrorText();
const [sourceId, setSourceId] = useState('');
const [channel, setChannel] = useState<Channel>('latest');
const [version, setVersion] = useState('');
const [confirm, setConfirm] = useState(false);
const [editing, setEditing] = useState<Source[] | null>(null);
const query = useQuery({
queryKey: ['server-updates', sourceId],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates', {
params: { query: { source_id: sourceId || undefined } },
})),
refetchInterval: 3000,
retry: false,
});
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
const running = query.data?.job?.state === 'running';
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
const save = useMutation({
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
});
const start = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined },
})),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', sourceId], (old) => old ? { ...old, job } : old);
void refresh();
},
});
if (query.isPending) return <PageSkeleton />;
return <div className={s.page}>
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
<Button onClick={() => void query.refetch()} busy={query.isFetching}>{t('management.refresh')}</Button>
</header>
{query.error && <FormError>{errorText(query.error)}</FormError>}
{query.data && <>
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
<section className={s.card}>
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
<label className={s.selectLabel}>{t('management.source')}
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
</select>
</label>
<p className={s.url}>{source?.url}</p>
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
{start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.actions}><Button variant="primary" disabled={!source || running || !!query.data.source_error || (channel === 'version' && !version)} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button></div>
</section>
<JobPanel job={query.data.job} reconnecting={query.isError} />
</>}
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
<p>{t('management.confirm_update_text', { source: source?.name, target: channel === 'version' ? version : t(`management.channel_${channel}`) })}</p>
<p className={s.note}>{t('management.update_warning')}</p>
{start.error && <FormError>{errorText(start.error)}</FormError>}
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
</Dialog>}
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
<p className={s.note}>{t('management.sources_hint')}</p>
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
</div>)}</div>
{save.error && <FormError>{errorText(save.error)}</FormError>}
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
</Dialog>}
</div>;
}
@@ -0,0 +1,34 @@
.page { max-width: 1050px; margin: 0 auto; display: flex; flex-direction: column; gap: 24px; }
.header, .cardHead { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; }
.header h1 { margin: 6px 0; font-family: var(--font-display); font-size: 30px; }
.header p, .note { color: var(--text2); font-size: 13px; line-height: 1.6; margin: 0; }
.eyebrow { font-size: 11px; text-transform: uppercase; letter-spacing: .12em; color: var(--text2); font-weight: 600; }
.hero { display: flex; gap: 24px; align-items: center; justify-content: space-between; padding: 28px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: linear-gradient(120deg, var(--accent-soft), var(--surface-a)); }
.hero strong { display: block; font-family: var(--font-display); font-size: 30px; margin-top: 8px; overflow-wrap: anywhere; }
.hero strong.priority { font-size: 19px; }
.hero p { color: var(--text2); max-width: 340px; line-height: 1.6; font-size: 13px; }
.card { padding: 24px; background: var(--surface-a); border: 1px solid var(--line); border-radius: var(--radius-lg); display: flex; flex-direction: column; gap: 18px; }
.card h2 { margin: 0; font-size: 18px; }
.selectLabel { display: flex; flex-direction: column; gap: 8px; font-size: 13px; font-weight: 500; }
.selectLabel select { height: 42px; padding: 0 12px; font: inherit; color: var(--text); border: 1px solid var(--line); border-radius: 10px; background: var(--surface2); max-width: 100%; }
.selectLabel select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.url { color: var(--text2); font: 12px monospace; overflow-wrap: anywhere; margin: -8px 0 0; }
.actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.status, .tag { padding: 5px 10px; border: 1px solid var(--line); border-radius: 99px; color: var(--text2); font-size: 12px; }
.status[data-state='running'] { background: var(--accent-soft); color: var(--accent); }
.status[data-state='succeeded'] { color: var(--ok); }
.status[data-state='failed'] { color: var(--danger); }
.log { margin: 0; padding: 18px; border-radius: 12px; background: var(--surface2); border: 1px solid var(--line); max-height: 380px; min-height: 100px; overflow: auto; font-size: 12px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; }
.sourceList { display: flex; flex-direction: column; gap: 16px; max-height: 50vh; overflow: auto; margin-bottom: 18px; }
.sourceEditor { display: flex; flex-direction: column; gap: 12px; padding: 14px; border: 1px solid var(--line); border-radius: 12px; }
.toolbar { display: grid; grid-template-columns: 1fr 220px; gap: 16px; align-items: end; }
.configList { display: flex; flex-direction: column; gap: 12px; }
.configRow { padding: 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-a); display: flex; flex-direction: column; gap: 14px; }
.configRow[data-changed] { border-color: var(--accent); }
.configMeta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.configMeta > code { font-size: 13px; font-weight: 600; overflow-wrap: anywhere; }
.values { display: flex; flex-wrap: wrap; gap: 12px 24px; color: var(--text2); font-size: 12px; }
.values code { overflow-wrap: anywhere; }
.editor { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 12px; }
.saveBar { position: sticky; bottom: 12px; z-index: 2; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface2); box-shadow: 0 8px 32px #0003; font-size: 13px; }
@media (max-width: 650px) { .hero { flex-direction: column; align-items: flex-start; gap: 10px; padding: 20px; } .hero p { max-width: none; } .toolbar { grid-template-columns: 1fr; } .editor { grid-template-columns: 1fr; } .card, .configRow { padding: 16px; } .actions { justify-content: flex-start; } }
+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
View File
@@ -13,6 +13,7 @@ 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']>;
@@ -27,6 +28,8 @@ export function SettingsPage() {
<ProfileSection />
<RecommendationSettings />
<AppearanceSection />
<DesktopOfflineSection />
<DesktopUpdateButton />
<PasswordSection />
<SessionsSection />
</div>
@@ -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>;
}
+15 -5
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 && (
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
<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
+6
View File
@@ -140,6 +140,11 @@ export function UsersPage() {
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
});
if (me.role === 'admin' && u.role !== 'admin')
items.push({
label: t(u.manages_updates ? 'users.revoke_updates' : 'users.grant_updates'),
onSelect: () => patch.mutate({ id: u.id, body: { manages_updates: !u.manages_updates } }),
});
if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items;
@@ -214,6 +219,7 @@ export function UsersPage() {
</td>
<td data-label={t('users.col_role')}>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
{u.manages_updates && <span className={cx(s.badge, s.role_moderator)}>{t('users.manage_updates')}</span>}
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td>
<td data-label={t('users.col_status')}>
@@ -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; }
+4 -2
View File
@@ -1,5 +1,6 @@
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). */
@@ -30,6 +31,7 @@ function deviceId(): string {
}
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) {}
@@ -50,7 +52,7 @@ export class PlaybackPersistence {
saveLocal() {
if (!this.getState().track) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState())));
localStorage.setItem(this.localKey, JSON.stringify(this.snapshot(this.getState())));
} catch {
// недоступное хранилище не мешает воспроизведению
}
@@ -91,7 +93,7 @@ export class PlaybackPersistence {
async restore(): Promise<Saved | null> {
let local: Saved | null = null;
try {
const raw = localStorage.getItem(LOCAL_KEY);
const raw = localStorage.getItem(this.localKey);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
+57 -10
View File
@@ -18,6 +18,7 @@ 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 };
@@ -34,6 +35,8 @@ const PRELOAD_BEFORE_MS = 20_000;
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
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; }
@@ -65,8 +68,16 @@ export class Player {
audio?: HTMLAudioElement,
) {
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 } }));
},
});
@@ -82,9 +93,16 @@ export class Player {
/** Запуск фоновой отправки событий (парный вызов — 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) ---
subscribe = (fn: () => void) => {
@@ -137,11 +155,20 @@ export class Player {
this.finishAttempt('skip');
this.leaveWave();
const version = this.wavePolicy.version;
const res = unwrap(
await this.session.client.POST('/api/v1/wave/sessions', {
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) {
@@ -339,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.media.updateMediaMetadata(track);
if (!autoplay) return;
void this.offline?.save(track).catch(() => undefined);
this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null;
@@ -425,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;
@@ -458,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;
@@ -471,6 +508,7 @@ export class Player {
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.media.updateMediaMetadata(next);
void this.offline?.save(next).catch(() => undefined);
void this.audio.play().finally(() => {
this.fading = false;
this.ignoreEnded = false;
@@ -560,7 +598,7 @@ 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);
void fetch(this.offline?.local(next).stream_url ?? next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
}
// --- сохранение состояния ---
@@ -583,10 +621,18 @@ export class Player {
const version = ++this.restoreVersion;
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;
@@ -608,6 +654,7 @@ export class Player {
this.fading = false;
this.finishAttempt('skip');
this.engine.dispose();
this.offline?.dispose();
this.persistence.dispose();
for (const t of this.timers) clearInterval(t);
this.timers = [];
+1
View File
@@ -6,6 +6,7 @@ export function installE2EFetch() {
const api = fakeApi([
makeUser('admin', 'admin', { must_change_credentials: true }),
makeUser('alice', 'user'),
makeUser('update-manager', 'user', { manages_updates: true }),
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
makeUser('moderator', 'moderator', { reviews_uploads: true }),
]);
+38
View File
@@ -79,12 +79,27 @@ export function fakeApi(initial: FakeUser[]) {
const playlistOwners = new Map<string, string>();
const playlists = new Map<string, components['schemas']['Playlist']>();
let managementSources = [{ id: 'origin', name: 'Текущий репозиторий', url: 'https://git.example.org/pipu/music.git' }];
let managementJob: components['schemas']['ServerJob'] | null = null;
let configRevision = 1;
let configPending = false;
const configValues: Record<string, string> = {};
const configEntries: components['schemas']['ServerConfigEntry'][] = [
{ key: 'LOG_LEVEL', group: 'shared', type: 'string', secret: false, readonly: false, effective: 'info', env: 'info', local: null, default_value: 'info', source: 'env' },
{ key: 'TRAIN_HOUR', group: 'recommender', type: 'integer', secret: false, readonly: false, effective: '3', env: null, local: null, default_value: '3', source: 'default' },
{ key: 'GITEA_TOKEN', group: 'api', type: 'string', secret: true, readonly: false, effective: '••••••••', env: null, local: '••••••••', default_value: '', source: 'local' },
];
const serverConfig = () => ({ revision: String(configRevision), restart_required: configPending, entries: configEntries.map((entry) => {
const local = configValues[entry.key] ?? entry.local;
return { ...entry, local, effective: entry.env ?? local ?? entry.default_value, source: entry.env !== null ? 'env' : local !== null ? 'local' : 'default' };
}) });
const me = (u: FakeUser): Me => ({
id: u.id,
username: u.username,
display_name: u.display_name,
role: u.role,
reviews_uploads: u.reviews_uploads,
manages_updates: u.role === 'admin' || !!u.manages_updates,
locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password,
@@ -107,6 +122,25 @@ export function fakeApi(initial: FakeUser[]) {
calls.push({ method: req.method, path, body });
const route = `${req.method} ${path}`;
const user = current ? users.get(current) : undefined;
if (path.startsWith('/api/v1/admin/server/')) {
if (!user) return problem(401, 'auth.unauthorized');
const configs = path.includes('/config');
if (configs ? user.role !== 'admin' : user.role !== 'admin' && !user.manages_updates) return problem(403, 'auth.forbidden');
if (route === 'GET /api/v1/admin/server/updates') return json({ sources: managementSources, versions: ['v1.10.0', 'v1.9.0', 'v2.0.0-rc.1'], current_version: '1.9.0', source_error: '', job: managementJob });
if (route === 'PUT /api/v1/admin/server/update-sources') { managementSources = body?.sources as typeof managementSources; return json({ sources: managementSources }); }
if (route === 'GET /api/v1/admin/server/config') return json(serverConfig());
if (route === 'PATCH /api/v1/admin/server/config') {
if (body?.revision !== String(configRevision)) return problem(409, 'management.config_conflict');
for (const [key, value] of Object.entries(body?.values as Record<string, string | null>)) {
if (value === null) delete configValues[key]; else configValues[key] = value;
}
configRevision++; configPending = true; return json(serverConfig());
}
if (route === 'POST /api/v1/admin/server/updates' || route === 'POST /api/v1/admin/server/config/apply') {
managementJob = { id: 'job-1', kind: configs ? 'config' : 'update', state: 'running', target: configs ? 'config.local' : body?.channel === 'version' ? String(body.version) : body?.channel === 'main' ? 'main' : 'v1.10.0', started_at: '2026-10-22T09:00:00Z', finished_at: '', log: '→ build and restart' };
return json(managementJob, 202);
}
}
switch (route) {
case 'GET /api/v1/server-info':
@@ -323,6 +357,10 @@ export function fakeApi(initial: FakeUser[]) {
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
target.username = username;
}
if (typeof body?.manages_updates === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.manages_updates = body.manages_updates;
}
if (typeof body?.reviews_uploads === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.reviews_uploads = body.reviews_uploads;
+10
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 =
| '/'
@@ -47,8 +48,11 @@ type NavPath =
| '/admin/uploads'
| '/uploads/new'
| '/uploads/mine'
| '/admin/updates'
| '/admin/config'
| '/admin/settings'
| '/collection/playlists'
| '/collection/downloads'
| '/collection/history'
| '/collection/likes'
| '/collection/dislikes';
@@ -147,6 +151,7 @@ function NavSection({
export function AppShell() {
return useAuthState().status === 'signed_in' ? (
<PlayerProvider>
<DesktopUpdateDialog />
<Shell />
</PlayerProvider>
) : null;
@@ -174,6 +179,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
@@ -193,6 +199,7 @@ function Shell() {
badge={pending}
/>
)}
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
@@ -211,8 +218,10 @@ function Shell() {
badge={pending}
/>
)}
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{user.role === 'admin' && (
<>
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
@@ -298,6 +307,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 />
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
# Share a lock with manual deployments and config application.
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
exec 9<.management.lock
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
touch config.local
compose() { docker compose --env-file config.local --env-file .env "$@"; }
# Validate before stopping any container.
compose config --quiet
compose up -d --force-recreate --remove-orphans
echo '→ ждём готовности api'
for _ in $(seq 1 90); do
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
echo 'Конфигурация применена; API готов'
exit 0
fi
sleep 2
done
echo 'API не стал готов: проверьте docker compose logs api' >&2
exit 1
+12 -1
View File
@@ -55,7 +55,7 @@ services:
dockerfile: services/api/Dockerfile
target: api
args: *version
env_file: .env
env_file: [config.local, .env]
environment: &db-env
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
volumes:
@@ -63,6 +63,7 @@ services:
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
- ${PIPU_DATA:-/srv/pipu}/management:/management
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
depends_on:
postgres: { condition: service_healthy }
@@ -112,6 +113,11 @@ services:
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
LOG_LEVEL: ${LOG_LEVEL:-info}
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
TRAIN_HOUR: ${TRAIN_HOUR:-3}
LIBRARY_ROOT: /library
LIBRARY_HOST_ROOT: /library-host
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
depends_on:
postgres: { condition: service_healthy }
@@ -124,6 +130,11 @@ services:
<<: *db-env
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
LOG_LEVEL: ${LOG_LEVEL:-info}
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
TRAIN_HOUR: ${TRAIN_HOUR:-3}
LIBRARY_ROOT: /library
LIBRARY_HOST_ROOT: /library-host
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
volumes:
- *library-ro
- *library-host-ro
+418
View File
@@ -0,0 +1,418 @@
[
{
"key": "MANAGEMENT_DIR",
"group": "api",
"type": "string",
"default_value": "/management",
"secret": false,
"readonly": true
},
{
"key": "HTTP_ADDR",
"group": "api",
"type": "string",
"default_value": ":8080",
"secret": false,
"readonly": false
},
{
"key": "DATABASE_URL",
"group": "api",
"type": "string",
"default_value": "Compose: POSTGRES_PASSWORD → postgres:5432/pipu",
"secret": true,
"readonly": true
},
{
"key": "REDIS_URL",
"group": "shared",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "LOG_LEVEL",
"group": "shared",
"type": "string",
"default_value": "info",
"secret": false,
"readonly": false
},
{
"key": "MIGRATE_ON_START",
"group": "api",
"type": "boolean",
"default_value": "true",
"secret": false,
"readonly": false
},
{
"key": "SHUTDOWN_TIMEOUT",
"group": "api",
"type": "duration",
"default_value": "15s",
"secret": false,
"readonly": false
},
{
"key": "SUPPORTED_LOCALES",
"group": "api",
"type": "string",
"default_value": "ru,en",
"secret": false,
"readonly": false
},
{
"key": "MIN_ANDROID_VERSION_CODE",
"group": "api",
"type": "integer",
"default_value": "0",
"secret": false,
"readonly": false
},
{
"key": "JWT_PRIVATE_KEY",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "JWT_PREVIOUS_KEYS",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "ACCESS_TOKEN_TTL",
"group": "api",
"type": "duration",
"default_value": "15m",
"secret": true,
"readonly": false
},
{
"key": "COOKIE_SECURE",
"group": "api",
"type": "boolean",
"default_value": "true",
"secret": false,
"readonly": false
},
{
"key": "BOOTSTRAP_ADMIN_USERNAME",
"group": "api",
"type": "string",
"default_value": "admin",
"secret": false,
"readonly": false
},
{
"key": "BOOTSTRAP_ADMIN_PASSWORD",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "LIBRARY_ROOT",
"group": "api",
"type": "string",
"default_value": "/library",
"secret": false,
"readonly": true
},
{
"key": "LIBRARY_HOST_ROOT",
"group": "api",
"type": "string",
"default_value": "/library-host",
"secret": false,
"readonly": true
},
{
"key": "STORAGE_AGENT_URL",
"group": "api",
"type": "string",
"default_value": "http://storage-agent:8090",
"secret": false,
"readonly": true
},
{
"key": "INTERNAL_TOKEN",
"group": "shared",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "FFPROBE_PATH",
"group": "api",
"type": "string",
"default_value": "ffprobe",
"secret": false,
"readonly": false
},
{
"key": "FPCALC_PATH",
"group": "api",
"type": "string",
"default_value": "fpcalc",
"secret": false,
"readonly": false
},
{
"key": "STREAM_SIGNING_KEY",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "CACHE_DIR",
"group": "api",
"type": "string",
"default_value": "/cache",
"secret": false,
"readonly": true
},
{
"key": "FFMPEG_PATH",
"group": "shared",
"type": "string",
"default_value": "ffmpeg",
"secret": false,
"readonly": false
},
{
"key": "X_ACCEL",
"group": "api",
"type": "boolean",
"default_value": "true",
"secret": false,
"readonly": false
},
{
"key": "TRANSCODE_CONCURRENCY",
"group": "api",
"type": "integer",
"default_value": "0",
"secret": false,
"readonly": false
},
{
"key": "TRANSCODE_CACHE_LIMIT_GB",
"group": "api",
"type": "integer",
"default_value": "50",
"secret": false,
"readonly": false
},
{
"key": "RECOMMENDER_URL",
"group": "api",
"type": "string",
"default_value": "http://recommender:8000",
"secret": false,
"readonly": true
},
{
"key": "GENIUS_ACCESS_TOKEN",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "GENIUS_REQUEST_INTERVAL",
"group": "api",
"type": "duration",
"default_value": "2s",
"secret": false,
"readonly": false
},
{
"key": "MEILI_URL",
"group": "api",
"type": "string",
"default_value": "http://meilisearch:7700",
"secret": false,
"readonly": false
},
{
"key": "MEILI_MASTER_KEY",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "GITEA_URL",
"group": "api",
"type": "string",
"default_value": "",
"secret": false,
"readonly": false
},
{
"key": "GITEA_REPO",
"group": "api",
"type": "string",
"default_value": "",
"secret": false,
"readonly": false
},
{
"key": "GITEA_TOKEN",
"group": "api",
"type": "string",
"default_value": "",
"secret": true,
"readonly": false
},
{
"key": "UPDATES_DIR",
"group": "api",
"type": "string",
"default_value": "/updates",
"secret": false,
"readonly": true
},
{
"key": "UPLOADS_DIR",
"group": "api",
"type": "string",
"default_value": "/uploads",
"secret": false,
"readonly": true
},
{
"key": "LIBRARY_RW_ROOT",
"group": "api",
"type": "string",
"default_value": "/library-rw",
"secret": false,
"readonly": true
},
{
"key": "LIBRARY_HOST_RW_ROOT",
"group": "api",
"type": "string",
"default_value": "/library-host-rw",
"secret": false,
"readonly": true
},
{
"key": "UPLOADS_ROOT",
"group": "api",
"type": "string",
"default_value": "/uploads",
"secret": false,
"readonly": true
},
{
"key": "PIPU_FILE_UID",
"group": "api",
"type": "integer",
"default_value": "-1",
"secret": false,
"readonly": false
},
{
"key": "PIPU_FILE_GID",
"group": "api",
"type": "integer",
"default_value": "-1",
"secret": false,
"readonly": false
},
{
"key": "PIPU_VERSION",
"group": "deployment",
"type": "string",
"default_value": "dev",
"secret": false,
"readonly": true
},
{
"key": "PIPU_DATA",
"group": "deployment",
"type": "string",
"default_value": "/srv/pipu",
"secret": false,
"readonly": true
},
{
"key": "LIBRARY_HOST",
"group": "deployment",
"type": "string",
"default_value": "",
"secret": false,
"readonly": false
},
{
"key": "COMPOSE_FILE",
"group": "deployment",
"type": "string",
"default_value": "compose.yaml:compose.tls.yaml",
"secret": false,
"readonly": true
},
{
"key": "DOMAIN",
"group": "deployment",
"type": "string",
"default_value": "music.example.com",
"secret": false,
"readonly": false
},
{
"key": "ACME_EMAIL",
"group": "deployment",
"type": "string",
"default_value": "admin@example.com",
"secret": false,
"readonly": false
},
{
"key": "HTTP_PORT",
"group": "deployment",
"type": "integer",
"default_value": "80",
"secret": false,
"readonly": false
},
{
"key": "POSTGRES_PASSWORD",
"group": "deployment",
"type": "string",
"default_value": "",
"secret": true,
"readonly": true
},
{
"key": "CONTEXTS_PATH",
"group": "recommender",
"type": "string",
"default_value": "",
"secret": false,
"readonly": false
},
{
"key": "TRAIN_HOUR",
"group": "recommender",
"type": "integer",
"default_value": "3",
"secret": false,
"readonly": false
}
]
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Called explicitly by install.sh. Also usable once on existing installations.
set -euo pipefail
cd "$(dirname "$0")"
DEPLOY="$(pwd -P)"
DATA="${PIPU_DATA:-$(sed -n 's/^PIPU_DATA=//p' .env)}"
DATA="${DATA:-/srv/pipu}"
command -v python3 >/dev/null || { echo 'Install python3 (>=3.10) first' >&2; exit 1; }
python3 -c 'import sys; assert sys.version_info >= (3, 10), "Python >=3.10 required"'
[ -d /etc/systemd/system ] || { echo 'systemd is required for server management' >&2; exit 1; }
mkdir -p "$DATA/management" /var/lib/pipu-management /usr/local/lib/pipu
chown 65532:65532 "$DATA/management"
chmod 700 "$DATA/management" /var/lib/pipu-management
# Copy outside the checkout: switching revisions must not replace a running worker.
install -m 700 server-agent.py /usr/local/lib/pipu/server-agent.py
touch config.local
chmod 600 config.local
if [ -n "${SUDO_USER:-}" ]; then chown "$SUDO_USER" config.local; fi
# systemd quoted arguments escape backslashes, quotes and specifiers.
quote_unit() { printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/%/%%/g'; }
DEPLOY_UNIT="$(quote_unit "$DEPLOY")"
MAILBOX_UNIT="$(quote_unit "$DATA/management")"
cat > /etc/systemd/system/pipu-management.service <<EOF
[Unit]
Description=Pipu Music server management agent
After=docker.service
Requires=docker.service
[Service]
Type=simple
ExecStart=/usr/bin/python3 /usr/local/lib/pipu/server-agent.py --deploy-dir "$DEPLOY_UNIT" --mailbox "$MAILBOX_UNIT" --state-dir /var/lib/pipu-management
Restart=on-failure
RestartSec=3
UMask=0077
KillMode=control-group
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
systemctl enable pipu-management.service
systemctl restart pipu-management.service
echo '→ управление сервером: pipu-management.service'
+2
View File
@@ -118,4 +118,6 @@ if [ -n "$LIBRARY_HOST" ]; then
fi
fi
bash ./install-management.sh
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env python3
"""Host-side management worker. No network listener or Docker socket in the API.
Only fixed operations cross the shared mailbox. Private state stays outside it.
Run with Python >= 3.10; installed by install-management.sh.
"""
import argparse
import hashlib
import json
import os
import re
import subprocess
import tempfile
import threading
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlsplit
class ManagementError(Exception):
def __init__(self, code, status=400):
super().__init__(code)
self.code, self.status = code, status
def atomic(path, value, mode=0o600, preserve_owner=False):
fd, name = tempfile.mkstemp(dir=path.parent, prefix=".management-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as out:
os.chmod(name, mode)
if preserve_owner and path.exists() and hasattr(os, "chown"):
previous = path.stat()
os.chown(name, previous.st_uid, previous.st_gid)
out.write(value)
out.flush()
os.fsync(out.fileno())
os.replace(name, path)
finally:
if os.path.exists(name):
os.unlink(name)
def read_env(path):
"""Read literal dotenv values, including the quoted format we write.
Compose performs interpolation; this reader never executes dotenv contents.
"""
values = {}
if not path.exists():
return values
for line in path.read_text(encoding="utf-8").splitlines():
match = re.match(r"^(?:export\s+)?([A-Z][A-Z0-9_]*)\s*=(.*)$", line.strip())
if not match:
continue
key, value = match.groups()
value = value.strip()
if value.startswith("'") and value.endswith("'"):
value = value[1:-1].replace("\\'", "'")
elif value.startswith('"') and value.endswith('"'):
try:
value = json.loads(value)
except ValueError:
value = value[1:-1]
else:
value = value.split(" #", 1)[0].strip()
values[key] = value
return values
def validate_url(url):
if not isinstance(url, str) or len(url) > 2048 or re.search(r"[\s\x00-\x1f]", url):
raise ManagementError("management.source_invalid")
if re.fullmatch(r"git@[A-Za-z0-9.-]+:[A-Za-z0-9_./-]+", url):
return
parsed = urlsplit(url)
if (parsed.scheme not in ("https", "http", "ssh") or not parsed.hostname
or parsed.password or parsed.query or parsed.fragment
or (parsed.username and not (parsed.scheme == "ssh" and parsed.username == "git"))
or not parsed.path.strip("/")):
raise ManagementError("management.source_invalid")
def version_key(tag):
match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+))?", tag)
if not match:
return None
major, minor, patch, rc = match.groups()
return int(major), int(minor), int(patch), 1 if rc is None else 0, int(rc or 0)
def now():
return datetime.now(timezone.utc).isoformat()
class Agent:
def __init__(self, deploy, mailbox, private):
self.deploy, self.mailbox, self.private = deploy, mailbox, private
self.lock = threading.Lock()
self.cache = {}
self.job = self.load("job.json", None)
# systemd KillMode=control-group stops descendants too. Never claim success
# after a reboot or interrupted deployment.
if self.job and self.job["state"] == "running":
self.job.update(state="failed", finished_at=now(), log="Agent interrupted; inspect deployment before retrying.")
self.save("job.json", self.job)
def load(self, name, default):
path = self.private / name
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else default
def save(self, name, value):
atomic(self.private / name, json.dumps(value, ensure_ascii=False))
def git_environment(self):
return {"GIT_TERMINAL_PROMPT": "0", "GIT_SSH_COMMAND": "ssh -oBatchMode=yes",
"GIT_CONFIG_COUNT": "1", "GIT_CONFIG_KEY_0": "safe.directory", "GIT_CONFIG_VALUE_0": str(self.deploy.parent)}
def command(self, args, timeout=30, **kwargs):
return subprocess.run(args, cwd=self.deploy, timeout=timeout, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
env={**os.environ, **self.git_environment()}, **kwargs).stdout
def sources(self):
sources = self.load("sources.json", None)
if sources is None:
url = self.command(["git", "remote", "get-url", "origin"]).strip()
validate_url(url)
sources = [{"id": "origin", "name": "origin", "url": url}]
self.save("sources.json", sources)
return sources
def refs(self, url, fresh=False):
cached = self.cache.get(url)
if cached and not fresh and time.monotonic() - cached[0] < 60:
return cached[1]
output = self.command(["git", "-c", "protocol.file.allow=never", "-c", "protocol.ext.allow=never",
"ls-remote", "--heads", "--tags", "--", url], timeout=20)
refs = dict(line.split()[::-1] for line in output.splitlines() if len(line.split()) == 2)
self.cache[url] = time.monotonic(), refs
return refs
def schema(self):
return json.loads((self.deploy / "config.schema.json").read_text(encoding="utf-8"))
def revision(self):
return hashlib.sha256((self.deploy / "config.local").read_bytes()).hexdigest()
def config(self):
env = read_env(self.deploy / ".env")
local = read_env(self.deploy / "config.local")
entries = []
for item in self.schema():
key = item["key"]
source = "env" if key in env else "local" if key in local else "default"
effective = env.get(key, local.get(key, item["default_value"]))
def display(value):
return "••••••••" if item["secret"] and value else value
entries.append({**item, "env": display(env.get(key)), "local": display(local.get(key)),
"effective": display(effective), "source": source})
return {"entries": entries, "revision": self.revision(),
"restart_required": self.load("applied.json", "") != self.revision()}
def save_config(self, body):
if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409)
if body.get("revision") != self.revision():
raise ManagementError("management.config_conflict", 409)
values = body.get("values")
if not isinstance(values, dict) or len(values) > 200:
raise ManagementError("management.config_invalid")
schema = {v["key"]: v for v in self.schema()}
local = read_env(self.deploy / "config.local")
for key, value in values.items():
item = schema.get(key)
if not item or item["readonly"]:
raise ManagementError("management.config_readonly")
if value is None:
local.pop(key, None)
continue
if not isinstance(value, str) or len(value) > 8192 or any(c in value for c in "\r\n\x00"):
raise ManagementError("management.config_invalid")
if item["type"] == "boolean" and value not in ("true", "false", "1", "0"):
raise ManagementError("management.config_invalid")
if item["type"] == "integer" and not re.fullmatch(r"-?\d+", value):
raise ManagementError("management.config_invalid")
if key == "LOG_LEVEL" and value not in ("debug", "info", "warn", "error"):
raise ManagementError("management.config_invalid")
if item["type"] == "integer" and key != "TRAIN_HOUR":
minimum = -1 if key in ("PIPU_FILE_UID", "PIPU_FILE_GID") else 0
if int(value) < minimum:
raise ManagementError("management.config_invalid")
if key == "TRAIN_HOUR" and not -1 <= int(value) <= 23:
raise ManagementError("management.config_invalid")
if key.endswith("_PORT") and not 1 <= int(value) <= 65535:
raise ManagementError("management.config_invalid")
if item["type"] == "duration" and not re.fullmatch(r"(?:\d+(?:\.\d+)?(?:ns|us|µs|ms|s|m|h))+", value):
raise ManagementError("management.config_invalid")
local[key] = value
# Single quotes make dollar signs literal to Compose. Newlines are rejected.
content = "# Managed by Pipu. Environment and .env override these values.\n"
content += "".join(k + "='" + v.replace("'", "\\'") + "'\n" for k, v in sorted(local.items()))
path = self.deploy / "config.local"
previous = path.read_text(encoding="utf-8")
atomic(path, content, preserve_owner=True)
try:
self.command([*self.compose(), "config", "--quiet"])
except (subprocess.SubprocessError, OSError):
atomic(path, previous, preserve_owner=True)
raise ManagementError("management.config_invalid") from None
self.audit("config.save", list(values))
return self.config()
def compose(self):
return ["docker", "compose", "--env-file", "config.local", "--env-file", ".env"]
def audit(self, action, details):
# Deliberately no configuration values or credentials in this log.
with (self.private / "audit.jsonl").open("a", encoding="utf-8") as out:
out.write(json.dumps({"at": now(), "actor": getattr(self, "actor", ""), "action": action, "details": details}) + "\n")
def start_job(self, kind, target, command, extra_env=None):
if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409)
job = {"id": str(uuid.uuid4()), "kind": kind, "state": "running", "target": target,
"started_at": now(), "finished_at": "", "log": ""}
self.job = job
self.save("job.json", job)
revision = self.revision()
self.audit(kind + ".start", {"id": job["id"], "target": target})
threading.Thread(target=self.run_job, args=(job, command, extra_env or {}, revision), daemon=True).start()
return dict(job)
def run_job(self, job, command, extra_env, revision):
# Let the API deliver the accepted job before recreating its container.
time.sleep(2)
# Capture full output privately, expose only a bounded redacted tail.
secrets = [v for k, v in {**read_env(self.deploy / "config.local"), **read_env(self.deploy / ".env")}.items()
if re.search(r"TOKEN|PASSWORD|KEY|SECRET|DATABASE_URL|REDIS_URL", k) and len(v) >= 4]
def redact(text):
for value in secrets:
text = text.replace(value, "[redacted]")
return re.sub(r"(https?://)[^\s/@]+:[^\s/@]+@", r"\1[redacted]@", text)[-64000:]
logpath = self.private / (job["id"] + ".log")
result = 1
try:
with logpath.open("w", encoding="utf-8") as output:
process = subprocess.Popen(command, cwd=self.deploy, stdout=output, stderr=subprocess.STDOUT,
env={**os.environ, **self.git_environment(), **extra_env}, start_new_session=True)
deadline = time.monotonic() + 7200
while process.poll() is None:
if time.monotonic() > deadline:
import signal
os.killpg(process.pid, signal.SIGKILL)
process.wait()
break
with self.lock:
with logpath.open("rb") as output:
output.seek(max(0, logpath.stat().st_size - 128000))
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
self.save("job.json", job)
time.sleep(1)
result = process.returncode
with logpath.open("rb") as output:
output.seek(max(0, logpath.stat().st_size - 128000))
job["log"] = redact(output.read().decode("utf-8", errors="replace"))
if result == 0:
self.save("applied.json", revision)
except (OSError, subprocess.SubprocessError):
result = 1
job["log"] += "\nCould not execute deployment command. Inspect journalctl -u pipu-management."
finally:
with self.lock:
job.update(state="succeeded" if result == 0 else "failed", finished_at=now())
self.save("job.json", job)
def handle(self, op, body):
if op == "config.get":
return self.config()
if op == "config.save":
return self.save_config(body)
if op == "config.apply":
return self.start_job("config", "config.local", ["bash", str(self.deploy / "apply-config.sh")])
if op == "sources.save":
if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409)
sources = body.get("sources")
if not isinstance(sources, list) or not 1 <= len(sources) <= 20:
raise ManagementError("management.source_invalid")
ids = set()
for source in sources:
if (not isinstance(source, dict) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", source.get("id", ""))
or not isinstance(source.get("name"), str) or not 1 <= len(source["name"].strip()) <= 100
or source["id"] in ids):
raise ManagementError("management.source_invalid")
validate_url(source.get("url"))
ids.add(source["id"])
self.save("sources.json", sources)
self.audit("sources.save", list(ids))
return {"sources": sources}
sources = self.sources()
selected = next((s for s in sources if s["id"] == (body.get("source_id") or sources[0]["id"])), None)
if not selected:
raise ManagementError("management.source_invalid")
if op == "updates.get":
versions, error = [], ""
try:
refs = self.refs(selected["url"])
versions = sorted([r.removeprefix("refs/tags/") for r in refs if r.startswith("refs/tags/") and version_key(r[10:])], key=version_key, reverse=True)
except (subprocess.SubprocessError, OSError):
error = "management.source_unavailable"
return {"sources": sources, "versions": versions, "source_error": error,
"current_version": read_env(self.deploy / ".env").get("PIPU_VERSION", ""), "job": self.job}
if op == "updates.start":
if self.job and self.job["state"] == "running":
raise ManagementError("management.busy", 409)
channel = body.get("channel")
if channel not in ("latest", "version", "main"):
raise ManagementError("management.target_invalid")
refs = self.refs(selected["url"], fresh=True)
if channel == "latest":
tags = [r[10:] for r in refs if r.startswith("refs/tags/") and version_key(r[10:]) and "-rc." not in r]
if not tags:
raise ManagementError("management.no_releases")
target = max(tags, key=version_key)
else:
target = "main" if channel == "main" else body.get("version", "")
if target != "main" and not version_key(target):
raise ManagementError("management.target_invalid")
ref = "refs/heads/main" if target == "main" else "refs/tags/" + target
commit = refs.get(ref + "^{}", refs.get(ref))
if not commit or not re.fullmatch(r"[a-f0-9]{40,64}", commit):
raise ManagementError("management.target_invalid")
return self.start_job("update", target, ["bash", str(self.deploy / "update.sh"), target],
{"PIPU_UPDATE_SOURCE": selected["url"], "PIPU_UPDATE_COMMIT": commit,
"PIPU_UPDATE_REF": ref})
raise ManagementError("management.operation_invalid")
def tick(self):
atomic(self.mailbox / "heartbeat", str(time.time()), 0o644)
for path in sorted(self.mailbox.glob("*.request.json")):
if path.is_symlink() or not re.fullmatch(r"[a-f0-9-]{36}\.request\.json", path.name):
continue
response = self.mailbox / path.name.replace(".request.json", ".response.json")
if response.exists():
path.unlink(missing_ok=True)
continue
try:
if path.stat().st_size > 131072:
raise ManagementError("management.request_invalid")
request = json.loads(path.read_text(encoding="utf-8"))
if time.time() - request["created_at"] > 45:
raise ManagementError("management.request_expired", 409)
# Claim before executing: a worker crash must never replay a mutation.
path.unlink(missing_ok=True)
with self.lock:
self.actor = request.get("actor", "")
result = {"data": self.handle(request["operation"], request.get("body") or {})}
except ManagementError as error:
result = {"code": error.code, "status": error.status}
except (OSError, ValueError, KeyError, TypeError, subprocess.SubprocessError):
result = {"code": "management.agent_error", "status": 502}
atomic(response, json.dumps(result, ensure_ascii=False), 0o644)
path.unlink(missing_ok=True)
for path in self.mailbox.glob("*.response.json"):
if not path.is_symlink() and time.time() - path.stat().st_mtime > 120:
path.unlink(missing_ok=True)
def main():
import fcntl # Linux host only; tests import this module on Windows too.
parser = argparse.ArgumentParser()
parser.add_argument("--deploy-dir", type=Path, required=True)
parser.add_argument("--mailbox", type=Path, required=True)
parser.add_argument("--state-dir", type=Path, required=True)
args = parser.parse_args()
args.state_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
with (args.state_dir / "lock").open("w") as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
agent = Agent(args.deploy_dir.resolve(), args.mailbox.resolve(), args.state_dir.resolve())
while True:
# Refresh the installed worker only after a successful deployment has
# persisted its final status. The running worker survives checkout/build.
with agent.lock:
installed = Path(__file__).resolve()
candidate = agent.deploy / "server-agent.py"
if (agent.job and agent.job["kind"] == "update" and agent.job["state"] == "succeeded"
and installed == Path("/usr/local/lib/pipu/server-agent.py") and candidate.is_file()
and candidate.read_bytes() != installed.read_bytes()):
atomic(installed, candidate.read_text(encoding="utf-8"), 0o700)
# The singleton lock is non-inheritable and released by exec.
import sys
os.execv(sys.executable, [sys.executable, str(installed), *sys.argv[1:]])
agent.tick()
time.sleep(0.2)
if __name__ == "__main__":
main()
+147
View File
@@ -0,0 +1,147 @@
"""Host worker tests: python3 -m unittest discover -s deploy -p 'test_*.py'."""
import importlib.util
import json
import subprocess
import tempfile
import unittest
import shutil
import sys
from pathlib import Path
from unittest.mock import patch
spec = importlib.util.spec_from_file_location("server_agent", Path(__file__).with_name("server-agent.py"))
agent_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(agent_module)
Agent, ManagementError = agent_module.Agent, agent_module.ManagementError
class AgentTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.deploy, self.mailbox, self.private = [self.root / n for n in ("deploy", "mailbox", "private")]
for directory in (self.deploy, self.mailbox, self.private):
directory.mkdir()
self.schema = [
{"key": "LOG_LEVEL", "group": "api", "type": "string", "default_value": "info", "secret": False, "readonly": False},
{"key": "GITEA_TOKEN", "group": "api", "type": "string", "default_value": "", "secret": True, "readonly": False},
{"key": "TRAIN_HOUR", "group": "recommender", "type": "integer", "default_value": "3", "secret": False, "readonly": False},
{"key": "PIPU_DATA", "group": "deployment", "type": "string", "default_value": "/srv/pipu", "secret": False, "readonly": True},
]
(self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8")
(self.deploy / "config.local").write_text("", encoding="utf-8")
(self.deploy / ".env").write_text("LOG_LEVEL=warn\nGITEA_TOKEN=top-secret\n", encoding="utf-8")
self.agent = Agent(self.deploy, self.mailbox, self.private)
self.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}])
def test_env_priority_and_secrets_never_returned(self):
(self.deploy / "config.local").write_text("LOG_LEVEL=debug\nTRAIN_HOUR=5\n", encoding="utf-8")
config = self.agent.config()
entries = {v["key"]: v for v in config["entries"]}
self.assertEqual(("warn", "env", "debug"), tuple(entries["LOG_LEVEL"][k] for k in ("effective", "source", "local")))
self.assertEqual(("5", "local"), tuple(entries["TRAIN_HOUR"][k] for k in ("effective", "source")))
self.assertNotIn("top-secret", json.dumps(config))
def test_save_roundtrip_reset_and_conflict(self):
with patch.object(self.agent, "command", return_value=""):
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "7", "GITEA_TOKEN": "a$'\\b#c"}})
self.assertEqual("a$'\\b#c", agent_module.read_env(self.deploy / "config.local")["GITEA_TOKEN"])
self.assertTrue(result["restart_required"])
self.assertNotIn("a$", json.dumps(result))
self.assertNotIn("a$", (self.private / "audit.jsonl").read_text())
with self.assertRaises(ManagementError) as raised:
self.agent.save_config({"revision": "stale", "values": {"TRAIN_HOUR": "8"}})
self.assertEqual(409, raised.exception.status)
self.agent.save_config({"revision": result["revision"], "values": {"TRAIN_HOUR": None}})
self.assertNotIn("TRAIN_HOUR", agent_module.read_env(self.deploy / "config.local"))
def test_invalid_values_and_compose_failure_preserve_original(self):
original = (self.deploy / "config.local").read_bytes()
for values in ({"TRAIN_HOUR": "24"}, {"TRAIN_HOUR": "bad"}, {"UNKNOWN": "x"}, {"PIPU_DATA": "/tmp"}, {"LOG_LEVEL": "x\nOTHER=y"}):
with self.assertRaises(ManagementError):
self.agent.save_config({"revision": self.agent.revision(), "values": values})
self.assertEqual(original, (self.deploy / "config.local").read_bytes())
with patch.object(self.agent, "command", side_effect=subprocess.CalledProcessError(1, "docker")):
with self.assertRaises(ManagementError):
self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}})
self.assertEqual(original, (self.deploy / "config.local").read_bytes())
def test_source_validation_rejects_shell_local_and_credentials(self):
for url in ("/tmp/repo", "file:///tmp/repo", "ext::sh -c evil", "--upload-pack=evil", "https://user:secret@example.org/o/r", "https://secret@example.org/o/r", "https://example.org/o/r\narg", "ssh://root@example.org/o/r"):
with self.subTest(url=url), self.assertRaises(ManagementError):
agent_module.validate_url(url)
for url in ("https://example.org/o/r", "http://10.0.0.1:3000/o/r", "git@example.org:o/r.git", "ssh://git@example.org:2222/o/r.git"):
agent_module.validate_url(url)
def test_latest_is_semver_stable_and_pins_peeled_commit(self):
refs = {"refs/tags/v1.9.0": "a" * 40, "refs/tags/v1.10.0": "b" * 40, "refs/tags/v1.10.0^{}": "c" * 40, "refs/tags/v2.0.0-rc.1": "d" * 40}
with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job", return_value={}) as start:
self.agent.handle("updates.start", {"source_id": "origin", "channel": "latest"})
self.assertEqual("v1.10.0", start.call_args.args[1])
self.assertEqual("c" * 40, start.call_args.args[3]["PIPU_UPDATE_COMMIT"])
self.assertEqual("refs/tags/v1.10.0", start.call_args.args[3]["PIPU_UPDATE_REF"])
with self.assertRaises(ManagementError):
self.agent.handle("updates.start", {"source_id": "origin", "channel": "version", "version": "--evil"})
def test_main_and_default_source(self):
with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job", return_value={}) as start:
self.agent.handle("updates.start", {"source_id": "origin", "channel": "main"})
self.assertEqual("main", start.call_args.args[1])
result = self.agent.handle("updates.get", {"source_id": None})
self.assertEqual("origin", result["sources"][0]["id"])
def test_running_job_blocks_mutations_and_survives_restart_as_failure(self):
self.agent.job = {"state": "running"}
for operation, body in (("updates.start", {"channel": "main"}), ("config.apply", {}), ("sources.save", {}), ("config.save", {})):
with self.subTest(operation=operation), self.assertRaises(ManagementError) as raised:
self.agent.handle(operation, body)
self.assertEqual(409, raised.exception.status)
self.agent.save("job.json", {"state": "running", "id": "old"})
restored = Agent(self.deploy, self.mailbox, self.private)
self.assertEqual("failed", restored.job["state"])
def test_completed_job_redacts_secrets_and_marks_applied_revision(self):
job = {"id": "test", "kind": "config", "state": "running", "started_at": "", "finished_at": "", "target": "config.local", "log": ""}
self.agent.job = job
revision = self.agent.revision()
self.agent.run_job(job, [sys.executable, "-c", "print('top-secret')"], {}, revision)
self.assertEqual("succeeded", job["state"])
self.assertNotIn("top-secret", job["log"])
self.assertIn("[redacted]", job["log"])
self.assertFalse(self.agent.config()["restart_required"])
self.agent.run_job(job, [sys.executable, "-c", "raise SystemExit(2)"], {}, revision)
self.assertEqual("failed", job["state"])
def test_mailbox_discards_stale_request_and_never_executes_it(self):
path = self.mailbox / "00000000-0000-0000-0000-000000000001.request.json"
path.write_text(json.dumps({"operation": "config.apply", "created_at": 1}), encoding="utf-8")
with patch.object(self.agent, "handle") as handle:
self.agent.tick()
handle.assert_not_called()
response = json.loads(path.with_name(path.name.replace("request", "response")).read_text(encoding="utf-8"))
self.assertEqual("management.request_expired", response["code"])
class ComposeTests(unittest.TestCase):
@unittest.skipUnless(shutil.which('docker'), 'Docker Compose CLI unavailable')
def test_real_compose_priorities_and_literal_secret_roundtrip(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
(root / '.env').write_text('POSTGRES_PASSWORD=test-db\nINTERNAL_TOKEN=test-internal\nMEILI_MASTER_KEY=test-meili\nLOG_LEVEL=warn\n', encoding='utf-8')
secret = "literal$VALUE'with\\slash#hash"
(root / 'config.local').write_text("LOG_LEVEL='debug'\nTRAIN_HOUR='7'\nGITEA_TOKEN='" + secret.replace("'", "\\'") + "'\n", encoding='utf-8')
compose = Path(__file__).with_name('compose.yaml').resolve()
result = subprocess.run(['docker', 'compose', '--project-directory', str(root), '-f', str(compose), '--env-file', str(root / 'config.local'), '--env-file', str(root / '.env'), 'config', '--format', 'json'], capture_output=True, text=True, timeout=30)
self.assertEqual(0, result.returncode, 'Docker Compose configuration failed: ' + result.stderr)
services = json.loads(result.stdout)['services']
self.assertEqual('warn', services['api']['environment']['LOG_LEVEL'])
self.assertEqual('7', services['recommender']['environment']['TRAIN_HOUR'])
self.assertEqual('7', services['analyzer']['environment']['TRAIN_HOUR'])
# Canonical Compose output escapes dollars for reuse as YAML.
self.assertEqual(secret.replace('$', '$$'), services['api']['environment']['GITEA_TOKEN'])
self.assertNotIn('GITEA_TOKEN', services['recommender']['environment'])
if __name__ == "__main__":
unittest.main()
+37 -6
View File
@@ -14,6 +14,15 @@ fi
trap 'rm -f "$0"' EXIT
cd "$PIPU_DEPLOY_DIR"
# Share a lock with manual deployments and config application.
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
exec 9<.management.lock
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
touch config.local
chmod 600 config.local
compose() { docker compose --env-file config.local --env-file .env "$@"; }
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
@@ -69,7 +78,15 @@ if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [
fi
REF="${1:-}"
git fetch --tags --prune --quiet origin
if [ -n "${PIPU_UPDATE_SOURCE:-}" ]; then
[[ "${PIPU_UPDATE_COMMIT:-}" =~ ^[a-f0-9]{40,64}$ ]] || { echo 'Invalid commit' >&2; exit 1; }
[[ "${PIPU_UPDATE_REF:-}" =~ ^refs/(heads/main|tags/v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?)$ ]] || { echo 'Invalid ref' >&2; exit 1; }
git -c protocol.file.allow=never -c protocol.ext.allow=never fetch --quiet -- "$PIPU_UPDATE_SOURCE" "$PIPU_UPDATE_REF"
FETCHED="$(git rev-parse 'FETCH_HEAD^{commit}')"
[ "$FETCHED" = "$PIPU_UPDATE_COMMIT" ] || { echo 'Source changed; select update again' >&2; exit 1; }
else
git fetch --tags --prune --quiet origin
fi
if [ -z "$REF" ]; then
REF="$(git tag -l 'v*' --sort=-v:refname | grep -v -- '-rc' | head -1)"
@@ -96,26 +113,40 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
fi
echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
# Refuse to overwrite local source changes.
git diff --quiet && git diff --cached --quiet || { echo 'Repository has local changes' >&2; exit 1; }
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
git checkout --quiet --detach "$PIPU_UPDATE_COMMIT"
elif git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF"
git merge --ff-only --quiet "origin/$REF"
else
git checkout --quiet --detach "$REF"
fi
VERSION="$(git describe --tags --always --dirty)"
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
if [ "$REF" = main ]; then VERSION="main-$(git rev-parse --short HEAD)"; else VERSION="$REF"; fi
else
VERSION="$(git describe --tags --always --dirty)"
fi
VERSION="${VERSION#v}"
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)"
docker compose build --pull
docker compose up -d --remove-orphans
compose config --quiet
if ! compose build --pull; then
# A build failure leaves running containers untouched; keep their version in .env.
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$CURRENT|" .env
echo 'Build failed; running containers were not replaced' >&2
exit 1
fi
compose up -d --remove-orphans
docker image prune -f >/dev/null
echo "→ ждём готовности api"
for _ in $(seq 1 60); do
# 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4
if docker compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
echo "pipu $VERSION запущен"
exit 0
fi
+1
View File
@@ -24,6 +24,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
| [server-management.md](server-management.md) | Управление обновлениями и config.local из админки, установка агента |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
## Стек в одном абзаце
+5
View File
@@ -133,3 +133,8 @@ APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релиз
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
## Управление обновлениями и конфигурацией
Разделы **Обновления** и **Конфигурация** используют агент на хосте. Установка для существующего сервера, права, приоритет `.env` над `config.local` и диагностика описаны в [server-management.md](server-management.md).
+3
View File
@@ -97,3 +97,6 @@
делает **единственный** параллельный refresh (single-flight) и повторяет запросы.
- Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`;
админские разделы — по `role`.
Управление обновлениями сервера — право `manages_updates`: у admin всегда, другим его выдаёт и отзывает только admin. Оно даёт доступ к `/admin/updates`, управлению источниками и запуску обновления, но не к конфигурации сервера. `/admin/config` доступен только admin. Подробности: [server-management.md](server-management.md).
+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`,
+127
View File
@@ -0,0 +1,127 @@
# Управление сервером из веб-интерфейса
В меню админки доступны **Обновления** (`/admin/updates`) и **Конфигурация**
(`/admin/config`). Клиентские APK и desktop-сборки по-прежнему раздаются через
существующий механизм `appupdate`; источники обновления сервера от него независимы.
## Установка на существующий сервер
После получения версии с управлением сервером выполните на Linux-хосте:
```bash
cd /opt/pipu-music/deploy
sudo bash ./install-management.sh
./update.sh main # либо тег версии, содержащей эту возможность
```
Новая установка через `sudo ./install.sh` устанавливает агент автоматически.
Нужны systemd, Python **3.10+**, Git, Bash, `flock` (util-linux) и Docker Compose
**2.24+**. Compose должен поддерживать несколько `--env-file` и тег `!reset`
из существующего TLS-overlay. Агент выполняется от root, чтобы запускать Compose
и обращаться к данным развёртывания; API продолжает работать без root и без Docker socket.
Скрипт создаёт `pipu-management.service`, копирует агент вне Git-checkout в
`/usr/local/lib/pipu/server-agent.py`, создаёт `deploy/config.local` и общий каталог
`${PIPU_DATA}/management` с владельцем UID 65532. Последний монтируется только в API.
Приватные настройки источников, состояние, аудит и журналы лежат в
`/var/lib/pipu-management`, доступном только root. Нужен доступ этого пользователя
к origin; SSH-ключи и known_hosts для приватных репозиториев настройте на хосте.
Пароли и токены в URL источников не принимаются.
После успешного обновления агент автоматически заменяет свою установленную копию
из нового checkout и перезапускается, сохранив итоговый статус. Для ручной установки
нового кода можно повторно запустить `install-management.sh`.
Во время выполняющейся операции не перезапускайте его: systemd остановит дочерние
процессы. После прерывания/перезагрузки задача помечается ошибкой, а не успехом.
## Права
Администратор всегда может управлять обновлениями. В меню действий пользователя
на странице **Пользователи** он может выдать и забрать право **Управление
обновлениями** (`manages_updates`). Право действует независимо от роли и проверяется
на сервере при каждом запросе. Оно позволяет изменять источники, видеть журнал
и запускать обновление. Пользователь с этим правом получает ссылку на обновления,
но не получает доступ к пользователям, конфигурации или остальным действиям админа.
Выдавайте это право доверенным операторам: обновление запускает код выбранного
совместимого репозитория на хосте. Только администраторы могут просматривать,
сохранять и применять конфигурацию. Выдача/отзыв права записываются в `audit_log`;
операции агента с UUID инициатора — в его приватный `audit.jsonl` без значений секретов.
## Обновления
При первом обращении автоматически добавляется текущий `git remote origin`.
Можно создавать, редактировать и удалять источники; хотя бы один должен остаться.
Поддерживаются совместимые Git-репозитории Pipu через HTTP(S) и SSH.
* **Последний релиз** — значение по умолчанию: максимальный стабильный тег SemVer
`vMAJOR.MINOR.PATCH` (также допускается без `v`), без `-rc.N`.
* **Выбрать версию** — конкретный релизный тег, включая release candidate.
* **main** — текущий commit ветки main.
Обновления устанавливаются только по нажатию кнопки и после подтверждения.
Перед запуском агент повторно читает refs и фиксирует SHA; update.sh проверяет,
что полученный commit совпадает. Выбранный источник не перезаписывает origin
и не смешивает его теги с тегами другого репозитория. Git checkout с локальными
изменениями отклоняется. Защита update.sh от downgrade сохраняется: откат
выполняется вручную с `PIPU_ALLOW_DOWNGRADE=1` после проверки совместимости БД.
Выполнение, ограниченный хвост журнала и итоговый статус доступны на странице.
Можно закрыть браузер или переждать перезапуск API: работа продолжается на хосте.
Полные логи остаются приватными; для интерфейса известные секреты маскируются.
Одновременно агент допускает только одну операцию обновления/применения.
Резервная копия и откат схемы БД автоматически не выполняются.
## Конфигурация
Реестр поддерживаемых параметров находится в `deploy/config.schema.json`:
API, общие параметры сервисов, рекомендатель и развёртывание. Добавляя параметр
в конфигурацию сервиса, обновите реестр; для рекомендателя/агента хранилища также
проверьте передачу через Compose. Пути томов, сгенерированный DATABASE_URL и
некоторые служебные параметры показаны для просмотра и меняются на хосте.
Страница не является произвольным редактором nginx/Caddy/YAML-файлов.
Приоритет при запуске через скрипты:
1. Явные переменные окружения Compose и значения из `deploy/.env`.
2. `deploy/config.local`.
3. Встроенные значения по умолчанию.
Для интерполяции Compose скрипты передают `--env-file config.local --env-file .env`.
У API порядок `env_file: [config.local, .env]`; рекомендатель и storage-agent получают
только необходимые им переменные через `environment`. При ручном запуске Compose
сохраняйте эти флаги, иначе параметры интерполяции из config.local не подхватятся.
Страница показывает ожидаемое значение после применения, а не утверждает, что
работающий процесс уже перечитал сохранённый файл. Окружение shell запуска Compose
тоже может перекрывать интерполяцию; для агента используется окружение systemd.
В интерфейсе видны локальное значение, значение из `.env` и источник приоритета.
Если ключ уже есть в `.env`, изменение его в config.local сохраняется, но не
перекрывает `.env` — интерфейс прямо сообщает об этом. Чтобы управлять таким ключом
через страницу, удалите его строку из `.env` на хосте. Секреты не возвращаются в API
открытым текстом: поле без изменений сохраняет прежний секрет, ввод заменяет,
**Сбросить локальное** удаляет только значение из config.local.
Сохранение атомарно, с правами 0600 и проверкой ревизии: устаревшая форма не затрёт
правки другого администратора. Перед сохранением проверяются типы и Compose;
при ошибке прежний файл восстанавливается. Произвольные ключи, переводы строк
и изменение служебных параметров отклоняются. Сохраняемые значения трактуются
буквально: подстановки shell не выполняются.
Кнопка **Применить и перезапустить** отдельно подтверждает пересоздание контейнеров.
Агент запускает `apply-config.sh`, ждёт `/readyz` и сохраняет результат. Если сервисы
не стали готовы, состояние — ошибка; исправьте config.local или восстановите его
на хосте. Автоматического отката конфигурации после неудачного применения нет.
Диагностика:
```bash
sudo systemctl status pipu-management.service
sudo journalctl -u pipu-management.service -n 100
cd /opt/pipu-music/deploy
docker compose --env-file config.local --env-file .env logs api
```
Проверки разработки: `python3 -m unittest discover -s deploy -p 'test_*.py'`,
`go test ./internal/auth ./internal/management` из `services/api`, а также
`pnpm --filter @pipu/web test:e2e e2e/server-management.spec.ts`.
+339
View File
@@ -4,6 +4,70 @@
*/
export interface paths {
"/api/v1/admin/server/updates": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getServerUpdates"];
put?: never;
post: operations["startServerUpdate"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/server/update-sources": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put: operations["saveServerUpdateSources"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/server/config": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get: operations["getServerConfig"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch: operations["saveServerConfig"];
trace?: never;
};
"/api/v1/admin/server/config/apply": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["applyServerConfig"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/genius": {
parameters: {
query?: never;
@@ -860,6 +924,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;
@@ -1482,6 +1563,62 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
schemas: {
ServerSource: {
id: string;
name: string;
url: string;
};
ServerSources: {
sources: components["schemas"]["ServerSource"][];
};
ServerJob: {
id: string;
/** @enum {string} */
kind: "update" | "config";
/** @enum {string} */
state: "running" | "succeeded" | "failed";
started_at: string;
finished_at: string;
target: string;
log: string;
};
ServerUpdates: {
sources: components["schemas"]["ServerSource"][];
versions: string[];
current_version: string;
source_error: string;
job: components["schemas"]["ServerJob"] | null;
};
ServerUpdateRequest: {
source_id: string;
/** @enum {string} */
channel: "latest" | "version" | "main";
version?: string;
};
ServerConfigEntry: {
key: string;
group: string;
type: string;
secret: boolean;
readonly: boolean;
effective: string;
env: string | null;
local: string | null;
default_value: string;
/** @enum {string} */
source: "env" | "local" | "default";
};
ServerConfig: {
revision: string;
restart_required: boolean;
entries: components["schemas"]["ServerConfigEntry"][];
};
ServerConfigPatch: {
revision: string;
values: {
[key: string]: string | null;
};
};
PlaylistShare: {
/** Format: uuid */
share_id: string;
@@ -1842,6 +1979,8 @@ export interface components {
username: string;
display_name: string | null;
role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */
reviews_uploads: boolean;
locale: string;
@@ -1871,6 +2010,8 @@ export interface components {
username: string;
display_name: string | null;
role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */
reviews_uploads: boolean;
locale: string;
@@ -1890,6 +2031,8 @@ export interface components {
username: string;
display_name?: string;
role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Сразу выдать аудит загрузок. Может задать только админ */
reviews_uploads?: boolean;
};
@@ -1905,6 +2048,8 @@ export interface components {
display_name?: string;
role?: components["schemas"]["Role"];
is_blocked?: boolean;
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя */
reviews_uploads?: boolean;
};
@@ -1943,6 +2088,7 @@ export interface components {
/** @enum {string} */
TagPriority: "tags_first" | "path_first";
Source: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
/** Format: uuid */
id: string;
name: string;
@@ -1971,6 +2117,7 @@ export interface components {
items: components["schemas"]["Source"][];
};
SourceInput: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name: string;
type: components["schemas"]["SourceType"];
nfs_server?: string;
@@ -1983,6 +2130,7 @@ export interface components {
write_template?: string;
};
SourcePatch: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name?: string;
nfs_server?: string;
nfs_export?: string;
@@ -2001,6 +2149,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;
@@ -2078,6 +2228,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 */
@@ -2127,6 +2286,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[];
@@ -2517,6 +2691,146 @@ export interface components {
}
export type $defs = Record<string, never>;
export interface operations {
getServerUpdates: {
parameters: {
query?: {
source_id?: string;
};
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Server management result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerUpdates"];
};
};
default: components["responses"]["Problem"];
};
};
startServerUpdate: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ServerUpdateRequest"];
};
};
responses: {
/** @description Server management result */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerJob"];
};
};
default: components["responses"]["Problem"];
};
};
saveServerUpdateSources: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ServerSources"];
};
};
responses: {
/** @description Server management result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerSources"];
};
};
default: components["responses"]["Problem"];
};
};
getServerConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Server management result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerConfig"];
};
};
default: components["responses"]["Problem"];
};
};
saveServerConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ServerConfigPatch"];
};
};
responses: {
/** @description Server management result */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerConfig"];
};
};
default: components["responses"]["Problem"];
};
};
applyServerConfig: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Server management result */
202: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ServerJob"];
};
};
default: components["responses"]["Problem"];
};
};
getGeniusStats: {
parameters: {
query?: never;
@@ -3760,6 +4074,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?: {
+1
View File
@@ -29,3 +29,4 @@ export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveS
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';
+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();
}
+104 -4
View File
@@ -32,7 +32,10 @@
"recs_confirm": "Reset {{username}}'s recommendations? The wave will start learning their taste from scratch and ask for favourite artists again. Likes and history stay.",
"recs_done": "{{username}}'s recommendations were reset",
"confirm": "Confirm",
"you": "you"
"you": "you",
"manage_updates": "Server updates",
"grant_updates": "Grant update management",
"revoke_updates": "Revoke update management"
},
"create": {
"title": "New user",
@@ -71,7 +74,8 @@
"user.unblock": "User unblocked",
"user.password_reset": "Password reset",
"user.recommendations_reset": "Recommendations reset",
"user.delete": "User deleted"
"user.delete": "User deleted",
"user.manages_updates": "Server update permission changed"
}
},
"sources": {
@@ -98,7 +102,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 +184,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",
@@ -362,5 +382,85 @@
"server_settings": {
"title": "Server settings",
"subtitle": "Settings that apply to all users"
},
"management": {
"server": "Server management",
"updates": "Updates",
"config": "Configuration",
"updates_intro": "Choose a source and version. The server will build and report the result.",
"refresh": "Refresh status",
"current": "Installed version",
"manual": "Updates start manually. The latest stable release is resolved when you start the update.",
"update_target": "Next update",
"sources": "Update sources",
"source": "Source",
"channel": "Update target",
"channel_latest": "Latest release",
"channel_version": "Choose version",
"channel_main": "main",
"hint_latest": "Install the latest stable tag, excluding release candidates.",
"hint_version": "Choose a published tag. Downgrading to an older version requires a manual rollback.",
"hint_main": "The current main branch. May include unreleased changes.",
"version": "Version",
"choose_version": "Choose a version",
"source_unavailable": "Could not read the repository. Check its address, server access and SSH keys.",
"install": "Install update",
"confirm_update": "Update the server?",
"confirm_update_text": "Source: {{source}}. Target: {{target}}.",
"update_warning": "The service will be briefly unavailable during restart. Back up the database first. Database migrations are not rolled back.",
"cancel": "Cancel",
"sources_hint": "Git repositories compatible with Pipu. HTTP(S) or SSH; configure private repository keys on the agent host. Do not include tokens in the URL.",
"source_name": "Name",
"repository": "Git repository URL",
"remove_source": "Remove source",
"add_source": "Add source",
"save": "Save",
"job": "Last operation",
"started": "Started",
"finished": "Finished",
"log": "Execution log",
"waiting": "Waiting for command output…",
"running_hint": "The task runs on the server. You can close this page without interrupting it.",
"reconnecting": "Services are restarting. Waiting to reconnect to the server…",
"state": {
"running": "Running",
"succeeded": "Completed",
"failed": "Failed"
},
"config_intro": "API, recommender and deployment settings in deploy/config.local.",
"priority": "Priority order",
"defaults": "defaults",
"priority_hint": ".env values override config.local. Save and apply are separate actions.",
"search": "Search settings",
"group": "Section",
"all": "All sections",
"groups": {
"api": "API",
"deployment": "Deployment",
"shared": "Shared",
"recommender": "Recommender"
},
"config_files_hint": "Supported settings are listed here. “After applying” is the value from .env, config.local or a default. Fixed paths and service parameters are read-only. Secrets can be replaced; their values stay hidden.",
"value_source": {
"env": "From .env",
"local": "From config.local",
"default": "Default"
},
"effective": "After applying",
"readonly": "Service parameter. Change on the host together with its Compose configuration.",
"secret_saved": "Secret saved",
"will_reset": "The local value will be removed when saved.",
"overridden": "A value in .env takes priority. This local change will not take effect yet.",
"secret_hint": "Leave unchanged to keep the stored secret. Enter a new value to replace it.",
"default": "Default",
"reset": "Reset local value",
"no_results": "No settings found",
"saved": "Changes saved to config.local",
"unsaved": "Unsaved settings: {{count}}",
"pending_restart": "Saved changes are waiting to be applied",
"no_changes": "No changes to apply",
"discard": "Discard edits",
"apply": "Apply and restart",
"apply_warning": "Containers will be recreated with the saved configuration. The service will be briefly unavailable; persistent volume data is preserved. Values from .env will keep taking priority."
}
}
+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).",
+23 -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",
@@ -44,7 +62,9 @@
"suggest": "Suggest tracks",
"my_uploads": "My uploads",
"upload_queue": "Queue",
"admin_settings": "Settings"
"admin_settings": "Settings",
"server_updates": "Updates",
"server_config": "Configuration"
},
"profile": {
"menu": "Profile menu",
+17
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",
@@ -100,5 +101,21 @@
"invalid_tracks": "Tracks must be unique and exist in the library",
"too_large": "Maximum 2000 tracks and 2000 description characters",
"conflict": "This playlist changed on another device. Close the editor and try again with the refreshed data."
},
"management": {
"unavailable": "Server management agent is unavailable. Run sudo bash deploy/install-management.sh on the server and check pipu-management.service.",
"timeout": "The agent timed out. Check operation status before retrying.",
"busy": "A server operation is already running. Wait for it to finish.",
"source_invalid": "Check source names and URLs. Embedded passwords and tokens are not supported.",
"source_unavailable": "The repository is unavailable from the server.",
"target_invalid": "The selected version is unavailable or changed. Refresh the list and try again.",
"no_releases": "No stable release tags were found.",
"config_conflict": "Another administrator changed the configuration. Reload the page and repeat your edits.",
"config_invalid": "Invalid value or Compose configuration. Changes were not saved.",
"config_readonly": "This setting cannot be changed in the interface.",
"agent_error": "The agent could not complete the operation. Check journalctl -u pipu-management.",
"request_expired": "The request expired. Check operation status before retrying.",
"request_invalid": "Invalid server management request.",
"operation_invalid": "Unsupported server management operation."
}
}
+104 -4
View File
@@ -32,7 +32,10 @@
"recs_confirm": "Сбросить рекомендации {{username}}? Волна начнёт подбирать музыку с нуля и снова спросит любимых исполнителей. Лайки и история останутся.",
"recs_done": "Рекомендации {{username}} сброшены",
"confirm": "Подтвердить",
"you": "вы"
"you": "вы",
"manage_updates": "Обновление сервера",
"grant_updates": "Дать управление обновлениями",
"revoke_updates": "Забрать управление обновлениями"
},
"create": {
"title": "Новый пользователь",
@@ -71,7 +74,8 @@
"user.unblock": "Пользователь разблокирован",
"user.password_reset": "Сброшен пароль",
"user.recommendations_reset": "Сброшены рекомендации",
"user.delete": "Пользователь удалён"
"user.delete": "Пользователь удалён",
"user.manages_updates": "Изменены права обновления сервера"
}
},
"sources": {
@@ -98,7 +102,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 +184,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": "Приём загрузок и шаблон пути",
@@ -362,5 +382,85 @@
"server_settings": {
"title": "Настройки сервера",
"subtitle": "Параметры, которые действуют для всех пользователей"
},
"management": {
"server": "Управление сервером",
"updates": "Обновления",
"config": "Конфигурация",
"updates_intro": "Выберите источник и версию. Сервер выполнит сборку и покажет результат.",
"refresh": "Обновить статус",
"current": "Установленная версия",
"manual": "Обновления запускаются вручную. Последний стабильный релиз выбирается в момент запуска.",
"update_target": "Следующее обновление",
"sources": "Источники обновлений",
"source": "Источник",
"channel": "Тип обновления",
"channel_latest": "Последний релиз",
"channel_version": "Выбрать версию",
"channel_main": "main",
"hint_latest": "Будет установлен последний стабильный тег без release candidate.",
"hint_version": "Можно выбрать опубликованный тег. Переход на старую версию требует ручного отката.",
"hint_main": "Текущее состояние ветки main. Может содержать ещё не выпущенные изменения.",
"version": "Версия",
"choose_version": "Выберите версию",
"source_unavailable": "Не удалось прочитать репозиторий. Проверьте адрес, доступ сервера и SSH-ключи.",
"install": "Установить обновление",
"confirm_update": "Обновить сервер?",
"confirm_update_text": "Источник: {{source}}. Цель: {{target}}.",
"update_warning": "Во время перезапуска сервис временно недоступен. Перед обновлением сделайте резервную копию базы. Миграции назад не откатываются.",
"cancel": "Отмена",
"sources_hint": "Git-репозитории с совместимой структурой Pipu. HTTP(S) или SSH; для приватных репозиториев настройте ключи на хосте агента. Не вставляйте токены в адрес.",
"source_name": "Название",
"repository": "URL Git-репозитория",
"remove_source": "Удалить источник",
"add_source": "Добавить источник",
"save": "Сохранить",
"job": "Последняя операция",
"started": "Начало",
"finished": "Завершение",
"log": "Журнал выполнения",
"waiting": "Ожидаем вывод команды…",
"running_hint": "Задача выполняется на сервере. Страницу можно закрыть — выполнение продолжится.",
"reconnecting": "Сервисы перезапускаются. Ждём восстановления связи с сервером…",
"state": {
"running": "Выполняется",
"succeeded": "Готово",
"failed": "Ошибка"
},
"config_intro": "Настройки API, рекомендателя и развёртывания в deploy/config.local.",
"priority": "Порядок приоритета",
"defaults": "по умолчанию",
"priority_hint": "Значения из .env перекрывают config.local. Сохранение и применение — отдельные действия.",
"search": "Поиск по названию настройки",
"group": "Раздел",
"all": "Все разделы",
"groups": {
"api": "API",
"deployment": "Развёртывание",
"shared": "Общие",
"recommender": "Рекомендатель"
},
"config_files_hint": "Показаны поддерживаемые параметры. «После применения» — значение из .env, config.local или значение по умолчанию. Фиксированные пути и служебные параметры доступны для просмотра. Секреты можно заменить, их значения скрыты.",
"value_source": {
"env": "Из .env",
"local": "Из config.local",
"default": "По умолчанию"
},
"effective": "После применения",
"readonly": "Служебный параметр. Меняется на хосте с учётом конфигурации Compose.",
"secret_saved": "Секрет сохранён",
"will_reset": "Локальное значение будет удалено при сохранении.",
"overridden": "В .env есть значение: оно имеет приоритет. Локальное изменение пока не вступит в силу.",
"secret_hint": "Оставьте поле без изменений, чтобы сохранить секрет. Введите новое значение для замены.",
"default": "По умолчанию",
"reset": "Сбросить локальное",
"no_results": "Настройки не найдены",
"saved": "Изменения сохранены в config.local",
"unsaved": "Несохранённых параметров: {{count}}",
"pending_restart": "Сохранённые изменения ожидают применения",
"no_changes": "Нет изменений для применения",
"discard": "Отменить правки",
"apply": "Применить и перезапустить",
"apply_warning": "Контейнеры будут пересозданы с сохранённой конфигурацией. Сервис временно недоступен; данные в постоянных томах сохранятся. Значения из .env продолжат иметь приоритет."
}
}
+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).",
+23 -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",
@@ -44,7 +62,9 @@
"suggest": "Предложить треки",
"my_uploads": "Мои предложения",
"upload_queue": "Очередь",
"admin_settings": "Настройки"
"admin_settings": "Настройки",
"server_updates": "Обновления",
"server_config": "Конфигурация"
},
"profile": {
"menu": "Меню профиля",
+17
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": "Неизвестный тип хранилища",
@@ -100,5 +101,21 @@
"invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке",
"too_large": "Максимум 2000 треков и 2000 символов описания",
"conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными."
},
"management": {
"unavailable": "Агент управления сервером недоступен. Выполните sudo bash deploy/install-management.sh на сервере и проверьте pipu-management.service.",
"timeout": "Агент не ответил вовремя. Проверьте статус операции перед повторным запуском.",
"busy": "На сервере уже выполняется операция. Дождитесь завершения.",
"source_invalid": "Проверьте названия и адреса источников. Токены и пароли в URL не поддерживаются.",
"source_unavailable": "Репозиторий недоступен с сервера.",
"target_invalid": "Выбранная версия недоступна или изменилась. Обновите список и повторите.",
"no_releases": "В источнике нет стабильных релизных тегов.",
"config_conflict": "Конфигурация изменена другим администратором. Обновите страницу и повторите правки.",
"config_invalid": "Недопустимое значение или конфигурация Compose. Изменения не сохранены.",
"config_readonly": "Этот параметр нельзя менять через интерфейс.",
"agent_error": "Агент не смог выполнить операцию. Проверьте journalctl -u pipu-management.",
"request_expired": "Запрос устарел. Проверьте статус перед повтором.",
"request_invalid": "Недопустимый запрос управления сервером.",
"operation_invalid": "Операция управления сервером не поддерживается."
}
}
+2
View File
@@ -23,6 +23,7 @@ import (
"pipu/api/internal/buildinfo"
"pipu/api/internal/genius"
"pipu/api/internal/library"
"pipu/api/internal/management"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx"
@@ -172,6 +173,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Client: &http.Client{}}, libStore, dto, log)
wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
Genius: genius.NewHandler(geniusSvc),
Log: log,
System: sys,
File diff suppressed because it is too large. Load diff
+14 -3
View File
@@ -78,6 +78,7 @@ type CreateUserInput struct {
Username string
DisplayName string
Role Role
ManagesUpdates bool
ReviewsUploads bool
}
@@ -89,7 +90,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
return User{}, "", forbidden()
}
if in.ReviewsUploads && p.Role != RoleAdmin {
if (in.ReviewsUploads || in.ManagesUpdates) && p.Role != RoleAdmin {
return User{}, "", forbidden()
}
username := NormalizeUsername(in.Username)
@@ -114,7 +115,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
}
u := User{
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role,
ReviewsUploads: in.ReviewsUploads, Locale: locale,
ReviewsUploads: in.ReviewsUploads, ManagesUpdates: in.ManagesUpdates, Locale: locale,
MustChangePassword: true, CreatedAt: s.clock.Now(),
}
err = s.store.WithTx(ctx, func(tx Store) error {
@@ -123,7 +124,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
}
return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID,
Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads},
Payload: map[string]any{"username": username, "role": string(in.Role), "reviews_uploads": in.ReviewsUploads, "manages_updates": in.ManagesUpdates},
})
})
if errors.Is(err, ErrUsernameTaken) {
@@ -154,6 +155,7 @@ type UserPatch struct {
DisplayName *string
Role *Role
IsBlocked *bool
ManagesUpdates *bool
ReviewsUploads *bool
}
@@ -218,6 +220,15 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
upd.ReviewsUploads = patch.ReviewsUploads
audit("user.reviews_uploads", map[string]any{"reviews_uploads": *patch.ReviewsUploads})
}
if patch.ManagesUpdates != nil {
if p.Role != RoleAdmin {
return forbidden()
}
if *patch.ManagesUpdates != target.ManagesUpdates {
upd.ManagesUpdates = patch.ManagesUpdates
audit("user.manages_updates", map[string]any{"manages_updates": *patch.ManagesUpdates})
}
}
blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
if blockChange {
if !CanManageUser(actor, tgt, UserBlock) {
+5 -1
View File
@@ -44,6 +44,9 @@ func (h *Handler) CreateUser(ctx context.Context, req gen.CreateUserRequestObjec
return nil, err
}
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)}
if req.Body.ManagesUpdates != nil {
in.ManagesUpdates = *req.Body.ManagesUpdates
}
if req.Body.ReviewsUploads != nil {
in.ReviewsUploads = *req.Body.ReviewsUploads
}
@@ -63,7 +66,7 @@ func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObjec
if err != nil {
return nil, err
}
patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads}
patch := UserPatch{Username: req.Body.Username, DisplayName: req.Body.DisplayName, IsBlocked: req.Body.IsBlocked, ReviewsUploads: req.Body.ReviewsUploads, ManagesUpdates: req.Body.ManagesUpdates}
if req.Body.Role != nil {
r := Role(*req.Body.Role)
patch.Role = &r
@@ -151,6 +154,7 @@ func AdminUserDTO(u User) gen.AdminUser {
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: updatePermission(u),
Locale: u.Locale,
IsBlocked: u.IsBlocked,
MustChangePassword: u.MustChangePassword,
+22
View File
@@ -448,3 +448,25 @@ func TestReviewUploadsGrant(t *testing.T) {
me = e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod))
assert.Equal(t, true, me.body["reviews_uploads"])
}
func TestManageUpdatesGrantAndRevoke(t *testing.T) {
e := newEnv(t)
e.addUser("root", "root-password-1", auth.RoleAdmin)
admin := e.signIn("root", "root-password-1")
target := e.addUser("mod", "mod-password-1", auth.RoleModerator)
mod := e.signIn("mod", "mod-password-1")
path := "/api/v1/admin/users/" + target.ID.String()
grant := map[string]any{"manages_updates": true}
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(admin)).body["manages_updates"])
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPatch, path, grant, bearer(mod)).code())
assert.Equal(t, auth.CodeForbidden, e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(mod)).code())
r := e.do(http.MethodPatch, path, grant, bearer(admin))
require.Equal(t, http.StatusOK, r.Code, r.Body.String())
assert.Equal(t, true, r.body["manages_updates"])
assert.Equal(t, true, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
r = e.do(http.MethodPatch, path, map[string]any{"manages_updates": false}, bearer(admin))
require.Equal(t, http.StatusOK, r.Code)
assert.Equal(t, false, e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod)).body["manages_updates"])
r = e.do(http.MethodPost, "/api/v1/admin/users", map[string]any{"username": "delegate", "role": "user", "manages_updates": true}, bearer(admin))
require.Equal(t, http.StatusCreated, r.Code, r.Body.String())
}
@@ -317,6 +317,9 @@ func (s *MemStore) UpdateUser(ctx context.Context, id uuid.UUID, upd auth.UserUp
if upd.ReviewsUploads != nil {
u.ReviewsUploads = *upd.ReviewsUploads
}
if upd.ManagesUpdates != nil {
u.ManagesUpdates = *upd.ManagesUpdates
}
if upd.RevokeTokens {
u.TokenVersion++
}
+3
View File
@@ -232,6 +232,7 @@ func MeDTO(u User) gen.Me {
DisplayName: u.DisplayName,
Role: gen.Role(u.Role),
ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: updatePermission(u),
Locale: u.Locale,
UiPrefs: uiPrefsDTO(u.UIPrefs),
MustChangePassword: u.MustChangePassword,
@@ -271,3 +272,5 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
Normalize: p.Normalize,
}
}
func updatePermission(u User) *bool { v := u.ManagesUpdates || u.Role == RoleAdmin; return &v }
+2
View File
@@ -55,6 +55,7 @@ type User struct {
DisplayName *string
PasswordHash string
Role Role
ManagesUpdates bool
ReviewsUploads bool // аудит очереди на добавление; не следует из роли
Locale string
UIPrefs UIPrefs
@@ -96,6 +97,7 @@ type Session struct {
type Principal struct {
UserID uuid.UUID
Role Role
ManagesUpdates bool
ReviewsUploads bool
SessionID uuid.UUID
MustChangePassword bool
+7 -6
View File
@@ -40,7 +40,7 @@ func (s *PgStore) WithTx(ctx context.Context, fn func(Store) error) error {
const userColumns = `id, username, display_name, password_hash, role, locale, ui_prefs, must_change_password,
must_change_credentials, is_blocked, token_version, failed_login_count, locked_until, created_at, last_login_at,
reviews_uploads`
reviews_uploads, manages_updates`
func scanUser(row pgx.Row) (User, error) {
var (
@@ -50,7 +50,7 @@ func scanUser(row pgx.Row) (User, error) {
)
err := row.Scan(&u.ID, &u.Username, &u.DisplayName, &u.PasswordHash, &role, &u.Locale, &prefs,
&u.MustChangePassword, &u.MustChangeCredentials, &u.IsBlocked, &u.TokenVersion, &u.FailedLoginCount,
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt, &u.ReviewsUploads)
&u.LockedUntil, &u.CreatedAt, &u.LastLoginAt, &u.ReviewsUploads, &u.ManagesUpdates)
if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound
}
@@ -82,10 +82,10 @@ func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID)
}
_, err = s.q.Exec(ctx, `
INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs,
must_change_password, must_change_credentials, created_by, created_at, updated_at, reviews_uploads)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12)`,
must_change_password, must_change_credentials, created_by, created_at, updated_at, reviews_uploads, manages_updates)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11, $12, $13)`,
u.ID, u.Username, u.DisplayName, u.PasswordHash, string(u.Role), u.Locale, prefs,
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt, u.ReviewsUploads)
u.MustChangePassword, u.MustChangeCredentials, createdBy, u.CreatedAt, u.ReviewsUploads, u.ManagesUpdates)
if isUniqueViolation(err) {
return ErrUsernameTaken
}
@@ -347,10 +347,11 @@ func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate)
role = COALESCE($4::user_role, role),
is_blocked = COALESCE($5, is_blocked),
reviews_uploads = COALESCE($7, reviews_uploads),
manages_updates = COALESCE($9, manages_updates),
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
updated_at = now()
WHERE id = $1
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username))
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username, upd.ManagesUpdates))
if isUniqueViolation(err) {
return User{}, ErrUsernameTaken
}
+8
View File
@@ -16,6 +16,7 @@ const (
PermSuggestTracks // предлагать треки, видеть свои предложения
PermReviewUploads // очередь, одобрение, правка метаданных; даёт флаг, не роль
PermSystemStatus // статус рекомендательной системы, аудит
PermManageUpdates // обновление сервера; admin или выданное право
)
var permissions = map[Permission][]Role{
@@ -25,6 +26,7 @@ var permissions = map[Permission][]Role{
PermViewLibraryStatus: {RoleModerator, RoleAdmin},
PermSuggestTracks: {RoleModerator, RoleAdmin},
PermSystemStatus: {RoleAdmin},
PermManageUpdates: {RoleAdmin},
}
// Can сообщает, есть ли у роли глобальное право.
@@ -40,6 +42,12 @@ func Can(r Role, p Permission) bool {
// Allowed — право конкретного пользователя. Аудит загрузок не вытекает из роли admin.
func (p Principal) Allowed(perm Permission) bool {
if perm == PermManageUpdates {
return p.Role == RoleAdmin || p.ManagesUpdates
}
if perm == PermAdminPanel && p.ManagesUpdates {
return true
}
if perm == PermReviewUploads {
return p.ReviewsUploads
}
@@ -21,6 +21,7 @@ func TestCan(t *testing.T) {
{PermViewLibraryStatus, "library status", [3]bool{false, true, true}},
{PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}},
{PermSystemStatus, "system status", [3]bool{false, false, true}},
{PermManageUpdates, "manage updates", [3]bool{false, false, true}},
}
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
for _, row := range matrix {
+1
View File
@@ -369,6 +369,7 @@ func (s *Service) Authenticate(ctx context.Context, access string) (Principal, e
UserID: u.ID,
Role: u.Role,
ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: u.ManagesUpdates || u.Role == RoleAdmin,
SessionID: claims.SessionID,
MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials,
+1
View File
@@ -35,6 +35,7 @@ type UserUpdate struct {
DisplayName **string
Role *Role
IsBlocked *bool
ManagesUpdates *bool
ReviewsUploads *bool
RevokeTokens bool
}
@@ -0,0 +1,160 @@
//go:build integration
package dbtest
import (
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/library"
"pipu/api/internal/platform/db"
)
func TestLibraryDiagnostics(t *testing.T) {
pool := connect(t)
ctx := context.Background()
require.NoError(t, db.Migrate(ctx, pool))
store := library.NewStore(pool)
src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "diagnostics", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, store.DeleteSource(ctx, src.ID))
require.NoError(t, store.CleanupOrphans(ctx))
})
before, err := store.Counts(ctx)
require.NoError(t, err)
badID, err := store.SaveFile(ctx, src.ID, uuid.Nil, library.FileData{RelPath: "broken.mp3", Mtime: time.Now(), ScanError: "invalid audio"}, nil)
require.NoError(t, err)
counts, err := store.Counts(ctx)
require.NoError(t, err)
require.Equal(t, before.Errors+1, counts.Errors)
require.NoError(t, store.MarkMissing(ctx, []uuid.UUID{badID}, time.Now()))
counts, err = store.Counts(ctx)
require.NoError(t, err)
require.Equal(t, before.Errors, counts.Errors)
fileErrors, err := store.FileErrors(ctx, "", 200)
require.NoError(t, err)
for _, item := range fileErrors {
require.NotEqual(t, badID, item.ID, "absent files must not remain in read errors")
}
// Failed analysis jobs retain their place in diagnostics even without a file.
root := t.TempDir()
scanner := library.NewScanner(store, fakeProber{}, library.NewProgressBoard(), slog.New(slog.DiscardHandler))
tracks := map[uuid.UUID]bool{}
for _, name := range []string{"Artist - First.flac", "Artist - Second.flac"} {
require.NoError(t, os.WriteFile(filepath.Join(root, name), []byte(name), 0o644))
fileID, err := scanner.ScanFile(ctx, src, root, name)
require.NoError(t, err)
var trackID uuid.UUID
require.NoError(t, pool.QueryRow(ctx, `SELECT track_id FROM media_files WHERE id = $1`, fileID).Scan(&trackID))
tracks[trackID] = true
_, err = pool.Exec(ctx, `UPDATE analysis_jobs SET status = 'failed', error = 'audio is shorter than 1 s' WHERE track_id = $1`, trackID)
require.NoError(t, err)
if name == "Artist - Second.flac" {
_, err = pool.Exec(ctx, `DELETE FROM media_files WHERE id = $1`, fileID)
require.NoError(t, err)
}
}
var paths, missing int
after := uuid.Nil
for {
page, err := store.AnalysisErrors(ctx, after, 1)
require.NoError(t, err)
if len(page) == 0 {
break
}
item := page[0]
require.NotEqual(t, after, item.TrackID, "pagination must advance")
after = item.TrackID
if !tracks[item.TrackID] {
continue
}
require.Equal(t, "audio is shorter than 1 s", item.Error)
if item.RelPath == nil {
missing++
} else {
paths++
require.Equal(t, "Artist - First.flac", *item.RelPath)
require.Equal(t, src.ID, *item.SourceID)
}
}
require.Equal(t, 1, paths)
require.Equal(t, 1, missing)
}
func TestScanExcludedFolders(t *testing.T) {
pool := connect(t)
ctx := context.Background()
require.NoError(t, db.Migrate(ctx, pool))
store := library.NewStore(pool)
src, err := store.SaveSource(ctx, library.Source{ID: uuid.New(), Name: "exclusions", Type: library.SourceLocal, Status: "mounted", TagsFirst: true, NFSOptions: "ro"})
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, store.DeleteSource(ctx, src.ID))
require.NoError(t, store.CleanupOrphans(ctx))
})
root := t.TempDir()
for _, rel := range []string{"Downloads_PumpEars/Artist - Loop.wav", "Downloads_PumpEars/nested/Artist - Hit.wav", "Downloads_PumpEars-copy/Artist - Song.flac"} {
p := filepath.Join(root, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
require.NoError(t, os.WriteFile(p, []byte(rel), 0o644))
}
board := library.NewProgressBoard()
scanner := library.NewScanner(store, fakeProber{}, board, slog.New(slog.DiscardHandler))
scan := func() library.Progress {
require.NoError(t, scanner.Start(src, root, true))
require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 10*time.Millisecond)
p, _ := board.Get(src.ID)
require.Empty(t, p.Error)
return p
}
require.Equal(t, 3, scan().Found)
_, err = pool.Exec(ctx, `UPDATE analysis_jobs SET status = 'failed', error = 'too short' WHERE track_id IN (SELECT track_id FROM media_files WHERE source_id = $1)`, src.ID)
require.NoError(t, err)
before, err := store.AnalysisCounts(ctx)
require.NoError(t, err)
src.ExcludedDirs = []string{"Downloads_PumpEars"}
src, err = store.SaveSource(ctx, src)
require.NoError(t, err)
saved, err := store.GetSource(ctx, src.ID)
require.NoError(t, err)
require.Equal(t, src.ExcludedDirs, saved.ExcludedDirs)
counts, err := store.AnalysisCounts(ctx)
require.NoError(t, err)
require.Equal(t, before.Failed-2, counts.Failed, "old failed jobs from excluded folders disappear immediately")
_, err = store.RetryFailedAnalysis(ctx)
require.NoError(t, err)
var failed int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM analysis_jobs WHERE status = 'failed' AND track_id IN (SELECT track_id FROM media_files WHERE source_id = $1)`, src.ID).Scan(&failed))
require.Equal(t, 2, failed, "retry must not requeue excluded jobs")
p := scan()
require.Equal(t, 1, p.Found)
require.Equal(t, 1, p.ToProcess)
require.Zero(t, p.Missing)
files, err := store.Files(ctx, src.ID)
require.NoError(t, err)
require.Len(t, files, 3, "excluded records must be preserved")
for _, f := range files {
require.False(t, f.Missing, "excluded files must not be treated as absent")
}
// Literal matching must not treat % and _ as SQL wildcards.
for _, test := range []struct {
path string
excluded bool
}{{"100%_loops/a.wav", true}, {"100xxloops/a.wav", false}, {"100%_loops-copy/a.wav", false}} {
var excluded bool
require.NoError(t, pool.QueryRow(ctx, `SELECT library_path_excluded($1, ARRAY['100%_loops'])`, test.path).Scan(&excluded))
require.Equal(t, test.excluded, excluded)
}
src.ExcludedDirs = []string{}
src, err = store.SaveSource(ctx, src)
require.NoError(t, err)
require.Equal(t, 3, scan().Found, "clearing exclusions makes the subtree eligible again")
}
@@ -0,0 +1,60 @@
package library
import (
"context"
"encoding/base64"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
)
type analysisErrorStore struct {
SourceStore
rows []AnalysisError
after uuid.UUID
limit int
}
func (s *analysisErrorStore) AnalysisErrors(_ context.Context, after uuid.UUID, limit int) ([]AnalysisError, error) {
s.after, s.limit = after, limit
return s.rows, nil
}
func TestAnalysisErrorsPermissionsAndPagination(t *testing.T) {
first, second := uuid.New(), uuid.New()
store := &analysisErrorStore{rows: []AnalysisError{{TrackID: first, Title: "One"}, {TrackID: second, Title: "Two"}}}
h := &Handler{svc: &Service{SourceService: &SourceService{store: store}}}
limit := 1
req := gen.ListAnalysisErrorsRequestObject{Params: gen.ListAnalysisErrorsParams{Limit: &limit}}
for _, role := range []auth.Role{auth.RoleUser, auth.RoleModerator} {
_, err := h.ListAnalysisErrors(auth.WithPrincipal(context.Background(), auth.Principal{Role: role}), req)
require.Error(t, err)
require.Zero(t, store.limit, "unauthorized requests must not reach the store")
}
_, err := h.ListAnalysisErrors(context.Background(), req)
require.Error(t, err)
ctx := auth.WithPrincipal(context.Background(), auth.Principal{Role: auth.RoleAdmin})
resp, err := h.ListAnalysisErrors(ctx, req)
require.NoError(t, err)
page := resp.(gen.ListAnalysisErrors200JSONResponse)
require.Len(t, page.Items, 1)
require.Equal(t, first, page.Items[0].TrackId)
require.Nil(t, page.Items[0].RelPath, "a missing file must not hide the failed job")
require.Equal(t, 2, store.limit)
require.NotNil(t, page.NextCursor)
store.rows = store.rows[1:]
req.Params.Cursor = page.NextCursor
resp, err = h.ListAnalysisErrors(ctx, req)
require.NoError(t, err)
require.Equal(t, first, store.after)
require.Nil(t, resp.(gen.ListAnalysisErrors200JSONResponse).NextCursor)
for _, cursor := range []string{"!invalid", base64.RawURLEncoding.EncodeToString([]byte("not-a-uuid"))} {
req.Params.Cursor = &cursor
_, err = h.ListAnalysisErrors(ctx, req)
require.Error(t, err)
}
}
@@ -0,0 +1,50 @@
package library
import (
"path"
"strings"
"unicode/utf8"
"pipu/api/internal/platform/apperr"
)
// normalizeExcludedDirs accepts literal paths relative to the storage root, not globs.
func normalizeExcludedDirs(dirs []string) ([]string, error) {
invalid := func() ([]string, error) {
return nil, apperr.Validation("excluded_dirs", "validation.excluded_dirs_invalid", "use up to 100 relative folder paths without .., one per entry", nil)
}
if len(dirs) > 100 {
return invalid()
}
out := []string{}
seen := map[string]bool{}
for _, dir := range dirs {
dir = strings.ReplaceAll(strings.TrimSpace(dir), "\\", "/")
if dir == "" || strings.HasPrefix(dir, "/") || strings.ContainsAny(dir, "\x00\r\n") || utf8.RuneCountInString(dir) > 1000 || (len(dir) > 1 && dir[1] == ':') {
return invalid()
}
for _, part := range strings.Split(dir, "/") {
if part == ".." {
return invalid()
}
}
dir = path.Clean(dir)
if dir == "." {
return invalid()
}
if !seen[dir] {
out = append(out, dir)
seen[dir] = true
}
}
return out, nil
}
func pathExcluded(rel string, dirs []string) bool {
for _, dir := range dirs {
if rel == dir || strings.HasPrefix(rel, dir+"/") {
return true
}
}
return false
}
@@ -0,0 +1,85 @@
package library
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
)
func TestNormalizeExcludedDirs(t *testing.T) {
dirs, err := normalizeExcludedDirs([]string{" Downloads_PumpEars/ ", `Artist\Samples`, "Downloads_PumpEars", "Artist/./Samples", "100%_loops"})
require.NoError(t, err)
require.Equal(t, []string{"Downloads_PumpEars", "Artist/Samples", "100%_loops"}, dirs)
for _, invalid := range []string{"", ".", "./", "/etc", "../outside", "album/../samples", `C:\Downloads`, `\\server\share`, "folder\nother", strings.Repeat("a", 1001)} {
t.Run(invalid, func(t *testing.T) {
_, err := normalizeExcludedDirs([]string{invalid})
require.Error(t, err)
})
}
_, err = normalizeExcludedDirs(make([]string, 101))
require.Error(t, err)
dirs, err = normalizeExcludedDirs(nil)
require.NoError(t, err)
require.Empty(t, dirs)
}
func TestWalkPrunesExcludedSubtrees(t *testing.T) {
root := t.TempDir()
for _, rel := range []string{"Downloads_PumpEars/sample.wav", "Downloads_PumpEars/nested/sample.wav", "Downloads_PumpEars-copy/song.flac", "Artist/Downloads_PumpEars/song.flac", "Artist/Samples/loop.wav", "Artist/Album/song.flac", "100%_loops/loop.wav", "100xxloops/song.flac"} {
p := filepath.Join(root, filepath.FromSlash(rel))
require.NoError(t, os.MkdirAll(filepath.Dir(p), 0o755))
require.NoError(t, os.WriteFile(p, []byte("audio"), 0o644))
}
dirs := []string{"Downloads_PumpEars", "Artist/Samples", "100%_loops"}
var visited []string
found, err := walk(root, dirs, func(string) bool { return false }, func(_, _ int, current string) { visited = append(visited, current) })
require.NoError(t, err)
require.ElementsMatch(t, []string{"Downloads_PumpEars-copy/song.flac", "Artist/Downloads_PumpEars/song.flac", "Artist/Album/song.flac", "100xxloops/song.flac"}, rels(found))
for _, dir := range visited {
require.False(t, pathExcluded(dir, dirs), "excluded directories must not be visited")
}
// A point scan must reject an excluded path before touching storage or the filesystem.
_, err = (&Scanner{}).ScanFile(context.Background(), Source{ExcludedDirs: dirs}, root, "Downloads_PumpEars/sample.wav")
require.ErrorContains(t, err, "excluded scan folder")
}
type exclusionsSourceStore struct {
SourceStore
source Source
}
func (s *exclusionsSourceStore) GetSource(context.Context, uuid.UUID) (Source, error) {
return s.source, nil
}
func (s *exclusionsSourceStore) SaveSource(_ context.Context, src Source) (Source, error) {
s.source = src
return src, nil
}
func TestExcludedDirsPatchPreservesOmittedAndAllowsClearing(t *testing.T) {
id := uuid.New()
store := &exclusionsSourceStore{source: Source{ID: id, Name: "Base", Type: SourceLocal, Status: "mounted", ExcludedDirs: []string{"Downloads"}}}
board := NewProgressBoard()
svc := &SourceService{store: store, board: board}
ctx := auth.WithPrincipal(context.Background(), auth.Principal{Role: auth.RoleAdmin})
_, err := svc.UpdateSource(ctx, id, SourceInput{})
require.NoError(t, err)
require.Equal(t, []string{"Downloads"}, store.source.ExcludedDirs)
empty := []string{}
_, err = svc.UpdateSource(ctx, id, SourceInput{ExcludedDirs: &empty})
require.NoError(t, err)
require.Empty(t, store.source.ExcludedDirs)
board.start(id, false, time.Now())
dirs := []string{"Downloads_PumpEars"}
_, err = svc.UpdateSource(ctx, id, SourceInput{ExcludedDirs: &dirs})
require.Error(t, err)
require.Empty(t, store.source.ExcludedDirs, "a running scan must not race with exclusions changes")
}
+42 -2
View File
@@ -11,6 +11,8 @@ import (
"os"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/library/meta"
"pipu/api/internal/platform/apperr"
@@ -59,11 +61,15 @@ func (d DTO) Tracks(ctx context.Context, rows []TrackRow) ([]gen.Track, error) {
}
func (h *Handler) sourceDTO(s Source) gen.Source {
if s.ExcludedDirs == nil {
s.ExcludedDirs = []string{}
}
out := gen.Source{
Id: s.ID, Name: s.Name, Type: gen.SourceType(s.Type), NfsServer: s.NFSServer, NfsExport: s.NFSExport,
NfsOptions: s.NFSOptions, LocalPath: s.LocalPath, TagPriority: gen.TagsFirst,
Status: gen.SourceStatus(s.Status), StatusMessage: s.StatusMessage, LastScanAt: s.LastScanAt,
CreatedAt: s.CreatedAt, AcceptsUploads: s.AcceptsUploads, WriteTemplate: s.WriteTemplate,
ExcludedDirs: &s.ExcludedDirs,
}
if !s.TagsFirst {
out.TagPriority = gen.PathFirst
@@ -80,7 +86,9 @@ func (h *Handler) sourceDTO(s Source) gen.Source {
}
func progressDTO(p Progress) gen.ScanProgress {
phase := gen.ScanProgressPhase(p.Phase)
out := gen.ScanProgress{
Phase: &phase, WalkedDirs: &p.WalkedDirs, CurrentDir: &p.CurrentDir,
Running: p.Running, Full: p.Full, StartedAt: p.StartedAt, FinishedAt: p.FinishedAt, Found: p.Found,
ToProcess: p.ToProcess, Processed: p.Processed, Added: p.Added, Updated: p.Updated, Moved: p.Moved,
Missing: p.Missing, Errors: p.Errors,
@@ -118,7 +126,7 @@ func (h *Handler) CreateSource(ctx context.Context, req gen.CreateSourceRequestO
src, err := h.svc.CreateSource(ctx, SourceInput{
Name: &b.Name, Type: SourceType(b.Type), NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions,
LocalPath: b.LocalPath, TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
})
if err != nil {
return nil, err
@@ -141,7 +149,7 @@ func (h *Handler) UpdateSource(ctx context.Context, req gen.UpdateSourceRequestO
src, err := h.svc.UpdateSource(ctx, req.Id, SourceInput{
Name: b.Name, NFSServer: b.NfsServer, NFSExport: b.NfsExport, NFSOptions: b.NfsOptions, LocalPath: b.LocalPath,
TagsFirst: tagsFirst(b.TagPriority), ScanIntervalMinutes: b.ScanIntervalMinutes, Enabled: b.Enabled,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate,
AcceptsUploads: b.AcceptsUploads, WriteTemplate: b.WriteTemplate, ExcludedDirs: b.ExcludedDirs,
})
if err != nil {
return nil, err
@@ -365,6 +373,38 @@ func (h *Handler) ListLibraryErrors(ctx context.Context, req gen.ListLibraryErro
return out, nil
}
// ListAnalysisErrors implements the paginated analysis diagnostics endpoint.
func (h *Handler) ListAnalysisErrors(ctx context.Context, req gen.ListAnalysisErrorsRequestObject) (gen.ListAnalysisErrorsResponseObject, error) {
limit := 50
if req.Params.Limit != nil {
limit = min(max(*req.Params.Limit, 1), 200)
}
after := uuid.Nil
if req.Params.Cursor != nil {
raw, err := base64.RawURLEncoding.DecodeString(*req.Params.Cursor)
if err == nil {
after, err = uuid.Parse(string(raw))
}
if err != nil {
return nil, apperr.Validation("cursor", "validation.cursor_invalid", "malformed cursor", nil)
}
}
rows, err := h.svc.AnalysisErrors(ctx, after, limit+1)
if err != nil {
return nil, err
}
out := gen.ListAnalysisErrors200JSONResponse{Items: []gen.AnalysisError{}}
if len(rows) > limit {
rows = rows[:limit]
next := base64.RawURLEncoding.EncodeToString([]byte(rows[limit-1].TrackID.String()))
out.NextCursor = &next
}
for _, r := range rows {
out.Items = append(out.Items, gen.AnalysisError{TrackId: r.TrackID, Title: r.Title, SourceId: r.SourceID, SourceName: r.SourceName, RelPath: r.RelPath, Error: r.Error})
}
return out, nil
}
// ListHostDirs реализует gen.StrictServerInterface.
func (h *Handler) ListHostDirs(ctx context.Context, req gen.ListHostDirsRequestObject) (gen.ListHostDirsResponseObject, error) {
rel := ""
+42 -8
View File
@@ -28,6 +28,9 @@ const MissingRetention = 30 * 24 * time.Hour
// Progress — состояние скана одного источника (для админки).
type Progress struct {
Phase string
WalkedDirs int
CurrentDir string
SourceID uuid.UUID
Running bool
Full bool
@@ -82,7 +85,7 @@ func (b *ProgressBoard) start(id uuid.UUID, full bool, now time.Time) bool {
if p, ok := b.m[id]; ok && p.Running {
return false
}
b.m[id] = &Progress{SourceID: id, Running: true, Full: full, StartedAt: now}
b.m[id] = &Progress{SourceID: id, Running: true, Full: full, StartedAt: now, Phase: "walking"}
return true
}
@@ -202,14 +205,24 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
if err != nil {
return err
}
found, err := walk(root, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip })
// Excluded files keep their catalog rows and are not treated as missing or rename candidates.
for rel := range existing {
if pathExcluded(rel, src.ExcludedDirs) {
delete(existing, rel)
}
}
found, err := walk(root, src.ExcludedDirs, func(rel string) bool { return meta.MatchPath(patterns, rel).Skip }, func(found, dirs int, currentDir string) {
sc.board.update(src.ID, func(p *Progress) {
p.Found, p.WalkedDirs, p.CurrentDir = found, dirs, currentDir
})
})
if err != nil {
return err
}
if len(found) == 0 && hasPresent(existing) {
return ErrSourceEmpty
}
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
sc.board.update(src.ID, func(p *Progress) { p.Found, p.Phase = len(found), "preparing" })
var (
jobs []job
@@ -256,11 +269,12 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
for _, w := range rest {
jobs = append(jobs, job{walked: w})
}
sc.board.update(src.ID, func(p *Progress) { p.ToProcess, p.Moved = len(jobs), len(moves) })
sc.board.update(src.ID, func(p *Progress) { p.ToProcess, p.Moved, p.Phase = len(jobs), len(moves), "processing" })
if err := sc.process(ctx, src, root, patterns, jobs); err != nil {
return err
}
sc.board.update(src.ID, func(p *Progress) { p.Phase = "finalizing" })
var goneIDs []uuid.UUID
affected := map[uuid.UUID]bool{}
@@ -291,6 +305,9 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
// ScanFile — точечный скан одного файла (scan_path): после импорта из очереди трек сразу появляется в каталоге.
// Возвращает id записи media_files. Хуки AfterScan (обложки, поиск, отпечатки) вызывает сам.
func (sc *Scanner) ScanFile(ctx context.Context, src Source, root, rel string) (uuid.UUID, error) {
if pathExcluded(filepath.ToSlash(rel), src.ExcludedDirs) {
return uuid.Nil, fmt.Errorf("library: file is in an excluded scan folder: %s", rel)
}
rows, err := sc.store.Patterns(ctx, src.ID)
if err != nil {
return uuid.Nil, err
@@ -462,11 +479,12 @@ func (sc *Scanner) apply(ctx context.Context, src Source, patterns []meta.Patter
// walk обходит дерево источника и возвращает аудиофайлы. Скрытые файлы и каталоги пропускаются;
// skip — пути, которые шаблоны велят игнорировать. Ошибки отдельных каталогов не прерывают обход.
func walk(root string, skip func(rel string) bool) ([]walked, error) {
func walk(root string, excludedDirs []string, skip func(rel string) bool, report func(found, dirs int, currentDir string)) ([]walked, error) {
if _, err := os.Stat(root); err != nil {
return nil, fmt.Errorf("library: source root: %w", err)
}
var out []walked
dirs := 0
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
if p == root {
@@ -481,14 +499,27 @@ func walk(root string, skip func(rel string) bool) ([]walked, error) {
}
return nil
}
if d.IsDir() || !IsAudio(name) {
return nil
}
rel, err := filepath.Rel(root, p)
if err != nil {
return nil
}
rel = filepath.ToSlash(rel)
if pathExcluded(rel, excludedDirs) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if d.IsDir() {
dirs++
if report != nil {
report(len(out), dirs, rel)
}
return nil
}
if !IsAudio(name) {
return nil
}
if skip(rel) {
return nil
}
@@ -498,6 +529,9 @@ func walk(root string, skip func(rel string) bool) ([]walked, error) {
}
// PostgreSQL хранит время с точностью до микросекунд.
out = append(out, walked{rel: rel, size: info.Size(), mtime: info.ModTime().UTC().Truncate(time.Microsecond)})
if report != nil {
report(len(out), dirs, filepath.ToSlash(filepath.Dir(rel)))
}
return nil
})
if err != nil {
+34 -2
View File
@@ -55,7 +55,7 @@ func TestWalkAndHash(t *testing.T) {
write("_incoming/new.mp3", 10)
write("big.mp3", 3<<20)
found, err := walk(root, func(rel string) bool { return rel == "_incoming/new.mp3" })
found, err := walk(root, nil, func(rel string) bool { return rel == "_incoming/new.mp3" }, nil)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"Artist/Album/01 - Song.FLAC", "big.mp3"}, rels(found))
for _, w := range found {
@@ -69,10 +69,42 @@ func TestWalkAndHash(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, h1, h2, "hash survives a rename")
_, err = walk(filepath.Join(root, "nope"), func(string) bool { return false })
_, err = walk(filepath.Join(root, "nope"), nil, func(string) bool { return false }, nil)
assert.Error(t, err)
}
func TestWalkReportsIncrementalProgress(t *testing.T) {
root := t.TempDir()
for _, dir := range []string{"a-empty", "b-album", ".hidden"} {
require.NoError(t, os.MkdirAll(filepath.Join(root, dir), 0o755))
}
for _, rel := range []string{"b-album/1.mp3", "b-album/2.flac", "b-album/skip.mp3", ".hidden/3.mp3"} {
require.NoError(t, os.WriteFile(filepath.Join(root, filepath.FromSlash(rel)), []byte("audio"), 0o644))
}
var counts, folders []int
var paths []string
found, err := walk(root, nil, func(rel string) bool { return rel == "b-album/skip.mp3" }, func(n, dirs int, path string) {
counts, folders, paths = append(counts, n), append(folders, dirs), append(paths, path)
})
require.NoError(t, err)
require.Len(t, found, 2)
assert.Equal(t, []int{0, 0, 0, 1, 2}, counts, "reports activity before any file is found and after each accepted audio file")
assert.Equal(t, []int{1, 2, 3, 3, 3}, folders)
assert.Equal(t, []string{".", "a-empty", "b-album", "b-album", "b-album"}, paths)
}
func TestProgressDTOIncludesWalkActivity(t *testing.T) {
p := Progress{Phase: "walking", Running: true, Found: 7, WalkedDirs: 4, CurrentDir: "Artist/Album"}
dto := progressDTO(p)
require.NotNil(t, dto.Phase)
assert.Equal(t, "walking", string(*dto.Phase))
assert.Equal(t, p.Found, dto.Found)
require.NotNil(t, dto.WalkedDirs)
assert.Equal(t, p.WalkedDirs, *dto.WalkedDirs)
require.NotNil(t, dto.CurrentDir)
assert.Equal(t, p.CurrentDir, *dto.CurrentDir)
}
func TestParseFFprobe(t *testing.T) {
raw := []byte(`{
"streams": [
+32
View File
@@ -33,6 +33,7 @@ type SourceStore interface {
AnalysisCounts(context.Context) (AnalysisCounts, error)
WaveMetrics(context.Context) (WaveMetrics, error)
FileErrors(context.Context, string, int) ([]FileError, error)
AnalysisErrors(context.Context, uuid.UUID, int) ([]AnalysisError, error)
}
// SourceService owns source configuration, connectivity and diagnostics.
@@ -104,9 +105,17 @@ type SourceInput struct {
Enabled *bool
AcceptsUploads *bool
WriteTemplate *string // "" — убрать
ExcludedDirs *[]string
}
func (s *SourceService) apply(src *Source, in SourceInput) error {
if in.ExcludedDirs != nil {
dirs, err := normalizeExcludedDirs(*in.ExcludedDirs)
if err != nil {
return err
}
src.ExcludedDirs = dirs
}
if in.Name != nil {
name := strings.TrimSpace(*in.Name)
if name == "" || utf8.RuneCountInString(name) > 100 {
@@ -227,6 +236,11 @@ func (s *SourceService) UpdateSource(ctx context.Context, id uuid.UUID, in Sourc
if err := s.apply(&src, in); err != nil {
return Source{}, err
}
if !slices.Equal(before.ExcludedDirs, src.ExcludedDirs) && s.board != nil {
if progress, ok := s.board.Get(id); ok && progress.Running {
return Source{}, apperr.Conflict(CodeScanRunning, "wait for the running scan before changing excluded folders")
}
}
if src, err = s.store.SaveSource(ctx, src); err != nil {
return Source{}, err
}
@@ -386,6 +400,16 @@ func (s *SourceService) testRead(ctx context.Context, src Source) (TestResult, e
if err != nil {
return err
}
rel, err := filepath.Rel(root, p)
if err != nil {
return err
}
if pathExcluded(filepath.ToSlash(rel), src.ExcludedDirs) {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
if !d.IsDir() && IsAudio(d.Name()) {
n++
if n >= 1000 {
@@ -440,6 +464,14 @@ func (s *SourceService) FileErrors(ctx context.Context, after string, limit int)
return s.store.FileErrors(ctx, after, limit)
}
// AnalysisErrors lists failed analysis jobs for library administrators.
func (s *SourceService) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int) ([]AnalysisError, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return nil, err
}
return s.store.AnalysisErrors(ctx, after, limit)
}
// HostDirs — подпапки каталога LIBRARY_HOST для выбора local-хранилища.
func (s *SourceService) HostDirs(ctx context.Context, rel string) (string, []string, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
+62 -17
View File
@@ -45,6 +45,7 @@ type Source struct {
CreatedAt time.Time
AcceptsUploads bool
WriteTemplate *string
ExcludedDirs []string
}
// PatternRow — шаблон пути источника.
@@ -86,7 +87,7 @@ func newID() uuid.UUID { return uuid.Must(uuid.NewV7()) }
const sourceColumns = `id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority = 'tags_first',
extract(epoch from scan_interval)::bigint, status, status_message, last_scan_at, created_at, accepts_uploads,
write_template`
write_template, excluded_dirs`
func scanSource(row pgx.Row) (Source, error) {
var (
@@ -95,7 +96,7 @@ func scanSource(row pgx.Row) (Source, error) {
interval *int64
)
err := row.Scan(&s.ID, &s.Name, &typ, &s.NFSServer, &s.NFSExport, &s.NFSOptions, &s.LocalPath, &s.TagsFirst,
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate)
&interval, &s.Status, &s.StatusMessage, &s.LastScanAt, &s.CreatedAt, &s.AcceptsUploads, &s.WriteTemplate, &s.ExcludedDirs)
if errors.Is(err, pgx.ErrNoRows) {
return Source{}, ErrNotFound
}
@@ -150,17 +151,20 @@ func tagPriority(tagsFirst bool) string {
// SaveSource создаёт или полностью обновляет хранилище (кроме статуса и времени скана).
func (st *Store) SaveSource(ctx context.Context, s Source) (Source, error) {
if s.ExcludedDirs == nil {
s.ExcludedDirs = []string{}
}
return scanSource(st.pool.QueryRow(ctx, `
INSERT INTO library_sources (id, name, type, nfs_server, nfs_export, nfs_options, local_path, tag_priority,
scan_interval, status, status_message, accepts_uploads, write_template, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, now())
scan_interval, status, status_message, accepts_uploads, write_template, excluded_dirs, created_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, make_interval(secs => $9::bigint), $10, $11, $12, $13, $14, now())
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, nfs_server = EXCLUDED.nfs_server,
nfs_export = EXCLUDED.nfs_export, nfs_options = EXCLUDED.nfs_options, local_path = EXCLUDED.local_path,
tag_priority = EXCLUDED.tag_priority, scan_interval = EXCLUDED.scan_interval,
accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template
accepts_uploads = EXCLUDED.accepts_uploads, write_template = EXCLUDED.write_template, excluded_dirs = EXCLUDED.excluded_dirs
RETURNING `+sourceColumns,
s.ID, s.Name, string(s.Type), s.NFSServer, s.NFSExport, s.NFSOptions, s.LocalPath, tagPriority(s.TagsFirst),
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate))
intervalSeconds(s.ScanInterval), s.Status, s.StatusMessage, s.AcceptsUploads, s.WriteTemplate, s.ExcludedDirs))
}
// SetSourceStatus обновляет состояние подключения.
@@ -263,7 +267,9 @@ func (st *Store) FileByPath(ctx context.Context, sourceID uuid.UUID, rel string)
// SampleFiles — случайная выборка путей источника (для предпросмотра шаблонов).
func (st *Store) SampleFiles(ctx context.Context, sourceID uuid.UUID, n int) ([]string, error) {
rows, err := st.pool.Query(ctx, `
SELECT rel_path FROM media_files WHERE source_id = $1 AND missing_since IS NULL ORDER BY random() LIMIT $2`,
SELECT f.rel_path FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.source_id = $1 AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
ORDER BY random() LIMIT $2`,
sourceID, n)
if err != nil {
return nil, fmt.Errorf("library: sample files: %w", err)
@@ -342,7 +348,9 @@ func (st *Store) MarkMissing(ctx context.Context, ids []uuid.UUID, at time.Time)
// PurgeMissing удаляет файлы, пропавшие раньше before.
func (st *Store) PurgeMissing(ctx context.Context, sourceID uuid.UUID, before time.Time) (int64, error) {
tag, err := st.pool.Exec(ctx, `DELETE FROM media_files WHERE source_id = $1 AND missing_since < $2`,
tag, err := st.pool.Exec(ctx, `DELETE FROM media_files f USING library_sources s
WHERE f.source_id = s.id AND f.source_id = $1 AND f.missing_since < $2
AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)`,
sourceID, before)
if err != nil {
return 0, fmt.Errorf("library: purge missing: %w", err)
@@ -511,7 +519,8 @@ func (st *Store) Counts(ctx context.Context) (Counts, error) {
err := st.pool.QueryRow(ctx, `
SELECT (SELECT count(*) FROM artists), (SELECT count(*) FROM albums), (SELECT count(*) FROM tracks),
(SELECT count(*) FROM media_files), (SELECT count(*) FROM media_files WHERE missing_since IS NOT NULL),
(SELECT count(*) FROM media_files WHERE scan_error IS NOT NULL)`).
(SELECT count(*) FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.scan_error IS NOT NULL AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs))`).
Scan(&c.Artists, &c.Albums, &c.Tracks, &c.Files, &c.Missing, &c.Errors)
if err != nil {
return Counts{}, fmt.Errorf("library: counts: %w", err)
@@ -530,8 +539,9 @@ type FileError struct {
// FileErrors — страница файлов с ошибками чтения (по пути, после after).
func (st *Store) FileErrors(ctx context.Context, after string, limit int) ([]FileError, error) {
rows, err := st.pool.Query(ctx, `
SELECT id, source_id, rel_path, scan_error FROM media_files
WHERE scan_error IS NOT NULL AND rel_path > $1 ORDER BY rel_path LIMIT $2`, after, limit)
SELECT f.id, f.source_id, f.rel_path, f.scan_error FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.scan_error IS NOT NULL AND f.missing_since IS NULL AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
AND f.rel_path > $1 ORDER BY f.rel_path LIMIT $2`, after, limit)
if err != nil {
return nil, fmt.Errorf("library: file errors: %w", err)
}
@@ -551,7 +561,7 @@ func (st *Store) AlbumsWithoutCover(ctx context.Context, limit int) ([]CoverCand
SELECT mf.source_id, mf.rel_path FROM tracks t
JOIN media_files mf ON mf.id = t.primary_file_id
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE t.album_id = al.id
WHERE t.album_id = al.id AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
ORDER BY t.disc_no NULLS FIRST, t.track_no NULLS LAST
LIMIT 1) f
WHERE al.cover_checked_at IS NULL
@@ -612,23 +622,56 @@ type AnalysisCounts struct {
func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
var c AnalysisCounts
err := st.pool.QueryRow(ctx, `
WITH jobs AS (SELECT * FROM analysis_jobs WHERE NOT library_track_scan_excluded(track_id))
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
(SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL
(SELECT error FROM jobs WHERE status = 'failed' AND error IS NOT NULL
GROUP BY error ORDER BY count(*) DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
FROM jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
if err != nil {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
}
return c, nil
}
// RetryFailedAnalysis возвращает неудавшиеся задачи анализа в очередь с нуля попыток.
// AnalysisError is a failed analysis job with a known file path, when available.
type AnalysisError struct {
TrackID uuid.UUID
Title string
SourceID *uuid.UUID
SourceName *string
RelPath *string
Error string
}
// AnalysisErrors includes failed jobs even when their file is no longer available.
func (st *Store) AnalysisErrors(ctx context.Context, after uuid.UUID, limit int) ([]AnalysisError, error) {
rows, err := st.pool.Query(ctx, `
SELECT t.id, t.title, f.source_id, s.name, f.rel_path, COALESCE(a.error, '')
FROM analysis_jobs a JOIN tracks t ON t.id = a.track_id
LEFT JOIN LATERAL (
SELECT mf.source_id, mf.rel_path FROM media_files mf JOIN library_sources src ON src.id = mf.source_id
WHERE mf.track_id = t.id AND NOT library_path_excluded(mf.rel_path, src.excluded_dirs)
ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, (mf.missing_since IS NULL) DESC, mf.id
LIMIT 1
) f ON true
LEFT JOIN library_sources s ON s.id = f.source_id
WHERE a.status = 'failed' AND NOT library_track_scan_excluded(t.id) AND t.id > $1 ORDER BY t.id LIMIT $2`, after, limit)
if err != nil {
return nil, fmt.Errorf("library: analysis errors: %w", err)
}
return pgx.CollectRows(rows, func(r pgx.CollectableRow) (AnalysisError, error) {
var e AnalysisError
return e, r.Scan(&e.TrackID, &e.Title, &e.SourceID, &e.SourceName, &e.RelPath, &e.Error)
})
}
// RetryFailedAnalysis returns failed jobs to the queue with attempts reset.
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
tag, err := st.pool.Exec(ctx, `
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
WHERE status = 'failed'`)
WHERE status = 'failed' AND NOT library_track_scan_excluded(track_id)`)
if err != nil {
return 0, fmt.Errorf("library: retry analysis: %w", err)
}
@@ -717,6 +760,7 @@ func (st *Store) FilesToFingerprint(ctx context.Context, withFP bool, after uuid
FROM media_files mf
JOIN library_sources s ON s.id = mf.source_id AND s.status = 'mounted'
WHERE mf.missing_since IS NULL AND mf.scan_error IS NULL
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
AND (mf.sha256 IS NULL OR ($1 AND mf.fingerprinted_at IS NULL)) AND mf.id > $2
ORDER BY mf.id
LIMIT $3`, withFP, after, limit)
@@ -770,7 +814,8 @@ func (st *Store) TemplateSamples(ctx context.Context, sourceID uuid.UUID, n int)
JOIN tracks t ON t.id = mf.track_id
JOIN albums al ON al.id = t.album_id
JOIN artists aa ON aa.id = al.album_artist_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL
JOIN library_sources src ON src.id = mf.source_id
WHERE mf.source_id = $1 AND mf.missing_since IS NULL AND NOT library_path_excluded(mf.rel_path, src.excluded_dirs)
ORDER BY random() LIMIT $2`, sourceID, n)
if err != nil {
return nil, fmt.Errorf("library: template samples: %w", err)
+1 -1
View File
@@ -116,7 +116,7 @@ func (s *TemplateService) Preview(ctx context.Context, id uuid.UUID, patterns []
return nil, 0, err
}
if len(paths) == 0 {
found, err := walk(root, func(string) bool { return false })
found, err := walk(root, src.ExcludedDirs, func(string) bool { return false }, nil)
if err != nil {
return nil, 0, apperr.New(http.StatusConflict, CodeSourceOffline, err.Error())
}
+148
View File
@@ -0,0 +1,148 @@
// Package management authorizes server administration and bridges fixed operations
// to the host worker. The API never receives a Docker socket or executes a shell.
package management
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/platform/apperr"
)
type Bridge interface {
Call(context.Context, string, string, any, any) error
}
type Mailbox struct{ Dir string }
func (m Mailbox) Call(ctx context.Context, actor, operation string, body, out any) error {
heartbeat, err := os.ReadFile(filepath.Join(m.Dir, "heartbeat"))
if err != nil {
return apperr.New(503, "management.unavailable", "host agent is not installed or running")
}
at, err := strconv.ParseFloat(strings.TrimSpace(string(heartbeat)), 64)
if err != nil || time.Since(time.Unix(int64(at), 0)) > 60*time.Second {
return apperr.New(503, "management.unavailable", "host agent heartbeat expired")
}
id := uuid.NewString()
request := filepath.Join(m.Dir, id+".request.json")
response := filepath.Join(m.Dir, id+".response.json")
raw, err := json.Marshal(map[string]any{"operation": operation, "actor": actor, "body": body, "created_at": time.Now().Unix()})
if err != nil {
return err
}
tmp, err := os.CreateTemp(m.Dir, ".request-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err = tmp.Write(raw); err != nil {
tmp.Close()
return err
}
if err = tmp.Close(); err != nil {
return err
}
if err = os.Rename(tmp.Name(), request); err != nil {
return err
}
defer os.Remove(response)
defer os.Remove(request)
ctx, cancel := context.WithTimeout(ctx, 40*time.Second)
defer cancel()
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return apperr.New(504, "management.timeout", "host agent did not respond; check status before retrying")
case <-ticker.C:
raw, err := os.ReadFile(response)
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return err
}
var result struct {
Data json.RawMessage `json:"data"`
Code string `json:"code"`
Status int `json:"status"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return err
}
if result.Code != "" {
status := result.Status
if status < 400 || status > 599 {
status = 502
}
return apperr.New(status, result.Code, "host management operation failed")
}
return json.Unmarshal(result.Data, out)
}
}
}
type Handler struct{ bridge Bridge }
func NewHandler(bridge Bridge) *Handler { return &Handler{bridge: bridge} }
func (h *Handler) call(ctx context.Context, admin bool, operation string, body, out any) error {
p, ok := auth.PrincipalFrom(ctx)
if !ok {
return apperr.Unauthorized(auth.CodeUnauthorized, "authentication required")
}
if (admin && p.Role != auth.RoleAdmin) || (!admin && !p.Allowed(auth.PermManageUpdates)) {
return apperr.Forbidden(auth.CodeForbidden, "server management permission required")
}
return h.bridge.Call(ctx, p.UserID.String(), operation, body, out)
}
func (h *Handler) GetServerUpdates(ctx context.Context, req gen.GetServerUpdatesRequestObject) (gen.GetServerUpdatesResponseObject, error) {
var out gen.ServerUpdates
err := h.call(ctx, false, "updates.get", map[string]any{"source_id": req.Params.SourceId}, &out)
out.CurrentVersion = buildinfo.Version
return gen.GetServerUpdates200JSONResponse(out), err
}
func (h *Handler) SaveServerUpdateSources(ctx context.Context, req gen.SaveServerUpdateSourcesRequestObject) (gen.SaveServerUpdateSourcesResponseObject, error) {
var out gen.ServerSources
err := h.call(ctx, false, "sources.save", req.Body, &out)
return gen.SaveServerUpdateSources200JSONResponse(out), err
}
func (h *Handler) StartServerUpdate(ctx context.Context, req gen.StartServerUpdateRequestObject) (gen.StartServerUpdateResponseObject, error) {
var out gen.ServerJob
err := h.call(ctx, false, "updates.start", req.Body, &out)
return gen.StartServerUpdate202JSONResponse(out), err
}
func (h *Handler) GetServerConfig(ctx context.Context, _ gen.GetServerConfigRequestObject) (gen.GetServerConfigResponseObject, error) {
var out gen.ServerConfig
err := h.call(ctx, true, "config.get", nil, &out)
return gen.GetServerConfig200JSONResponse(out), err
}
func (h *Handler) SaveServerConfig(ctx context.Context, req gen.SaveServerConfigRequestObject) (gen.SaveServerConfigResponseObject, error) {
var out gen.ServerConfig
err := h.call(ctx, true, "config.save", req.Body, &out)
return gen.SaveServerConfig200JSONResponse(out), err
}
func (h *Handler) ApplyServerConfig(ctx context.Context, _ gen.ApplyServerConfigRequestObject) (gen.ApplyServerConfigResponseObject, error) {
var out gen.ServerJob
err := h.call(ctx, true, "config.apply", nil, &out)
return gen.ApplyServerConfig202JSONResponse(out), err
}
@@ -0,0 +1,106 @@
package management_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"pipu/api/internal/auth"
"pipu/api/internal/management"
"pipu/api/internal/server"
)
type bridge struct{ calls int }
func (b *bridge) Call(_ context.Context, _, _ string, _, out any) error {
b.calls++
return json.Unmarshal([]byte(`{"sources":[],"versions":[],"current_version":"test","job":null,"source_error":"","entries":[],"revision":"r","restart_required":false}`), out)
}
func TestServerManagementAuthorization(t *testing.T) {
for _, tc := range []struct {
name string
principal auth.Principal
updates, config int
}{
{"user", auth.Principal{Role: auth.RoleUser}, 403, 403},
{"moderator", auth.Principal{Role: auth.RoleModerator}, 403, 403},
{"delegate", auth.Principal{Role: auth.RoleUser, ManagesUpdates: true}, 200, 403},
{"admin", auth.Principal{Role: auth.RoleAdmin}, 200, 200},
} {
t.Run(tc.name, func(t *testing.T) {
b := &bridge{}
router := server.NewRouter(server.Deps{Management: management.NewHandler(b)})
for _, op := range []struct {
method, path, body string
want int
}{
{"GET", "updates", "", tc.updates},
{"POST", "updates", `{"source_id":"origin","channel":"main"}`, tc.updates},
{"PUT", "update-sources", `{"sources":[]}`, tc.updates},
{"GET", "config", "", tc.config},
{"PATCH", "config", `{"revision":"r","values":{}}`, tc.config},
{"POST", "config/apply", "", tc.config},
} {
request := httptest.NewRequest(op.method, "/api/v1/admin/server/"+op.path, strings.NewReader(op.body))
request.Header.Set("Content-Type", "application/json")
request = request.WithContext(auth.WithPrincipal(request.Context(), tc.principal))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, request)
want := op.want
if op.method == "POST" && want == 200 {
want = 202
}
require.Equal(t, want, rec.Code, rec.Body.String())
}
if tc.updates == 403 && tc.config == 403 {
require.Zero(t, b.calls)
}
})
}
t.Run("anonymous", func(t *testing.T) {
router := server.NewRouter(server.Deps{Management: management.NewHandler(&bridge{})})
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/server/config", nil))
require.Equal(t, 401, rec.Code)
})
}
func TestMailboxRoundTripAndUnavailable(t *testing.T) {
dir := t.TempDir()
m := management.Mailbox{Dir: dir}
var out map[string]any
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte("1"), 0600))
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), []byte(time.Now().Format("invalid")), 0600))
require.Error(t, m.Call(context.Background(), "actor", "config.get", nil, &out))
raw, _ := json.Marshal(time.Now().Unix())
require.NoError(t, os.WriteFile(filepath.Join(dir, "heartbeat"), raw, 0600))
done := make(chan error, 1)
go func() { done <- m.Call(context.Background(), "actor", "config.get", nil, &out) }()
var requests []string
require.Eventually(t, func() bool {
requests, _ = filepath.Glob(filepath.Join(dir, "*.request.json"))
return len(requests) == 1
}, time.Second, 10*time.Millisecond)
request, err := os.ReadFile(requests[0])
require.NoError(t, err)
var payload map[string]any
require.NoError(t, json.Unmarshal(request, &payload))
require.Equal(t, "config.get", payload["operation"])
require.Equal(t, "actor", payload["actor"])
response := strings.Replace(requests[0], ".request.json", ".response.json", 1)
require.NoError(t, os.WriteFile(response, []byte(`{"data":{"revision":"ok"}}`), 0600))
require.NoError(t, <-done)
require.Equal(t, "ok", out["revision"])
remaining, _ := filepath.Glob(filepath.Join(dir, "*.json"))
require.Empty(t, remaining)
}
@@ -10,6 +10,7 @@ import (
// API — конфигурация публичного сервиса api.
type API struct {
ManagementDir string `env:"MANAGEMENT_DIR" envDefault:"/management"`
HTTPAddr string `env:"HTTP_ADDR" envDefault:":8080"`
DatabaseURL string `env:"DATABASE_URL,required"`
RedisURL string `env:"REDIS_URL,required"`
+18 -14
View File
@@ -13,6 +13,7 @@ import (
"pipu/api/internal/auth"
"pipu/api/internal/genius"
"pipu/api/internal/library"
"pipu/api/internal/management"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/playback"
"pipu/api/internal/search"
@@ -23,10 +24,11 @@ import (
// Deps — модули, реализующие части контракта API.
type Deps struct {
Genius *genius.Handler
Log *slog.Logger
System *system.Handler
Auth *auth.Handler
Management *management.Handler
Genius *genius.Handler
Log *slog.Logger
System *system.Handler
Auth *auth.Handler
// Library — хранилища, сканы и каталог.
Library *library.Handler
Search *search.Handler
@@ -46,18 +48,20 @@ type Deps struct {
// По мере появления модулей (catalog, ...) они встраиваются сюда.
// Алиасы дают встроенным полям разные имена: у модулей одинаковое имя типа Handler.
type (
geniusHandler = genius.Handler
systemHandler = system.Handler
authHandler = auth.Handler
libraryHandler = library.Handler
searchHandler = search.Handler
playbackHandler = playback.Handler
waveHandler = wave.Handler
appHandler = appupdate.Handler
uploadsHandler = uploads.Handler
managementHandler = management.Handler
geniusHandler = genius.Handler
systemHandler = system.Handler
authHandler = auth.Handler
libraryHandler = library.Handler
searchHandler = search.Handler
playbackHandler = playback.Handler
waveHandler = wave.Handler
appHandler = appupdate.Handler
uploadsHandler = uploads.Handler
)
type handlers struct {
*managementHandler
*geniusHandler
*systemHandler
*authHandler
@@ -89,7 +93,7 @@ func NewRouter(d Deps) http.Handler {
d.TUS.Mount(r)
}
strict := gen.NewStrictHandlerWithOptions(handlers{geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
strict := gen.NewStrictHandlerWithOptions(handlers{managementHandler: d.Management, geniusHandler: d.Genius, systemHandler: d.System, authHandler: d.Auth, libraryHandler: d.Library, searchHandler: d.Search, playbackHandler: d.Playback, waveHandler: d.Wave, appHandler: d.App, uploadsHandler: d.Uploads},
[]gen.StrictMiddlewareFunc{httpx.StrictRequest}, gen.StrictHTTPServerOptions{
RequestErrorHandlerFunc: httpx.RequestError,
ResponseErrorHandlerFunc: httpx.ResponseError(log),
@@ -0,0 +1,29 @@
-- +goose Up
ALTER TABLE library_sources ADD COLUMN excluded_dirs text[] NOT NULL DEFAULT '{}';
-- +goose StatementBegin
CREATE FUNCTION library_path_excluded(rel_path text, excluded_dirs text[])
RETURNS boolean LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
SELECT EXISTS (
SELECT 1 FROM unnest(excluded_dirs) AS d(dir)
WHERE rel_path = dir OR starts_with(rel_path, dir || '/')
);
$$;
-- +goose StatementEnd
-- Keep historical jobs and files; only tracks whose every known file is excluded are suppressed.
-- +goose StatementBegin
CREATE FUNCTION library_track_scan_excluded(track uuid)
RETURNS boolean LANGUAGE sql STABLE AS $$
SELECT EXISTS (SELECT 1 FROM media_files WHERE track_id = track)
AND NOT EXISTS (
SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.track_id = track AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
);
$$;
-- +goose StatementEnd
-- +goose Down
DROP FUNCTION library_track_scan_excluded(uuid);
DROP FUNCTION library_path_excluded(text, text[]);
ALTER TABLE library_sources DROP COLUMN excluded_dirs;
@@ -0,0 +1,5 @@
-- +goose Up
ALTER TABLE users ADD COLUMN manages_updates boolean NOT NULL DEFAULT false;
-- +goose Down
ALTER TABLE users DROP COLUMN manages_updates;
@@ -93,9 +93,13 @@ class Worker:
WHERE j.track_id IN (
SELECT a.track_id FROM analysis_jobs a
JOIN tracks t ON t.id = a.track_id
JOIN media_files f ON f.id = t.primary_file_id
JOIN library_sources s ON s.id = f.source_id AND s.status = 'mounted'
WHERE a.status = 'queued'
AND EXISTS (
SELECT 1 FROM media_files f JOIN library_sources s ON s.id = f.source_id
WHERE f.track_id = t.id AND s.status = 'mounted'
AND f.missing_since IS NULL AND f.scan_error IS NULL
AND NOT library_path_excluded(f.rel_path, s.excluded_dirs)
)
ORDER BY a.updated_at
LIMIT %s
FOR UPDATE OF a SKIP LOCKED)
@@ -108,10 +112,17 @@ class Worker:
return []
files = conn.execute(
"""
SELECT t.id::text, s.id::text, s.type::text, s.local_path, f.rel_path
SELECT t.id::text, f.source_id::text, f.source_type, f.local_path, f.rel_path
FROM tracks t
JOIN media_files f ON f.id = t.primary_file_id
JOIN library_sources s ON s.id = f.source_id
CROSS JOIN LATERAL (
SELECT s.id AS source_id, s.type::text AS source_type, s.local_path, mf.rel_path
FROM media_files mf JOIN library_sources s ON s.id = mf.source_id
WHERE mf.track_id = t.id AND s.status = 'mounted'
AND mf.missing_since IS NULL AND mf.scan_error IS NULL
AND NOT library_path_excluded(mf.rel_path, s.excluded_dirs)
ORDER BY (mf.id = t.primary_file_id) DESC NULLS LAST, mf.id
LIMIT 1
) f
WHERE t.id = ANY(%s::uuid[])
""",
([r[0] for r in rows],),
@@ -122,6 +122,37 @@ def test_analysis_worker_saves_features(world: dict[str, Any]) -> None:
assert row is not None and row[0] == pytest.approx(120.0)
def test_analysis_worker_skips_excluded_folders_and_uses_allowed_copy(world: dict[str, Any]) -> None:
worker = Worker(DSN, "/library", "/library-host", 1)
blocked, alternative, allowed = world["tracks"][:3]
with psycopg.connect(DSN, autocommit=True) as conn:
source = conn.execute(
"SELECT source_id FROM media_files WHERE track_id = %s", (blocked,)
).fetchone()
assert source is not None
conn.execute(
"UPDATE library_sources SET excluded_dirs = ARRAY['Downloads_PumpEars'] WHERE id = %s",
(source[0],),
)
for track, rel in [(blocked, "Downloads_PumpEars/nested/loop.wav"), (alternative, "Downloads_PumpEars/loop.wav"), (allowed, "Downloads_PumpEars-copy/song.flac")]:
conn.execute("UPDATE media_files SET rel_path = %s WHERE track_id = %s", (rel, track))
conn.execute(
"INSERT INTO media_files (id, source_id, rel_path, size_bytes, mtime, format, duration_ms, track_id) "
"VALUES (%s, %s, 'Music/allowed-copy.flac', 1, now(), 'flac', 200000, %s)",
(uid(), source[0], alternative),
)
conn.execute(
"UPDATE analysis_jobs SET status = 'queued' WHERE track_id = ANY(%s::uuid[])",
([blocked, alternative, allowed],),
)
jobs = {job.track_id: job.path for job in worker.claim(conn, 10000)}
assert blocked not in jobs
assert jobs[alternative].replace("\\", "/").endswith("/Music/allowed-copy.flac")
assert jobs[allowed].replace("\\", "/").endswith("/Downloads_PumpEars-copy/song.flac")
row = conn.execute("SELECT attempts, status FROM analysis_jobs WHERE track_id = %s", (blocked,)).fetchone()
assert row == (0, 'queued'), "excluded files must not consume retry attempts"
def test_training_and_wave_queries(world: dict[str, Any]) -> None:
with psycopg.connect(DSN) as conn:
assert len(load_interactions(conn)) >= 60