Compare commits

...
8 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
mrleo1nid 0a59059738 feat(playlists): share playlists with revocable read-only links
ci / api / api (push) Successful in 55s
release / release (версия и заметки) (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 2m19s
release / android (APK в релиз) (push) Successful in 4m45s
release / desktop (Linux и Windows в релиз) (push) Successful in 20m58s
release / prune (старые файлы и прогоны) (push) Successful in 9s
ci / module boundaries / boundaries (push) Successful in 7s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m35s
ci / db / db (push) Successful in 59s
2026-10-22 14:57:14 +03:00
mrleo1nid bfcf6e48b2 fix: harmonize wave colors from album covers
ci / ts / ts (packages + web) (push) Canceled after 5m28s
ci / module boundaries / boundaries (push) Successful in 5s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
2026-10-22 14:52:57 +03:00
119 changed files with 6744 additions and 455 deletions

No files matched your search

+2
View File
@@ -20,6 +20,8 @@ __pycache__/
.ruff_cache/ .ruff_cache/
# env / secrets # env / secrets
deploy/config.local
deploy/.management.lock
.env .env
*.pem *.pem
*.keystore *.keystore
+2 -2
View File
@@ -5,6 +5,6 @@
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную. - Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации. - После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя. - Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе. - Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в 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`. - Справочник полных проверок (выбирай локально только относящиеся к изменению): `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`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы. - Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+322
View File
@@ -22,6 +22,104 @@ tags:
security: security:
- bearerAuth: [] - bearerAuth: []
paths: 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: /api/v1/admin/genius:
get: get:
operationId: getGeniusStats operationId: getGeniusStats
@@ -991,6 +1089,23 @@ paths:
$ref: "#/components/schemas/LibraryErrorPage" $ref: "#/components/schemas/LibraryErrorPage"
default: default:
$ref: "#/components/responses/Problem" $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: /api/v1/admin/library/host-dirs:
get: get:
operationId: listHostDirs operationId: listHostDirs
@@ -1707,6 +1822,54 @@ paths:
'204': '204':
description: Deleted description: Deleted
/api/v1/me/playlists/{id}/share:
parameters:
- in: path
name: id
required: true
schema: { type: string, format: uuid }
post:
tags: [collection]
operationId: sharePlaylist
summary: Создать или вернуть ссылку только для чтения (только владелец)
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход
content:
application/json:
schema:
$ref: '#/components/schemas/PlaylistShare'
delete:
tags: [collection]
operationId: revokePlaylistShare
summary: Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку
responses:
default:
$ref: '#/components/responses/Problem'
'204':
description: Доступ по прежней ссылке отключён
/api/v1/shared/playlists/{shareId}:
parameters:
- in: path
name: shareId
required: true
schema: { type: string, format: uuid }
get:
tags: [collection]
operationId: getSharedPlaylist
summary: Прочитать плейлист по действующей ссылке (требуется вход)
responses:
default:
$ref: '#/components/responses/Problem'
'200':
description: Плейлист с реакциями и ссылками на стрим для текущего слушателя
content:
application/json:
schema:
$ref: '#/components/schemas/Playlist'
components: components:
parameters: parameters:
SourceId: SourceId:
@@ -1767,6 +1930,90 @@ components:
schema: schema:
$ref: "#/components/schemas/Problem" $ref: "#/components/schemas/Problem"
schemas: 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]
properties:
share_id: { type: string, format: uuid }
PlaylistInput: PlaylistInput:
type: object type: object
required: [name, description, track_ids] required: [name, description, track_ids]
@@ -1803,6 +2050,12 @@ components:
- type: object - type: object
required: [tracks] required: [tracks]
properties: properties:
share_id:
type: string
format: uuid
nullable: true
readOnly: true
description: Действующая ссылка; возвращается только владельцу через /me/playlists
tracks: tracks:
type: array type: array
items: { $ref: '#/components/schemas/Track' } items: { $ref: '#/components/schemas/Track' }
@@ -2419,6 +2672,9 @@ components:
nullable: true nullable: true
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads: reviews_uploads:
type: boolean type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
@@ -2474,6 +2730,9 @@ components:
nullable: true nullable: true
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads: reviews_uploads:
type: boolean type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
@@ -2514,6 +2773,9 @@ components:
maxLength: 64 maxLength: 64
role: role:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads: reviews_uploads:
type: boolean type: boolean
description: Сразу выдать аудит загрузок. Может задать только админ description: Сразу выдать аудит загрузок. Может задать только админ
@@ -2540,6 +2802,9 @@ components:
$ref: "#/components/schemas/Role" $ref: "#/components/schemas/Role"
is_blocked: is_blocked:
type: boolean type: boolean
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads: reviews_uploads:
type: boolean type: boolean
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
@@ -2611,6 +2876,8 @@ components:
type: object 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] 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: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
id: id:
type: string type: string
format: uuid format: uuid
@@ -2671,6 +2938,8 @@ components:
type: object type: object
required: [name, type] required: [name, type]
properties: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name: name:
type: string type: string
maxLength: 100 maxLength: 100
@@ -2698,6 +2967,8 @@ components:
SourcePatch: SourcePatch:
type: object type: object
properties: properties:
excluded_dirs:
$ref: "#/components/schemas/ExcludedDirs"
name: name:
type: string type: string
maxLength: 100 maxLength: 100
@@ -2726,6 +2997,14 @@ components:
description: | description: |
Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid` Шаблон пути новых файлов; пусто — убрать. Неверный синтаксис — `422 validation.template_invalid`
с `params.reason`. Проверка обратимости не обязательна для сохранения — её показывает `write-template/check`. с `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: SourceTestResult:
type: object type: object
required: [ok, message, audio_files] required: [ok, message, audio_files]
@@ -2896,6 +3175,16 @@ components:
type: object type: object
required: [running, full, started_at, finished_at, found, to_process, processed, added, updated, moved, missing, errors, error] required: [running, full, started_at, finished_at, found, to_process, processed, added, updated, moved, missing, errors, error]
properties: 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: running:
type: boolean type: boolean
full: full:
@@ -2997,6 +3286,39 @@ components:
next_cursor: next_cursor:
type: string type: string
nullable: true 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: HostDirList:
type: object type: object
required: [path, dirs] required: [path, dirs]
+1
View File
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
sha2 = "0.10"
tokio = { version = "1", features = ["net", "sync", "time"] } tokio = { version = "1", features = ["net", "sync", "time"] }
axum = "0.8" axum = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream", "json"] }
+4 -1
View File
@@ -1,6 +1,7 @@
mod media; mod media;
mod proxy; mod proxy;
mod store; mod store;
mod update;
use media::Media; use media::Media;
use proxy::Proxy; use proxy::Proxy;
@@ -110,6 +111,7 @@ pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app))) .plugin(tauri_plugin_single_instance::init(|app, _, _| show_main(app)))
.plugin(tauri_plugin_window_state::Builder::default().build()) .plugin(tauri_plugin_window_state::Builder::default().build())
.manage(update::Updater::default())
.setup(|app| { .setup(|app| {
let saved = store::load_server(app.handle())?; let saved = store::load_server(app.handle())?;
let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?; let proxy = tauri::async_runtime::block_on(Proxy::start(saved.clone())).map_err(|e| e.to_string())?;
@@ -150,7 +152,8 @@ pub fn run() {
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .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!()) .run(tauri::generate_context!())
.expect("desktop app failed to start"); .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-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-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 { 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 { } else {
match transfer(&proxy, request, tail).await { match transfer(&proxy, request, tail).await {
Ok(response) => response, 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()); 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());
}
}
+17 -4
View File
@@ -1,11 +1,11 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { playlistShareUrl, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native'; import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
import { useSession, useUser } from '../lib/session'; import { useSession, useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller'; import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit'; import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
@@ -78,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
function Detail({ id }: { id: string }) { function Detail({ id }: { id: string }) {
const session = useSession(); const session = useSession();
const { server } = useSessionCtx();
const user = useUser(); const user = useUser();
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -89,6 +90,15 @@ function Detail({ id }: { id: string }) {
const key = ['m-playlists', user.id, id]; const key = ['m-playlists', user.id, id];
const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) }); const query = useQuery({ queryKey: key, queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
const p = query.data; const p = query.data;
const sharing = useMutation({ mutationFn: async () => {
if (!server || !p) throw new Error('Server unavailable');
const token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })).share_id;
cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: token } : current);
await Share.share({ title: p.name, message: playlistShareUrl(server, token) }, { dialogTitle: t('playlists.share') });
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id } } })),
onSuccess: () => { cache.setQueryData<Playlist>(key, current => current ? { ...current, share_id: null } : current); } });
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })), const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { params: { path: { id } }, body: { ...input(p!), track_ids: ids } })),
onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); }, onSuccess: data => { cache.setQueryData(key, data); void cache.invalidateQueries({ queryKey: ['m-playlists'] }); },
onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } }); onError: () => { void cache.invalidateQueries({ queryKey: ['m-playlists'] }); } });
@@ -105,11 +115,14 @@ function Detail({ id }: { id: string }) {
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching} return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}> ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} /> <Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
<ErrorBox>{query.error || save.error || deletion.error || playError ? errorText(query.error ?? save.error ?? deletion.error ?? playError) : null}</ErrorBox> <ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
{query.isPending && <Muted>{t('playlists.loading')}</Muted>} {query.isPending && <Muted>{t('playlists.loading')}</Muted>}
{p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted> {p && <><H1>{p.name}</H1><Muted>{p.description}</Muted><Muted>{t('catalog.track_count',{count:p.track_count})} · {formatDuration(p.duration_ms)}</Muted>
<Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} /> <Button title={t('collection.play_all')} disabled={!p.tracks.some(t => t.available)} onPress={() => play(false)} />
<Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} /> <Button title={t('collection.shuffle')} variant="secondary" disabled={!p.tracks.some(t => t.available)} onPress={() => play(true)} />
<Button title={t('playlists.share')} variant="secondary" busy={sharing.isPending} disabled={revoke.isPending} onPress={() => sharing.mutate()} />
<Muted>{t('playlists.share_hint')}</Muted>
{p.share_id && <Button title={t('playlists.revoke_share')} variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onPress={() => revoke.mutate()} />}
<Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} /> <Button title={t('playlists.edit')} variant="secondary" disabled={save.isPending || deletion.isPending} onPress={() => setEdit(true)} />
<Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} /> <Button title={t('playlists.manage')} variant="secondary" onPress={() => setManage(!manage)} />
<Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[ <Button title={t('playlists.delete')} variant="danger" disabled={save.isPending} busy={deletion.isPending} onPress={() => Alert.alert(t('playlists.delete'),t('playlists.delete_confirm',{name:p.name}),[
+5 -14
View File
@@ -1,4 +1,5 @@
import { useEffect, useSyncExternalStore } from 'react'; import { useEffect, useSyncExternalStore } from 'react';
import { releaseNotesText } from '@pipu/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native'; import { AppState, Modal, Pressable, ScrollView, Text, View } from 'react-native';
@@ -10,7 +11,6 @@ import { Button } from './kit';
export function useUpdateState() { export function useUpdateState() {
return useSyncExternalStore(updater.subscribe, () => updater.state); return useSyncExternalStore(updater.subscribe, () => updater.state);
} }
/** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */ /** Проверяет обновления при запуске и возврате в приложение; показывает диалог с «Что нового». */
export function UpdateDialog() { export function UpdateDialog() {
const colors = useColors(); const colors = useColors();
@@ -32,6 +32,7 @@ export function UpdateDialog() {
const { release, required } = st; const { release, required } = st;
const busy = st.status === 'downloading' || st.status === 'verifying'; const busy = st.status === 'downloading' || st.status === 'verifying';
const notes = releaseNotesText(release.notes_md);
const action = () => void updater.downloadAndInstall(session, server ?? ''); const action = () => void updater.downloadAndInstall(session, server ?? '');
return ( return (
@@ -57,9 +58,9 @@ export function UpdateDialog() {
{t('mobile.update_title', { version: release.version })} {t('mobile.update_title', { version: release.version })}
</Text> </Text>
{required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>} {required && <Text style={{ color: colors.danger }}>{t('mobile.update_required')}</Text>}
{release.notes_md.trim() !== '' && ( {notes !== '' && (
<ScrollView style={{ maxHeight: 240 }}> <ScrollView style={{ maxHeight: 240, flexShrink: 1 }}>
<Text style={{ color: colors.text2, lineHeight: 20 }}>{plainNotes(release.notes_md)}</Text> <Text style={{ color: colors.text2, lineHeight: 20 }}>{notes}</Text>
</ScrollView> </ScrollView>
)} )}
<Text style={{ color: colors.text3, fontSize: 12 }}> <Text style={{ color: colors.text3, fontSize: 12 }}>
@@ -115,13 +116,3 @@ export function UpdateDialog() {
</Modal> </Modal>
); );
} }
/** Заметки релиза — Markdown; в диалоге показываем текстом без разметки заголовков и выделения. */
function plainNotes(md: string): string {
return md
.replace(/^#{1,6}\s*/gm, '')
.replace(/\*\*(.+?)\*\*/g, '$1')
.replace(/^\s*[-*]\s+/gm, '• ')
.replace(/\[(.+?)\]\(.+?\)/g, '$1')
.trim();
}
+3 -3
View File
@@ -1,7 +1,7 @@
import { initialWaveState, step, WAVE_COLORS, waveScale, type WaveState } from '@pipu/core'; import { initialWaveState, step, wavePalette, waveScale, type WaveState } from '@pipu/core';
import { Canvas, createPicture, Picture } from '@shopify/react-native-skia'; import { Canvas, createPicture, Picture } from '@shopify/react-native-skia';
import { useFocusEffect } from 'expo-router'; import { useFocusEffect } from 'expo-router';
import { useCallback, useEffect, useState, type ReactNode } from 'react'; import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { AppState, Pressable, View } from 'react-native'; import { AppState, Pressable, View } from 'react-native';
import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated'; import Animated, { useAnimatedStyle, useDerivedValue, useFrameCallback, useReducedMotion, useSharedValue } from 'react-native-reanimated';
@@ -36,7 +36,7 @@ export function WaveOrb({ variant, playing, bpm = 100, energy = 0.6, colors, siz
}, []), }, []),
); );
const [foreground, setForeground] = useState(AppState.currentState === 'active'); const [foreground, setForeground] = useState(AppState.currentState === 'active');
const palette = (colors?.length === 3 ? colors : WAVE_COLORS) as [string, string, string]; const palette = useMemo(() => wavePalette(colors), [colors]);
const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced }); const props = useSharedValue({ variant, playing, bpm, energy, colors: palette, dark, reduced });
const state = useSharedValue<WaveState>(initialWaveState(size)); const state = useSharedValue<WaveState>(initialWaveState(size));
+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');
});
+13
View File
@@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test';
for (const width of [320, 1280]) { for (const width of [320, 1280]) {
test(`playlists at ${width}px`, async ({ page }, info) => { test(`playlists at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 }); await page.setViewportSize({ width, height: 900 });
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru')); await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login'); await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice'); await page.getByLabel('Логин', { exact: true }).fill('alice');
@@ -30,10 +31,22 @@ for (const width of [320, 1280]) {
await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click(); await page.getByRole('link', { name: /Музыка для долгой дороги/ }).click();
await page.getByRole('button', { name: 'Управлять треками' }).click(); await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible(); await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => { await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!; const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth); return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1); })).toBeLessThanOrEqual(1);
await page.screenshot({ path: info.outputPath('playlist.png') }); await page.screenshot({ path: info.outputPath('playlist.png') });
await page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toHaveCount(0);
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
}); });
} }
+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('/');
});
+53 -1
View File
@@ -10,7 +10,7 @@ import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App'; import { App } from './App';
import { createAppRouter, safeRedirect } from './app/router'; import { createAppRouter, safeRedirect } from './app/router';
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi'; import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup); afterEach(cleanup);
@@ -506,3 +506,55 @@ describe('recommendation preferences', () => {
expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null }); expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null });
}); });
}); });
describe('playlist sharing', () => {
it('copies a stable read-only link and revokes it without exposing editing controls', async () => {
const alice = makeUser('alice', 'user');
const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice });
const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } });
const playlist = created.data!;
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await screen.findByRole('heading', { name: 'Общий микс' });
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
expect(await screen.findByRole('status')).toHaveTextContent('Ссылка на плейлист скопирована');
const shareId = api.playlists.get(playlist.id)!.share_id!;
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`);
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled());
expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2);
expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('heading', { name: 'Общий микс' });
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Слушать всё' }));
await screen.findByRole('contentinfo', { name: 'Плеер' });
await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } });
await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull());
await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' }));
await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy());
expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId);
await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } });
await screen.findByRole('alert');
expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument();
});
it('returns to the shared playlist after login without starting playback', async () => {
const bob = makeUser('bob', 'user');
const shareId = '00000000-0000-4000-9001-000000000001';
const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`);
api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId });
await screen.findByRole('heading', { name: 'Вход' });
await user.type(screen.getByLabelText('Логин'), bob.username);
await user.type(screen.getByLabelText('Пароль'), bob.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'От друга' });
expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`);
expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument();
});
});
+19
View File
@@ -9,6 +9,8 @@ import {
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { lazy } from 'react'; 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 AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage }))); const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage }))); 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 { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell'; import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError'; import { RouteError } from '../widgets/RouteError';
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
export interface RouterContext { export interface RouterContext {
session: AuthSession; session: AuthSession;
@@ -145,7 +148,11 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage }); const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const trackRoute = createRoute({ getParentRoute: () => appRoute, path: '/track/$id', component: () => <TrackPage key={trackRoute.useParams().id} id={trackRoute.useParams().id} /> }); const 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 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 playlistRoute = createRoute({ getParentRoute: () => appRoute, path: '/playlist/$id', component: () => <PlaylistPage id={playlistRoute.useParams().id} /> });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage }); const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
@@ -269,6 +276,14 @@ const adminSettingsRoute = createRoute({
component: AdminSettingsPage, 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([ const routeTree = rootRoute.addChildren([
downloadAppRoute, downloadAppRoute,
androidAppRoute, androidAppRoute,
@@ -280,6 +295,8 @@ const routeTree = rootRoute.addChildren([
homeRoute, homeRoute,
trackRoute, trackRoute,
playlistsRoute, playlistsRoute,
downloadsRoute,
sharedPlaylistRoute,
playlistRoute, playlistRoute,
settingsRoute, settingsRoute,
searchRoute, searchRoute,
@@ -297,6 +314,8 @@ const routeTree = rootRoute.addChildren([
myUploadsRoute, myUploadsRoute,
uploadQueueRoute, uploadQueueRoute,
adminSettingsRoute, adminSettingsRoute,
serverUpdatesRoute,
serverConfigRoute,
]), ]),
]); ]);
+18 -1
View File
@@ -1,12 +1,29 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; 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(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
describe('desktop bridge', () => { 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', () => { it('is inactive in the browser', () => {
expect(isDesktop()).toBe(false); expect(isDesktop()).toBe(false);
}); });
+9 -1
View File
@@ -14,11 +14,19 @@ export function isDesktop(): boolean {
return api() !== undefined; 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(); const tauri = api();
if (!tauri) throw new Error('Tauri API unavailable'); if (!tauri) throw new Error('Tauri API unavailable');
return tauri.core.invoke(cmd, args) as Promise<T>; return tauri.core.invoke(cmd, args) as Promise<T>;
} }
const invoke = desktopInvoke;
export type DesktopHandle = { export type DesktopHandle = {
proxyBase: string; proxyBase: string;
+2
View File
@@ -4,6 +4,7 @@ import { Link } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react'; import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists'; import { AddToPlaylist } from './playlists';
import { SaveOfflineButton } from '../features/desktop/DesktopFeatures';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession } from '../app/session';
@@ -218,6 +219,7 @@ export function TrackList({
</Link> </Link>
)} )}
<span className={s.end} data-playlist-actions={actions ? true : undefined}> <span className={s.end} data-playlist-actions={actions ? true : undefined}>
{!actions && <SaveOfflineButton track={tr} compact />}
{actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />} {actions ? actions(tr, i) : <AddToPlaylist trackIds={[tr.id]} compact />}
{disliked ? ( {disliked ? (
<button <button
+7 -4
View File
@@ -3,7 +3,7 @@ import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { memo, useCallback, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session'; import { useSession, useUser } from '../app/session';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui'; import { Button, Dialog, Field, FormError, Icon } from '../shared/ui/ui';
import { playlistAddIcon } from '../shared/ui/icons'; import { playlistAddIcon } from '../shared/ui/icons';
@@ -18,10 +18,13 @@ export function usePlaylists(enabled = true) {
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) }); queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists')) });
} }
export function usePlaylist(id: string) { export function usePlaylist(id: string, shared = false) {
const session = useSession(); const session = useSession();
return useQuery({ queryKey: ['playlists', id], const user = useUser();
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) }); return useQuery({ queryKey: shared ? ['shared-playlists', id, user.id] : ['playlists', id], staleTime: 0,
queryFn: async () => shared
? unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }))
: unwrap(await session.client.GET('/api/v1/me/playlists/{id}', { params: { path: { id } } })) });
} }
export function playlistInput(p: Playlist): PlaylistInput & { revision: number } { export function playlistInput(p: Playlist): PlaylistInput & { revision: number } {
@@ -0,0 +1,147 @@
import { releaseNotesText, type Track } from '@pipu/core';
import { useEffect, useState, useSyncExternalStore } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession } from '../../app/session';
import { desktopInvoke, useDesktop } from '../../desktop';
import { TrackList } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import { formatBytes } from '../../shared/lib/format';
import { checkIcon, downloadIcon } from '../../shared/ui/icons';
import s from '../../pages/page.module.css';
export function useOffline() {
const offline = usePlayer().offline;
useSyncExternalStore(offline?.subscribe ?? (() => () => undefined), offline?.snapshot ?? (() => 0));
return offline;
}
export function SaveOfflineButton({ track, compact = false }: { track: Track; compact?: boolean }) {
const offline = useOffline();
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const [error, setError] = useState(false);
if (!offline) return null;
return <span>
<Button busy={busy} disabled={!track.available} icon={offline.pinned(track.id) ? checkIcon : downloadIcon}
aria-label={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}
title={t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')} onClick={() => {
setBusy(true); setError(false);
void (offline.pinned(track.id) ? offline.remove(track.id) : offline.save(track, true))
.catch(() => setError(true)).finally(() => setBusy(false));
}}>{compact ? null : t(offline.pinned(track.id) ? 'desktop.remove_download' : 'desktop.save_track')}</Button>
{error && <span role="alert">{t('desktop.download_failed')}</span>}
</span>;
}
export function DesktopOfflineSection() {
const offline = useOffline();
const { track } = usePlayerState();
const { t, i18n } = useTranslation();
const [error, setError] = useState(false);
useEffect(() => { void offline?.ready.catch(() => setError(true)); }, [offline]);
if (!offline) return null;
const tracks = offline.tracks();
return <section className={s.section}>
<h2>{t('desktop.offline')}</h2>
<p className={s.note}>{t('desktop.cache_hint')}</p>
<div className={s.toolbar}>
<span>{formatBytes(offline.bytes, i18n.language)}</span>
<Button onClick={() => void offline.clearRecent().catch(() => setError(true))}>{t('desktop.clear_recent')}</Button>
{track && <SaveOfflineButton track={track} />}
</div>
<FormError>{error ? t('desktop.storage_failed') : null}</FormError>
{tracks.length > 0 ? <TrackList tracks={tracks} context={{ type: 'collection', id: 'offline' }} actions={(track) => <SaveOfflineButton track={track} compact />} /> : <p>{t('desktop.offline_empty')}</p>}
</section>;
}
export function DesktopDownloadsPage() {
const { t } = useTranslation();
return <div className={s.page}><h1>{t('desktop.offline')}</h1><DesktopOfflineSection /></div>;
}
export function DesktopOfflineBanner() {
const state = useAuthState();
const desktop = useDesktop();
const { t } = useTranslation();
return desktop && state.status === 'signed_in' && state.offline ? <p role="status">{t('mobile.offline')}</p> : null;
}
type Release = { build: { version: string; size: number }; notes: string };
type Update = { status: 'idle' | 'checking' | 'latest' | 'error' | 'available' | 'installing' | 'ready'; release?: Release; dismissed?: boolean; progress?: number };
let update: Update = { status: 'idle' };
let generation = 0;
const listeners = new Set<() => void>();
function setUpdate(value: Update) { update = value; for (const fn of listeners) fn(); }
const subscribe = (fn: () => void) => { listeners.add(fn); return () => { listeners.delete(fn); }; };
function useUpdate() { return useSyncExternalStore(subscribe, () => update); }
async function checkUpdate(server: string, manual = false) {
if (update.status === 'checking' || update.status === 'installing') return;
const key = `pipu.update.checked.${server}`;
if (!manual && Date.now() - Number(localStorage.getItem(key) ?? 0) < 6 * 60 * 60 * 1000) return;
setUpdate({ status: 'checking' });
const requestGeneration = generation;
try {
const release = await desktopInvoke<Release | null>('update_check');
if (requestGeneration !== generation) return;
localStorage.setItem(key, String(Date.now()));
setUpdate(release ? { status: 'available', release } : { status: manual ? 'latest' : 'idle' });
} catch { if (requestGeneration === generation) setUpdate({ status: manual ? 'error' : 'idle' }); }
}
export function DesktopUpdateButton() {
const desktop = useDesktop();
const state = useUpdate();
const { t } = useTranslation();
if (!desktop?.server) return null;
return <section className={s.section}>
<h2>{t('desktop.updates')}</h2>
<Button busy={state.status === 'checking'} disabled={state.status === 'installing'} onClick={() => void checkUpdate(desktop.server!, true)}>{t('mobile.update_check')}</Button>
{state.status === 'latest' && <p role="status">{t('mobile.update_latest')}</p>}
{state.status === 'error' && !state.release && <FormError>{t('desktop.check_failed')}</FormError>}
</section>;
}
export function DesktopUpdateDialog() {
const desktop = useDesktop();
const session = useSession();
const state = useUpdate();
const { t } = useTranslation();
useEffect(() => {
if (!desktop?.server) return;
const check = () => void checkUpdate(desktop.server!);
check();
window.addEventListener('focus', check);
return () => { window.removeEventListener('focus', check); generation++; setUpdate({ status: 'idle' }); };
}, [desktop, session]);
useEffect(() => {
if (!desktop) return;
let stop: (() => void) | undefined;
let disposed = false;
// The native updater emits progress through the same Tauri event surface as media controls.
const api = (window as Window & { __TAURI__?: { event: { listen: (name: string, fn: (e: { payload: string }) => void) => Promise<() => void> } } }).__TAURI__;
void api?.event.listen('update-progress', (e) => {
const progress = Number(e.payload);
if (update.status === 'installing' && Number.isFinite(progress)) setUpdate({ ...update, progress: Math.min(1, Math.max(0, progress)) });
}).then((fn) => { if (disposed) fn(); else stop = fn; });
return () => { disposed = true; stop?.(); };
}, [desktop]);
if (!desktop || !state.release || state.dismissed) return null;
const release = state.release;
const busy = state.status === 'installing';
const close = () => { if (!busy) setUpdate({ ...state, dismissed: true }); };
const notes = releaseNotesText(release.notes);
return <Dialog title={t('mobile.update_title', { version: release.build.version })} onClose={close} footer={<>
<Button disabled={busy} onClick={close}>{t('mobile.update_later')}</Button>
<Button busy={busy} onClick={() => {
setUpdate({ status: 'installing', release, progress: 0 });
void desktopInvoke('update_install').then(() => setUpdate({ status: 'ready', release })).catch(() => setUpdate({ status: 'error', release }));
}}>{t('mobile.update_install')}</Button>
</>}>
{notes && <p style={{ whiteSpace: 'pre-wrap', overflowWrap: 'anywhere', maxHeight: '35vh', overflowY: 'auto' }}>{notes}</p>}
<p>{t('mobile.update_size', { size: (release.build.size / 1048576).toFixed(1) })}</p>
{busy && <p role="status">{t('mobile.update_downloading', { percent: Math.round((state.progress ?? 0) * 100) })}</p>}
{state.status === 'ready' && <p role="status">{t('desktop.installer_open')}</p>}
{state.status === 'error' && <FormError>{t('mobile.update_failed')}</FormError>}
</Dialog>;
}
@@ -0,0 +1,30 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AuthSession, PlayEvent } from '@pipu/core';
import { desktopEventSink, evictRecent, offlineOwner } from './offline';
import { createDesktop } from '../../desktop';
import { albumTracks } from '../../test/fakeApi';
afterEach(() => {
localStorage.clear();
delete (window as Window & { __TAURI__?: unknown }).__TAURI__;
});
describe('desktop cache policy', () => {
it('evicts oldest recent files while keeping manual downloads outside the limit', () => {
const entry = (id: string, used: number, pinned = false) => ({ id, used, pinned, track: albumTracks[0]!, audio: { size: 60 } as Blob });
expect(evictRecent([entry('new', 3), entry('manual', 0, true), entry('old', 1), entry('middle', 2)], 100)).toEqual(['old', 'middle']);
});
});
it('persists offline events across sink restarts and deduplicates retries', async () => {
(window as Window & { __TAURI__?: unknown }).__TAURI__ = { core: { invoke: async () => ({ proxyBase: 'http://local', server: 'https://music.example' }) } };
await createDesktop();
const post = vi.fn().mockRejectedValueOnce(new TypeError('offline')).mockResolvedValue({ data: {}, response: new Response('{}') });
const session = { state: { status: 'signed_in', user: { id: 'alice' } }, client: { POST: post } } as unknown as AuthSession;
const owner = offlineOwner(session)!;
const event: PlayEvent = { id: 'one', type: 'play_start', occurred_at: new Date().toISOString(), track_id: 'track' };
await expect(desktopEventSink(session, owner)([event])).rejects.toThrow('offline');
await desktopEventSink(session, owner)([event]);
expect(post.mock.calls[1]?.[1].body.events).toEqual([event]);
expect(JSON.parse(localStorage.getItem(`pipu.events.${owner}`)!)).toEqual([]);
});
+181
View File
@@ -0,0 +1,181 @@
import { unwrap, type AuthSession, type PlayEvent, type Track } from '@pipu/core';
import { desktopHandle } from '../../desktop';
export const RECENT_LIMIT = 500 * 1024 * 1024;
type Entry = { id: string; track: Track; audio: Blob; cover?: Blob; pinned: boolean; used: number };
const size = (e: Entry) => e.audio.size + (e.cover?.size ?? 0);
export function evictRecent(entries: Entry[], limit = RECENT_LIMIT): string[] {
let total = entries.filter((e) => !e.pinned).reduce((n, e) => n + size(e), 0);
const removed: string[] = [];
for (const e of entries.filter((e) => !e.pinned).sort((a, b) => a.used - b.used)) {
if (total <= limit) break;
removed.push(e.id);
total -= size(e);
}
return removed;
}
export function offlineOwner(session: AuthSession): string | null {
const desktop = desktopHandle();
return desktop?.server && session.state.status === 'signed_in'
? JSON.stringify([desktop.server, session.state.user.id]) : null;
}
/** WebView IndexedDB is persistent across desktop restarts; signed URLs are never stored. */
export class DesktopOffline {
private db: Promise<IDBDatabase> | null = null;
private loaded: Promise<void> | null = null;
private entries: Entry[] = [];
private urls = new Map<string, Track>();
private objects = new Map<string, string[]>();
private inUse = new Set<string>();
private listeners = new Set<() => void>();
private revision = 0;
private writes: Promise<unknown> = Promise.resolve();
private disposed = false;
get ready(): Promise<void> {
this.loaded ??= this.reload();
return this.loaded;
}
activate() { this.disposed = false; }
constructor(readonly owner: string, private session: AuthSession) {}
private database(): Promise<IDBDatabase> {
return this.db ??= new Promise((resolve, reject) => {
const request = indexedDB.open(`pipu.offline.${this.owner}`, 1);
request.onupgradeneeded = () => request.result.createObjectStore('tracks', { keyPath: 'id' });
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
subscribe = (fn: () => void) => { this.listeners.add(fn); return () => { this.listeners.delete(fn); }; };
snapshot = () => this.revision;
private async reload() {
const db = await this.database();
const entries = await new Promise<Entry[]>((resolve, reject) => {
const req = db.transaction('tracks').objectStore('tracks').getAll();
req.onsuccess = () => resolve(req.result as Entry[]);
req.onerror = () => reject(req.error);
});
if (this.disposed) return;
this.entries = entries;
for (const e of entries) {
if (this.urls.has(e.id)) continue;
const audio = URL.createObjectURL(e.audio);
const cover = e.cover ? URL.createObjectURL(e.cover) : null;
this.objects.set(e.id, [...(this.objects.get(e.id) ?? []), audio, ...(cover ? [cover] : [])]);
this.urls.set(e.id, { ...e.track, stream_url: audio, available: true, album: { ...e.track.album, cover_url: cover } });
}
for (const id of this.urls.keys()) if (!entries.some((e) => e.id === id)) {
this.urls.delete(id);
if (!this.inUse.has(id)) this.release(id);
}
this.revision++;
for (const fn of this.listeners) fn();
}
tracks() { return this.entries.map((e) => this.local(e.track)); }
private release(id: string) {
for (const url of this.objects.get(id) ?? []) URL.revokeObjectURL(url);
this.objects.delete(id);
}
/** Keep only files currently playing/crossfading alive after cache deletion. */
keep(ids: string[]) {
this.inUse = new Set(ids);
for (const id of this.objects.keys()) if (!this.inUse.has(id) && !this.urls.has(id)) this.release(id);
}
local(track: Track): Track { return this.urls.get(track.id) ?? track; }
has(id: string) { return this.entries.some((e) => e.id === id); }
pinned(id: string) { return this.entries.some((e) => e.id === id && e.pinned); }
get bytes() { return this.entries.reduce((n, e) => n + size(e), 0); }
private mutate(fn: (store: IDBObjectStore) => void): Promise<void> {
const work = this.writes.catch(() => undefined).then(async () => {
const db = await this.database();
await new Promise<void>((resolve, reject) => {
const tx = db.transaction('tracks', 'readwrite');
fn(tx.objectStore('tracks'));
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error);
});
await this.reload();
});
this.writes = work;
return work;
}
private downloads = new Map<string, Promise<void>>();
private downloadTail: Promise<unknown> = Promise.resolve();
async save(track: Track, pinned = false): Promise<void> {
await this.ready;
const pending = this.downloads.get(track.id);
if (pending) { await pending; return this.save(track, pinned); }
const existing = this.entries.find((e) => e.id === track.id);
if (existing) {
await this.mutate((store) => {
const latest = this.entries.find((e) => e.id === track.id);
if (latest) store.put({ ...latest, pinned: latest.pinned || pinned, used: Date.now() });
});
return;
}
const job = this.downloadTail.catch(() => undefined).then(() => this.download(track, pinned));
this.downloadTail = job;
this.downloads.set(track.id, job);
try { await job; } finally { this.downloads.delete(track.id); }
}
private async download(track: Track, pinned: boolean) {
if (this.disposed) return;
const fresh = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } })).items[0];
if (!fresh?.available || !fresh.stream_url) throw new Error('Track unavailable');
const response = await fetch(fresh.stream_url);
if (!response.ok) throw new Error('Download failed');
const audio = await response.blob();
if (!audio.size) throw new Error('Empty download');
let cover: Blob | undefined;
if (fresh.album.cover_url) {
try {
const response = await fetch(`${fresh.album.cover_url}&size=300`);
if (response.ok) cover = await response.blob();
} catch { /* Artwork is optional. */ }
}
if (this.disposed) return;
const entry: Entry = { id: track.id, track: { ...fresh, stream_url: null, album: { ...fresh.album, cover_url: null } }, audio, cover, pinned, used: Date.now() };
await this.mutate((store) => {
const removed = evictRecent([...this.entries.filter((e) => e.id !== entry.id), entry]);
for (const id of removed) store.delete(id);
if (!removed.includes(entry.id)) store.put(entry);
});
}
async remove(id: string) { await this.mutate((store) => store.delete(id)); }
async clearRecent() { await this.mutate((store) => { for (const e of this.entries) if (!e.pinned) store.delete(e.id); }); }
dispose() {
this.disposed = true;
queueMicrotask(() => {
if (!this.disposed) return;
for (const id of this.objects.keys()) this.release(id);
void this.downloadTail.catch(() => undefined).then(() => this.writes)
.finally(() => this.db?.then((db) => db.close())).catch(() => undefined);
});
}
}
/** Persist before sending; retries keep event IDs for server-side deduplication. */
export function desktopEventSink(session: AuthSession, owner: string) {
const key = `pipu.events.${owner}`;
let sending = Promise.resolve();
return async (events: PlayEvent[]) => {
const work = sending.catch(() => undefined).then(async () => {
const old = JSON.parse(localStorage.getItem(key) ?? '[]') as PlayEvent[];
const merged = [...new Map([...old, ...events].map((e) => [e.id, e])).values()];
localStorage.setItem(key, JSON.stringify(merged));
if (offlineOwner(session) !== owner) return;
while (merged.length) {
if (offlineOwner(session) !== owner) return;
const batch = merged.slice(0, 100);
unwrap(await session.client.POST('/api/v1/events', { body: { events: batch } }));
merged.splice(0, batch.length);
localStorage.setItem(key, JSON.stringify(merged));
}
});
sending = work;
await work;
};
}
@@ -0,0 +1,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 { App } from './App';
import { createAppRouter } from './app/router'; import { createAppRouter } from './app/router';
import { applyTheme } from './app/session'; import { applyTheme } from './app/session';
import { createDesktop, DesktopProvider } from './desktop'; import { createDesktop, DesktopProvider, desktopFetch } from './desktop';
const LOCALE_KEY = 'pipu.locale'; const LOCALE_KEY = 'pipu.locale';
@@ -70,6 +70,7 @@ const session = new AuthSession({
deviceName: desktop ? 'Pipu / Desktop' : deviceName(), deviceName: desktop ? 'Pipu / Desktop' : deviceName(),
baseUrl: desktop?.proxyBase, baseUrl: desktop?.proxyBase,
refreshStore: desktop?.refreshStore, refreshStore: desktop?.refreshStore,
fetch: desktop ? desktopFetch : undefined,
}); });
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу. // До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
await session.restore(); await session.restore();
+1 -5
View File
@@ -41,8 +41,6 @@ const ICONS: Record<string, string> = {
rainy: waveRainyIcon, rainy: waveRainyIcon,
}; };
const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string; type Tr = (key: string) => string;
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */ /** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
@@ -97,8 +95,6 @@ function WaveHome() {
const waveOn = st.wave !== null; const waveOn = st.wave !== null;
const playing = waveOn && st.playing; const playing = waveOn && st.playing;
const track = waveOn ? st.track : null; const track = waveOn ? st.track : null;
const colors: [string, string, string] =
track?.album.cover_colors.length === 3 ? (track.album.cover_colors as [string, string, string]) : WAVE_COLORS;
const run = (fn: () => Promise<void> | void) => { const run = (fn: () => Promise<void> | void) => {
setError(null); setError(null);
@@ -129,7 +125,7 @@ function WaveHome() {
readAudio={player.readAudio} readAudio={player.readAudio}
bpm={track?.features?.bpm ?? undefined} bpm={track?.features?.bpm ?? undefined}
energy={track?.features?.energy ?? undefined} energy={track?.features?.energy ?? undefined}
colors={colors} colors={track?.album.cover_colors}
theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'} theme={document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'}
size={320} size={320}
label={t(playing ? 'wave.pause' : 'wave.start')} label={t(playing ? 'wave.pause' : 'wave.start')}
+32 -9
View File
@@ -9,9 +9,10 @@ import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context'; import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors'; import { useErrorText } from '../shared/lib/errors';
import { sharePlaylist } from '../shared/lib/share';
import { formatDuration } from '../shared/lib/format'; import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui'; import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon } from '../shared/ui/icons'; import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
import c from './catalog.module.css'; import c from './catalog.module.css';
import s from './page.module.css'; import s from './page.module.css';
import p from './playlists.module.css'; import p from './playlists.module.css';
@@ -42,19 +43,19 @@ export function PlaylistsPage() {
</div>; </div>;
} }
export function PlaylistPage({ id }: { id: string }) { export function PlaylistPage({ id, shared = false }: { id: string; shared?: boolean }) {
const { t } = useTranslation(); const { t } = useTranslation();
const query = usePlaylist(id); const query = usePlaylist(id, shared);
const errorText = useErrorText(); const errorText = useErrorText();
return <div className={s.page}> return <div className={s.page}>
<Link to="/collection/playlists">← {t('playlists.title')}</Link> <Link to="/collection/playlists">← {t('playlists.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError> <FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <PageSkeleton />} {query.isPending && <PageSkeleton />}
{query.data && <PlaylistDetail key={id} playlist={query.data} />} {query.data && !query.isError && <PlaylistDetail key={id} playlist={query.data} readOnly={shared} shareId={shared ? id : undefined} />}
</div>; </div>;
} }
function PlaylistDetail({ playlist }: { playlist: Playlist }) { function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const cache = useQueryClient(); const cache = useQueryClient();
@@ -64,6 +65,21 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
const [edit, setEdit] = useState(false); const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false); const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false); const [manage, setManage] = useState(false);
const [copied, setCopied] = useState(false);
const sharing = useMutation({ mutationFn: async () => {
setCopied(false);
let token = shareId ?? playlist.share_id;
if (!readOnly) {
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
}
if (!token) throw new Error('Share link unavailable');
const result = await sharePlaylist(token, playlist.name);
if (result === 'copied') setCopied(true);
} });
const revoke = useMutation({ mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })),
onSuccess: () => { setCopied(false); cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name }; const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', { const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids }, params: { path: { id: playlist.id } }, body: { ...playlistInput(playlist), track_ids: ids },
@@ -84,7 +100,7 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
return <> return <>
<header className={c.colHero}> <header className={c.colHero}>
<Cover album={playlist.tracks[0]?.album ?? playlist} size={144} /> <Cover album={playlist.tracks[0]?.album ?? playlist} size={144} />
<div className={c.heroText}><span className={c.kicker}>{t('playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1> <div className={c.heroText}><span className={c.kicker}>{t(readOnly || playlist.share_id ? 'playlists.shared' : 'playlists.private')}</span><h1 className={c.colTitle}>{playlist.name}</h1>
<p className={p.description}>{playlist.description}</p> <p className={p.description}>{playlist.description}</p>
<span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span> <span>{t('catalog.track_count', { count: playlist.track_count })} · {formatDuration(playlist.duration_ms)}</span>
</div> </div>
@@ -92,10 +108,17 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
<div className={s.toolbar}> <div className={s.toolbar}>
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button> <Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button> <Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
{!readOnly && <>
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button> <Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button> <Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
<Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button> <Button variant="ghost" disabled={save.isPending} onClick={() => setRemove(true)}>{t('playlists.delete')}</Button>
{playlist.share_id && <Button variant="ghost" busy={revoke.isPending} disabled={sharing.isPending} onClick={() => revoke.mutate()}>{t('playlists.revoke_share')}</Button>}
</>}
</div> </div>
{!readOnly && <p>{t('playlists.share_hint')}</p>}
{copied && <p role="status">{t('playlists.link_copied')}</p>}
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
<FormError>{save.error ? errorText(save.error) : null}</FormError> <FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && ( {!playlist.tracks.length && (
<EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}> <EmptyState icon={playlistAddIcon} action={<Link to="/search" search={{ q: '' }}>{t('playlists.find_tracks')}</Link>}>
@@ -103,13 +126,13 @@ function PlaylistDetail({ playlist }: { playlist: Playlist }) {
</EmptyState> </EmptyState>
)} )}
<TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)} <TrackList tracks={playlist.tracks} showAlbum context={context} lead={(_, i) => String(i + 1)}
actions={manage ? (track, i) => <span className={p.actions}> actions={!readOnly && manage ? (track, i) => <span className={p.actions}>
<button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button> <button type="button" disabled={i === 0 || save.isPending} aria-label={t('playlists.move_up', { name: track.title })} onClick={() => move(i, -1)}>↑</button>
<button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button> <button type="button" disabled={i === playlist.tracks.length - 1 || save.isPending} aria-label={t('playlists.move_down', { name: track.title })} onClick={() => move(i, 1)}>↓</button>
<button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button> <button type="button" disabled={save.isPending} aria-label={t('playlists.remove_track', { name: track.title })} onClick={() => save.mutate(playlist.tracks.filter(x => x.id !== track.id).map(x => x.id))}>×</button>
</span> : undefined} /> </span> : undefined} />
{edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />} {!readOnly && edit && <PlaylistEditor playlist={playlist} onClose={() => setEdit(false)} onSaved={() => setEdit(false)} />}
{remove && ( {!readOnly && remove && (
<Dialog <Dialog
title={t('playlists.delete')} title={t('playlists.delete')}
onClose={() => { onClose={() => {
+3
View File
@@ -13,6 +13,7 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch'; import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css'; import s from './page.module.css';
import { Onboarding } from './Onboarding'; import { Onboarding } from './Onboarding';
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>; type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>; type WaveVariant = NonNullable<components['schemas']['UiPrefs']['wave_variant']>;
@@ -27,6 +28,8 @@ export function SettingsPage() {
<ProfileSection /> <ProfileSection />
<RecommendationSettings /> <RecommendationSettings />
<AppearanceSection /> <AppearanceSection />
<DesktopOfflineSection />
<DesktopUpdateButton />
<PasswordSection /> <PasswordSection />
<SessionsSection /> <SessionsSection />
</div> </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'; import g from '../genius.module.css';
export function GeniusPanel() { export function GeniusPanel() {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const session = useSession(); const session = useSession();
const queries = useQueryClient(); const queries = useQueryClient();
const errorText = useErrorText(); const errorText = useErrorText();
@@ -24,16 +24,23 @@ export function GeniusPanel() {
}); });
const data = stats.data; const data = stats.data;
const cooling = data && Date.parse(data.cooldown_until) > Date.now(); 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"> return <section className={s.section} aria-label="Genius">
<div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div> <div className={s.head}><h2>Genius</h2><span role="status">{state}</span></div>
<p className={s.subtitle}>{t('genius.description')}</p> <p className={s.subtitle}>{t('genius.description')}</p>
<FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError> <FormError>{stats.error ? errorText(stats.error) : control.error ? errorText(control.error) : null}</FormError>
{data && <> {data && <>
{!data.configured && <p>{t('genius.configure')}</p>} {!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}> <dl className={g.stats}>
{(['total', 'pending', 'matched', 'lyrics', 'annotations', 'images', 'unmatched', 'errors'] as const).map((key) => {(['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> </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>} {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>} {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} 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> <Button disabled={!data.configured || control.isPending || data.errors + data.unmatched === 0} onClick={() => control.mutate('retry')}>{t('genius.retry')}</Button>
</div> </div>
<p className={s.subtitle}>{t('genius.controls_hint')}</p>
<p className={s.subtitle}>{t('genius.retry_hint')}</p>
</>} </>}
</section>; </section>;
} }
+12 -2
View File
@@ -1,6 +1,6 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react'; import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session'; import { useSession, useUser } from '../../app/session';
@@ -10,6 +10,7 @@ import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css'; import s from '../page.module.css';
import l from './library.module.css'; import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus'; import { ScanLine, StatusBadge } from './ScanStatus';
import { AnalysisErrorsDialog } from './AnalysisErrorsDialog';
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */ /** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
export function LibraryPage() { export function LibraryPage() {
@@ -18,6 +19,7 @@ export function LibraryPage() {
const user = useUser(); const user = useUser();
const errorText = useErrorText(); const errorText = useErrorText();
const admin = user.role === 'admin'; const admin = user.role === 'admin';
const [showAnalysisErrors, setShowAnalysisErrors] = useState(false);
const status = useQuery({ const status = useQuery({
queryKey: ['library-status'], queryKey: ['library-status'],
@@ -27,6 +29,7 @@ export function LibraryPage() {
const errors = useQuery({ const errors = useQuery({
queryKey: ['library-errors'], queryKey: ['library-errors'],
enabled: admin, 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 } } })), queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
}); });
const reindex = useMutation({ const reindex = useMutation({
@@ -35,7 +38,10 @@ export function LibraryPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const retry = useMutation({ const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')), 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 d = status.data;
const tiles = d const tiles = d
@@ -89,13 +95,17 @@ export function LibraryPage() {
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>} {d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span> </span>
{admin && ( {admin && (
<div className={l.analysisActions}>
<Button onClick={() => setShowAnalysisErrors(true)}>{t('library.analysis_show_errors')}</Button>
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}> <Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')} {t('library.analysis_retry')}
</Button> </Button>
</div>
)} )}
</div> </div>
)} )}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>} {d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
{showAnalysisErrors && <AnalysisErrorsDialog onClose={() => setShowAnalysisErrors(false)} />}
</section> </section>
)} )}
{d && ( {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 { t, i18n } = useTranslation('admin');
const id = useId(); const id = useId();
if (scan?.running) { 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 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 ( return (
<div className={s.scan}> <div className={s.scan}>
<div className={s.scanText} id={id}> <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>
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}> {phase === 'walking' && scan.current_dir && <div className={s.scanPath} title={scan.current_dir}>{t('sources.scan_current_dir', { path: scan.current_dir })}</div>}
<span style={{ width: `${pct}%` }} /> <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>
</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 { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react'; import { useId, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session'; import { useSession, useUser } from '../../app/session';
@@ -120,6 +120,7 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
{source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>} {source.accepts_uploads && <span className={l.typeBadge}>{t('write_template.badge')}</span>}
</div> </div>
<span className={l.location}>{location(source)}</span> <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 && ( {source.status === 'error' && source.status_message && (
<span className={l.message}>{source.status_message}</span> <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]; 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 { t } = useTranslation(['admin', 'common']);
const session = useSession(); const session = useSession();
const queryClient = useQueryClient(); 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 [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 [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0); const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
const [excludedDirs, setExcludedDirs] = useState((source?.excluded_dirs ?? []).join('\n'));
const excludedDirsId = useId();
const save = useMutation({ const save = useMutation({
mutationFn: async () => { mutationFn: async () => {
const common = { const common = {
name: name.trim(), name: name.trim(),
tag_priority: priority, tag_priority: priority,
excluded_dirs: excludedDirs.split(/\r?\n/).map((dir) => dir.trim()).filter(Boolean),
...(type === 'local' ...(type === 'local'
? { local_path: localPath } ? { local_path: localPath }
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }), : { 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 () => { onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['sources'] }); await queryClient.invalidateQueries({ queryKey: ['sources'] });
for (const key of ['library-status', 'library-errors', 'analysis-errors']) {
void queryClient.invalidateQueries({ queryKey: [key] });
}
onClose(); onClose();
}, },
}); });
const err = save.error; 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 general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
const submit = (e: FormEvent) => { 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}> <div className={l.field}>
<span className={l.label}>{t('sources.tag_priority')}</span> <span className={l.label}>{t('sources.tag_priority')}</span>
<Segmented <Segmented
+6
View File
@@ -140,6 +140,11 @@ export function UsersPage() {
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'), label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }), 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')) if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) }); items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items; return items;
@@ -214,6 +219,7 @@ export function UsersPage() {
</td> </td>
<td data-label={t('users.col_role')}> <td data-label={t('users.col_role')}>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span> <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>} {u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td> </td>
<td data-label={t('users.col_status')}> <td data-label={t('users.col_status')}>
@@ -123,8 +123,14 @@
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; 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 { .errorDetail {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
@@ -146,6 +152,26 @@
transition: width 0.4s; 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 { .test {
font-size: 13px; font-size: 13px;
} }
@@ -212,6 +238,20 @@
gap: 6px; 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 { .tiles {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); 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 dt { color: var(--text2); font-size: 13px; }
.stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; } .stats dd { margin: 6px 0 0; font-size: 25px; font-variant-numeric: tabular-nums; }
.actions { display: flex; flex-wrap: wrap; gap: 12px; } .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; } .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 { border-top: 1px solid var(--line); padding-top: 16px; overflow-wrap: anywhere; }
.annotation summary { cursor: pointer; line-height: 1.6; } .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 { unwrap, type AuthSession, type PlayContext, type RepeatMode } from '@pipu/core';
import type { PlayerState } from './types'; import type { PlayerState } from './types';
import { offlineOwner } from '../features/desktop/offline';
const LOCAL_KEY = 'pipu.player'; const LOCAL_KEY = 'pipu.player';
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */ /** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
@@ -30,6 +31,7 @@ function deviceId(): string {
} }
export class PlaybackPersistence { export class PlaybackPersistence {
private get localKey() { const owner = offlineOwner(this.session); return owner ? `${LOCAL_KEY}.${owner}` : LOCAL_KEY; }
private readonly device = deviceId(); private readonly device = deviceId();
lastServerSave = 0; lastServerSave = 0;
constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {} constructor(private readonly session: AuthSession, private readonly getState: () => PlayerState) {}
@@ -50,7 +52,7 @@ export class PlaybackPersistence {
saveLocal() { saveLocal() {
if (!this.getState().track) return; if (!this.getState().track) return;
try { try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot(this.getState()))); localStorage.setItem(this.localKey, JSON.stringify(this.snapshot(this.getState())));
} catch { } catch {
// недоступное хранилище не мешает воспроизведению // недоступное хранилище не мешает воспроизведению
} }
@@ -91,7 +93,7 @@ export class PlaybackPersistence {
async restore(): Promise<Saved | null> { async restore(): Promise<Saved | null> {
let local: Saved | null = null; let local: Saved | null = null;
try { try {
const raw = localStorage.getItem(LOCAL_KEY); const raw = localStorage.getItem(this.localKey);
local = raw ? (JSON.parse(raw) as Saved) : null; local = raw ? (JSON.parse(raw) as Saved) : null;
} catch { } catch {
local = null; local = null;
+57 -10
View File
@@ -18,6 +18,7 @@ import { MediaControls } from './media';
import type { PlayerState } from './types'; import type { PlayerState } from './types';
import { AudioEngine } from './engine'; import { AudioEngine } from './engine';
import { PlaybackPersistence } from './persistence'; import { PlaybackPersistence } from './persistence';
import { DesktopOffline, desktopEventSink, offlineOwner } from '../features/desktop/offline';
export type { PlayContext, Track }; export type { PlayContext, Track };
@@ -34,6 +35,8 @@ const PRELOAD_BEFORE_MS = 20_000;
* Media Session и сохранение «трек и момент» (локально и на сервере). * Media Session и сохранение «трек и момент» (локально и на сервере).
*/ */
export class Player { export class Player {
readonly offline: DesktopOffline | null;
private retryEvents: (() => Promise<void>) | null = null;
private readonly engine: AudioEngine; private readonly engine: AudioEngine;
private get audio() { return this.engine.audio; } private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; } private get overlap() { return this.engine.overlap; }
@@ -65,8 +68,16 @@ export class Player {
audio?: HTMLAudioElement, audio?: HTMLAudioElement,
) { ) {
this.engine = new AudioEngine(audio); 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({ this.events = new EventBuffer({
send: async (events) => { send: async (events) => {
if (this.sendEvents) return this.sendEvents(events);
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } })); unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
}, },
}); });
@@ -82,8 +93,15 @@ export class Player {
/** Запуск фоновой отправки событий (парный вызов — dispose). */ /** Запуск фоновой отправки событий (парный вызов — dispose). */
start() { start() {
this.offline?.activate();
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS)); 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) --- // --- подписка (useSyncExternalStore) ---
@@ -137,11 +155,20 @@ export class Player {
this.finishAttempt('skip'); this.finishAttempt('skip');
this.leaveWave(); this.leaveWave();
const version = this.wavePolicy.version; const version = this.wavePolicy.version;
const res = unwrap( let res;
await this.session.client.POST('/api/v1/wave/sessions', { try {
res = unwrap(await this.session.client.POST('/api/v1/wave/sessions', {
body: { activity, mood, ...(seed ? { seed: { type: seed.type, id: seed.id } } : {}) }, 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; if (!this.wavePolicy.isCurrent(version)) return;
const tracks = res.tracks.filter((t) => t.available && t.stream_url); const tracks = res.tracks.filter((t) => t.available && t.stream_url);
if (!tracks.length) { if (!tracks.length) {
@@ -339,12 +366,20 @@ export class Player {
private async load(autoplay: boolean, startMs = 0) { private async load(autoplay: boolean, startMs = 0) {
const track = this.queue.current; const track = this.queue.current;
if (!track?.stream_url) return; if (!track?.stream_url) return;
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 }); this.update({ loading: autoplay, error: null, positionMs: startMs, durationMs: track.duration_ms });
// Keep the restored element's buffer when the user presses Play. // 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; if (this.audio.currentTime !== startMs / 1000) this.audio.currentTime = startMs / 1000;
this.media.updateMediaMetadata(track); this.media.updateMediaMetadata(track);
if (!autoplay) return; if (!autoplay) return;
void this.offline?.save(track).catch(() => undefined);
this.applyLoudness(); this.applyLoudness();
this.attempt = new PlayAttempt(track, startMs); this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null; this.preloaded = null;
@@ -425,7 +460,8 @@ export class Player {
const next = this.state.queue[this.state.index + 1]; const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return; if (!next?.stream_url || !next.available) return;
this.fading = true; 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; this.overlap.volume = 0;
void this.overlap.play().catch(() => { void this.overlap.play().catch(() => {
this.fading = false; this.fading = false;
@@ -458,7 +494,8 @@ export class Player {
return; return;
} }
this.syncQueue(); 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) { if (!src) {
this.fading = false; this.fading = false;
this.ignoreEnded = false; this.ignoreEnded = false;
@@ -471,6 +508,7 @@ export class Player {
this.attempt = new PlayAttempt(next, Math.round(at * 1000)); 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.update({ track: next, loading: false, playing: true, positionMs: Math.round(at * 1000), durationMs: next.duration_ms });
this.media.updateMediaMetadata(next); this.media.updateMediaMetadata(next);
void this.offline?.save(next).catch(() => undefined);
void this.audio.play().finally(() => { void this.audio.play().finally(() => {
this.fading = false; this.fading = false;
this.ignoreEnded = false; this.ignoreEnded = false;
@@ -560,7 +598,7 @@ export class Player {
if (!cur || !next?.stream_url || this.preloaded === next.id) return; if (!cur || !next?.stream_url || this.preloaded === next.id) return;
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return; if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
this.preloaded = next.id; 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 version = ++this.restoreVersion;
const saved = await this.persistence.restore(); const saved = await this.persistence.restore();
if (version !== this.restoreVersion || !saved?.queue.length) return; if (version !== this.restoreVersion || !saved?.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } })); 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; if (version !== this.restoreVersion) return;
const wanted = saved.queue[saved.index]; const wanted = saved.queue[saved.index];
const tracks = res.items.filter((t) => t.available && t.stream_url); const tracks = items.filter((t) => t.available && t.stream_url);
if (!tracks.length) return; if (!tracks.length) return;
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0); const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
this.context = saved.context; this.context = saved.context;
@@ -608,6 +654,7 @@ export class Player {
this.fading = false; this.fading = false;
this.finishAttempt('skip'); this.finishAttempt('skip');
this.engine.dispose(); this.engine.dispose();
this.offline?.dispose();
this.persistence.dispose(); this.persistence.dispose();
for (const t of this.timers) clearInterval(t); for (const t of this.timers) clearInterval(t);
this.timers = []; this.timers = [];
+33 -1
View File
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { shareTrack } from './share'; import { sharePlaylist, shareTrack } from './share';
import * as desktop from '../../desktop';
afterEach(() => vi.restoreAllMocks()); afterEach(() => vi.restoreAllMocks());
@@ -38,3 +39,34 @@ describe('shareTrack', () => {
finally { vi.unstubAllGlobals(); } finally { vi.unstubAllGlobals(); }
}); });
}); });
describe('sharePlaylist', () => {
it('copies the shared page URL without exposing streaming links', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
try {
expect(await sharePlaylist('token', 'Mix')).toBe('copied');
expect(writeText).toHaveBeenCalledWith(`${window.location.origin}/shared/playlist/token`);
} finally { vi.unstubAllGlobals(); }
});
it('uses the configured music server when sharing from desktop', async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
vi.spyOn(desktop, 'desktopHandle').mockReturnValue({ server: 'https://music.example' } as desktop.DesktopHandle);
vi.stubGlobal('navigator', { userAgent: 'Windows', clipboard: { writeText } });
try {
await sharePlaylist('token', 'Mix');
expect(writeText).toHaveBeenCalledWith('https://music.example/shared/playlist/token');
} finally { vi.unstubAllGlobals(); }
});
it('opens the native share sheet on mobile browsers', async () => {
const share = vi.fn().mockResolvedValue(undefined);
vi.stubGlobal('navigator', { userAgent: 'Android', share });
try {
expect(await sharePlaylist('token', 'Mix')).toBe('shared');
expect(share).toHaveBeenCalledWith({ title: 'Mix', url: `${window.location.origin}/shared/playlist/token` });
} finally { vi.unstubAllGlobals(); }
});
});
+10 -2
View File
@@ -1,8 +1,16 @@
import { trackShareUrl } from '@pipu/core'; import { playlistShareUrl, trackShareUrl } from '@pipu/core';
import { desktopHandle } from '../../desktop';
/** Desktop always copies; mobile browsers can open their native share sheet. */ /** Desktop always copies; mobile browsers can open their native share sheet. */
export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> { export async function shareTrack(id: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
const url = trackShareUrl(window.location.origin, id); return shareUrl(trackShareUrl(desktopHandle()?.server ?? window.location.origin, id), title);
}
export async function sharePlaylist(shareId: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
return shareUrl(playlistShareUrl(desktopHandle()?.server ?? window.location.origin, shareId), title);
}
async function shareUrl(url: string, title: string): Promise<'copied' | 'shared' | 'cancelled'> {
if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) { if (/Android|iPhone|iPad|iPod/i.test(navigator.userAgent) && navigator.share) {
try { try {
await navigator.share({ title, url }); await navigator.share({ title, url });
+1
View File
@@ -6,6 +6,7 @@ export function installE2EFetch() {
const api = fakeApi([ const api = fakeApi([
makeUser('admin', 'admin', { must_change_credentials: true }), makeUser('admin', 'admin', { must_change_credentials: true }),
makeUser('alice', 'user'), makeUser('alice', 'user'),
makeUser('update-manager', 'user', { manages_updates: true }),
makeUser('mobile-admin', 'admin', { reviews_uploads: true }), makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
makeUser('moderator', 'moderator', { reviews_uploads: true }), makeUser('moderator', 'moderator', { reviews_uploads: true }),
]); ]);
+59 -3
View File
@@ -76,14 +76,30 @@ export function fakeApi(initial: FakeUser[]) {
let token = 0; let token = 0;
const calls: { method: string; path: string; body: unknown }[] = []; const calls: { method: string; path: string; body: unknown }[] = [];
const reactions = new Map<string, 'like' | 'dislike'>(); const reactions = new Map<string, 'like' | 'dislike'>();
const playlistOwners = new Map<string, string>();
const playlists = new Map<string, components['schemas']['Playlist']>(); 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 => ({ const me = (u: FakeUser): Me => ({
id: u.id, id: u.id,
username: u.username, username: u.username,
display_name: u.display_name, display_name: u.display_name,
role: u.role, role: u.role,
reviews_uploads: u.reviews_uploads, reviews_uploads: u.reviews_uploads,
manages_updates: u.role === 'admin' || !!u.manages_updates,
locale: u.locale, locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {}, ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password, must_change_password: u.must_change_password,
@@ -106,6 +122,25 @@ export function fakeApi(initial: FakeUser[]) {
calls.push({ method: req.method, path, body }); calls.push({ method: req.method, path, body });
const route = `${req.method} ${path}`; const route = `${req.method} ${path}`;
const user = current ? users.get(current) : undefined; 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) { switch (route) {
case 'GET /api/v1/server-info': case 'GET /api/v1/server-info':
@@ -157,20 +192,37 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized'); if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()] });
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
if (sharedPath && req.method === 'GET') {
const item = [...playlists.values()].find(p => p.share_id === sharedPath[1]);
return item ? json({ ...item, share_id: null }) : problem(404, 'playlist.not_found');
}
const sharePath = /^\/api\/v1\/me\/playlists\/([^/]+)\/share$/.exec(path);
if (sharePath) {
const item = playlists.get(sharePath[1]!);
if (!item || playlistOwners.get(item.id) !== user.id) return problem(404, 'playlist.not_found');
if (req.method === 'POST') {
const share_id = item.share_id ?? `00000000-0000-4000-9001-${String(++seq).padStart(12, '0')}`;
playlists.set(item.id, { ...item, share_id }); return json({ share_id });
}
if (req.method === 'DELETE') { playlists.set(item.id, { ...item, share_id: null }); return new Response(null, { status: 204 }); }
}
if (path === '/api/v1/me/playlists' && req.method === 'GET') return json({ items: [...playlists.values()].filter(p => !playlistOwners.has(p.id) || playlistOwners.get(p.id) === user.id) });
const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path); const playlistPath = /^\/api\/v1\/me\/playlists\/([^/]+)$/.exec(path);
if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) { if (path === '/api/v1/me/playlists' && req.method === 'POST' || playlistPath) {
const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`; const id = playlistPath?.[1] ?? `00000000-0000-4000-9000-${String(++seq).padStart(12, '0')}`;
const old = playlists.get(id); const old = playlists.get(id);
if (playlistPath && !old) return problem(404, 'playlist.not_found'); if (playlistPath && (!old || (playlistOwners.has(id) && playlistOwners.get(id) !== user.id))) return problem(404, 'playlist.not_found');
if (req.method === 'GET') return json(old); if (req.method === 'GET') return json(old);
if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); } if (req.method === 'DELETE') { playlists.delete(id); return new Response(null, { status: 204 }); }
if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict'); if (old && body?.revision !== old.revision) return problem(409, 'playlist.conflict');
const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!); const tracks = (body?.track_ids as string[]).map(id => albumTracks.find(t => t.id === id)!);
const item = { id, name: body?.name as string, description: body?.description as string, tracks, const item = { ...old, id, name: body?.name as string, description: body?.description as string, tracks,
track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0), track_count: tracks.length, duration_ms: tracks.reduce((sum, t) => sum + t.duration_ms, 0),
revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() }; revision: (old?.revision ?? 0) + 1, created_at: old?.created_at ?? new Date().toISOString(), updated_at: new Date().toISOString() };
playlists.set(id, item); playlists.set(id, item);
if (!old) playlistOwners.set(id, user.id);
return json(item, old ? 200 : 201); return json(item, old ? 200 : 201);
} }
@@ -305,6 +357,10 @@ export function fakeApi(initial: FakeUser[]) {
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] }); return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
target.username = username; 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 (typeof body?.reviews_uploads === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden'); if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.reviews_uploads = body.reviews_uploads; target.reviews_uploads = body.reviews_uploads;
+10
View File
@@ -37,6 +37,7 @@ import { Ambient } from './Ambient';
import { PlayerBar } from './PlayerBar'; import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox'; import { SearchBox } from './SearchBox';
import s from './AppShell.module.css'; import s from './AppShell.module.css';
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
type NavPath = type NavPath =
| '/' | '/'
@@ -47,8 +48,11 @@ type NavPath =
| '/admin/uploads' | '/admin/uploads'
| '/uploads/new' | '/uploads/new'
| '/uploads/mine' | '/uploads/mine'
| '/admin/updates'
| '/admin/config'
| '/admin/settings' | '/admin/settings'
| '/collection/playlists' | '/collection/playlists'
| '/collection/downloads'
| '/collection/history' | '/collection/history'
| '/collection/likes' | '/collection/likes'
| '/collection/dislikes'; | '/collection/dislikes';
@@ -147,6 +151,7 @@ function NavSection({
export function AppShell() { export function AppShell() {
return useAuthState().status === 'signed_in' ? ( return useAuthState().status === 'signed_in' ? (
<PlayerProvider> <PlayerProvider>
<DesktopUpdateDialog />
<Shell /> <Shell />
</PlayerProvider> </PlayerProvider>
) : null; ) : null;
@@ -174,6 +179,7 @@ function Shell() {
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} /> <NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}> <NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} /> <NavLink to="/collection/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/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} /> <NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} count={counts.likes} />
<NavLink <NavLink
@@ -193,6 +199,7 @@ function Shell() {
badge={pending} badge={pending}
/> />
)} )}
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{staff && ( {staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded> <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} /> <NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
@@ -211,8 +218,10 @@ function Shell() {
badge={pending} badge={pending}
/> />
)} )}
{(user.role === 'admin' || user.manages_updates) && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{user.role === 'admin' && ( {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/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} /> <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 }); const key = useRouterState({ select: (st) => st.location.pathname });
return ( return (
<main id="content" className={s.content} tabIndex={-1}> <main id="content" className={s.content} tabIndex={-1}>
<DesktopOfflineBanner />
<div key={key} className={s.routeIn}> <div key={key} className={s.routeIn}>
<Suspense fallback={<PageSkeleton />}> <Suspense fallback={<PageSkeleton />}>
<Outlet /> <Outlet />
+6 -6
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react'; import { wavePalette, WAVE_COLORS } from '@pipu/core';
import { useEffect, useMemo, useRef, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
import { drawLiquid, drawOrb, step, type WaveState } from './draw'; import { drawLiquid, drawOrb, step, type WaveState } from './draw';
@@ -8,7 +9,7 @@ export interface WaveButtonProps {
readAudio?: () => { bass: number; mid: number; treble: number }; readAudio?: () => { bass: number; mid: number; treble: number };
bpm?: number; bpm?: number;
energy?: number; energy?: number;
colors?: [string, string, string]; colors?: readonly string[];
theme?: 'dark' | 'light'; theme?: 'dark' | 'light';
interactive?: boolean; interactive?: boolean;
size?: number; size?: number;
@@ -17,8 +18,6 @@ export interface WaveButtonProps {
children?: ReactNode; children?: ReactNode;
} }
const DEFAULT_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
function prefersReducedMotion(): boolean { function prefersReducedMotion(): boolean {
return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches; return typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
} }
@@ -29,6 +28,7 @@ function prefersReducedMotion(): boolean {
*/ */
export function WaveButton(props: WaveButtonProps) { export function WaveButton(props: WaveButtonProps) {
const size = props.size ?? 320; const size = props.size ?? 320;
const colors = useMemo(() => wavePalette(props.colors), [props.colors]);
const audioRef = useRef(props.readAudio); const audioRef = useRef(props.readAudio);
audioRef.current = props.readAudio; audioRef.current = props.readAudio;
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
@@ -36,7 +36,7 @@ export function WaveButton(props: WaveButtonProps) {
const wrapRef = useRef<HTMLDivElement>(null); const wrapRef = useRef<HTMLDivElement>(null);
const st = useRef<WaveState>({ const st = useRef<WaveState>({
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0, hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: DEFAULT_COLORS, speed: 1, phase: 0, variant: 'orb', playing: false, bpm: 100, energy: 0.6, colors: WAVE_COLORS,
dark: true, reduced: false, size, dark: true, reduced: false, size,
}); });
// Пропсы читаются в цикле анимации через ref — перерисовка React не нужна. // Пропсы читаются в цикле анимации через ref — перерисовка React не нужна.
@@ -45,7 +45,7 @@ export function WaveButton(props: WaveButtonProps) {
s.playing = !!props.playing; s.playing = !!props.playing;
s.bpm = props.bpm ?? 100; s.bpm = props.bpm ?? 100;
s.energy = props.energy ?? 0.6; s.energy = props.energy ?? 0.6;
s.colors = props.colors ?? DEFAULT_COLORS; s.colors = colors;
s.dark = props.theme !== 'light'; s.dark = props.theme !== 'light';
s.size = size; s.size = size;
+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 dockerfile: services/api/Dockerfile
target: api target: api
args: *version args: *version
env_file: .env env_file: [config.local, .env]
environment: &db-env environment: &db-env
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
volumes: volumes:
@@ -63,6 +63,7 @@ services:
- *library-host-ro - *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache - ${PIPU_DATA:-/srv/pipu}/cache:/cache
- ${PIPU_DATA:-/srv/pipu}/updates:/updates - ${PIPU_DATA:-/srv/pipu}/updates:/updates
- ${PIPU_DATA:-/srv/pipu}/management:/management
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads - ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
@@ -112,6 +113,11 @@ services:
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)} INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
LOG_LEVEL: ${LOG_LEVEL:-info} 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: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
@@ -124,6 +130,11 @@ services:
<<: *db-env <<: *db-env
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
LOG_LEVEL: ${LOG_LEVEL:-info} 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: volumes:
- *library-ro - *library-ro
- *library-host-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
fi fi
bash ./install-management.sh
echo "Готово. Сборка и запуск последнего релиза: ./update.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 trap 'rm -f "$0"' EXIT
cd "$PIPU_DEPLOY_DIR" 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; } [ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз, # Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
@@ -69,7 +78,15 @@ if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [
fi fi
REF="${1:-}" 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 if [ -z "$REF" ]; then
REF="$(git tag -l 'v*' --sort=-v:refname | grep -v -- '-rc' | head -1)" 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 fi
echo "→ переключаемся на $REF" 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 checkout --quiet "$REF"
git merge --ff-only --quiet "origin/$REF" git merge --ff-only --quiet "origin/$REF"
else else
git checkout --quiet --detach "$REF" git checkout --quiet --detach "$REF"
fi 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}" VERSION="${VERSION#v}"
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)" echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)"
docker compose build --pull compose config --quiet
docker compose up -d --remove-orphans 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 docker image prune -f >/dev/null
echo "→ ждём готовности api" echo "→ ждём готовности api"
for _ in $(seq 1 60); do for _ in $(seq 1 60); do
# 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4 # 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 запущен" echo "pipu $VERSION запущен"
exit 0 exit 0
fi fi
+1
View File
@@ -24,6 +24,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) | | [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android | | [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика | | [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
| [server-management.md](server-management.md) | Управление обновлениями и config.local из админки, установка агента |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные | | [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
## Стек в одном абзаце ## Стек в одном абзаце
+5
View File
@@ -133,3 +133,8 @@ APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релиз
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске | | Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает). Остальное — [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) и повторяет запросы. делает **единственный** параллельный refresh (single-flight) и повторяет запросы.
- Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`; - Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`;
админские разделы — по `role`. админские разделы — по `role`.
Управление обновлениями сервера — право `manages_updates`: у admin всегда, другим его выдаёт и отзывает только admin. Оно даёт доступ к `/admin/updates`, управлению источниками и запуску обновления, но не к конфигурации сервера. `/admin/config` доступен только admin. Подробности: [server-management.md](server-management.md).
+6
View File
@@ -45,6 +45,12 @@
Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`. Палитра волны по умолчанию: `#2F80FF`, `#7C5CFF`, `#35D3FF`. Логотип — светлая «p»-нота на сфере с радиальным градиентом `#9CC6FF → #2F80FF → #6354E8 → #3A1FA8`.
Цвета обложки адаптируются в `wavePalette` (`packages/core/src/wave-anim.ts`) одинаково для веба и Android:
основой служит оттенок с наибольшей разницей RGB-каналов (> 0.08), акценты отклоняются на ±28°,
насыщенность ограничена 72–82%, светлота — 46–58%. Так тёмные цвета остаются видимыми,
а противоположные оттенки не обесцвечивают друг друга при наложении. Для почти бесцветных обложек
и отсутствующей или некорректной палитры используется стандартная палитра волны.
Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения. Исходник значка — `apps/web/public/favicon.svg`; он же используется в шапке веб-приложения.
Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon Команда `pnpm icons` пересобирает PNG/ICO/ICNS для desktop, favicon PNG, Apple touch icon
и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной и Android-ресурсы в `apps/mobile/assets/`. Android использует отдельные адаптивный цветной
+29
View File
@@ -15,3 +15,32 @@ pnpm --filter @pipu/desktop bundle
На Windows получается NSIS-установщик (~3 МБ). На Linux в релиз идёт только `.deb` (~4 МБ): AppImage тащит WebKitGTK и раздувается до ~80 МБ. Зависимости пакета: WebKitGTK 4.1, AppIndicator, libsecret, GStreamer. На 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. Релиз по тегу собирает 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 — чтение тегов). Ожидаемое время первого скана 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.*` в папке альбома → плейсхолдер, Источники по приоритету: встроенная в файл → `cover.jpg|folder.jpg|front.*` в папке альбома → плейсхолдер,
+1 -1
View File
@@ -109,7 +109,7 @@
кэширует ответ на 10 мин и APK на диске. Так: кэширует ответ на 10 мин и APK на диске. Так:
- мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным); - мобильному приложению не нужен доступ к Gitea и токен (репозиторий может быть приватным);
- версия приложения согласована с версией сервера (`min_supported` из `server-info`). - версия приложения согласована с версией сервера (`min_supported` из `server-info`).
2. Если `version_code` новее — диалог с changelog (Markdown из релиза), кнопки «Обновить» / «Позже». 2. Если `version_code` новее — диалог с changelog (текст из Markdown релиза без служебных CI-блоков сборок), кнопки «Обновить» / «Позже».
Если текущая версия ниже `min_supported` — обновление обязательно. Если текущая версия ниже `min_supported` — обновление обязательно.
3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза). 3. Загрузка APK через `expo-file-system` в `cacheDirectory` с прогрессом; проверка **SHA-256** (значение из релиза).
Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`, Хэш считается кусками по 1 МБ (`js-sha256`), APK целиком в память JS не читается. Загрузка идёт с `Bearer`,
+21 -2
View File
@@ -1,6 +1,6 @@
# Личные плейлисты # Личные плейлисты
Плейлисты хранятся в PostgreSQL и доступны владельцу на вебе и в React Native-клиенте. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции. Плейлисты хранятся в PostgreSQL. Владелец управляет ими в вебе, desktop и React Native-клиенте; по желанию может поделиться ссылкой для прослушивания. В вебе раздел находится в коллекции; на узком экране — в меню «Ещё». В мобильном клиенте — кнопка «Плейлисты» в коллекции.
Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога. Можно создать плейлист, изменить название и описание, добавить трек из поиска, альбома или коллекции, убрать трек и переместить его выше/ниже. В вебе можно добавить целый альбом. Один трек встречается в плейлисте один раз: повторное добавление не создаёт дубль. Воспроизведение доступно в сохранённом или случайном порядке; недоступные треки остаются видимыми, но пропускаются при запуске всего плейлиста. Удаление плейлиста требует подтверждения и не удаляет музыку из каталога.
@@ -11,12 +11,31 @@
- `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками. - `GET /api/v1/me/playlists/{id}` — метаданные и упорядоченные треки с персональными реакциями и подписанными ссылками.
- `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста. - `PUT /api/v1/me/playlists/{id}` — атомарная замена метаданных и порядка треков; дополнительно требуется `revision` из прочитанного плейлиста.
- `DELETE /api/v1/me/playlists/{id}` — удаление. - `DELETE /api/v1/me/playlists/{id}` — удаление.
- `POST /api/v1/me/playlists/{id}/share` — создать или вернуть действующий `share_id` (только владелец).
- `DELETE /api/v1/me/playlists/{id}/share` — отключить доступ по ссылке (только владелец).
- `GET /api/v1/shared/playlists/{shareId}` — прочитать плейлист по действующей ссылке; требуется вход на этот сервер.
Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками. Все операции требуют авторизации. Чужой и отсутствующий плейлист возвращают одинаковый `404`. Изменения блокируют строку плейлиста в транзакции; устаревшая версия возвращает `409 playlist.conflict`. Редактор сохраняет исходную версию до закрытия, даже если фоновый запрос получил новую. Ошибка трека откатывает изменение целиком. Ввод ограничен 120 символами названия, 2000 символами описания и 2000 уникальными треками.
Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы. Миграция `00013_playlists.sql` создаёт таблицы `playlists`, `playlist_tracks` и индексы. Удаление пользователя очищает его плейлисты; удаление трека из каталога очищает его членство. Таблицы не затрагивают музыкальные файлы.
Плейлисты личные; публичные ссылки, совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста. Изначально плейлисты личные. Кнопка «Поделиться плейлистом» включает доступ по ссылке
`/shared/playlist/{shareId}`. В вебе/desktop ссылка копируется, в мобильном браузере и Android открывается
системное меню «Поделиться». Desktop использует адрес музыкального сервера, а не локального WebView.
Как и ссылки на треки, ссылка требует входа на тот же сервер. После входа открывается запрошенный плейлист,
без автоматического запуска музыки. Получатель может слушать в обычном или случайном порядке и переслать
ту же ссылку, но не может редактировать или удалять исходный плейлист.
Ссылка содержит отдельный случайный UUID и не раскрывает токены авторизации или подписанные URL аудио.
Список `/me/playlists` и обычное чтение по UUID плейлиста остаются доступны только владельцу. Реакции
на треки и ссылки стрима в общем плейлисте принадлежат текущему слушателю. Данные общих страниц в веб-кэше
разделяются по пользователям.
«Отключить доступ по ссылке» отзывает прежний `share_id`; следующая публикация создаёт новый.
Повторное нажатие «Поделиться» без отзыва сохраняет ссылку. Ссылка показывает актуальный порядок и метаданные;
изменение доступа не меняет `revision` и не вызывает конфликт редактора. При удалении плейлиста или блокировке
владельца ссылка возвращает `404`. Миграция `00016_playlist_sharing.sql` добавляет `share_id` и уникальный индекс.
Совместное редактирование и загрузка пользовательских обложек не входят в эту реализацию. Карточки имеют градиентную обложку, страница использует обложку первого трека. Контекст воспроизведения — `collection` с UUID и названием плейлиста.
## Проверки ## Проверки
+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`.
+454
View File
@@ -4,6 +4,70 @@
*/ */
export interface paths { 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": { "/api/v1/admin/genius": {
parameters: { parameters: {
query?: never; query?: never;
@@ -860,6 +924,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/admin/library/host-dirs": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1439,10 +1520,109 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/me/playlists/{id}/share": {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
get?: never;
put?: never;
/** Создать или вернуть ссылку только для чтения (только владелец) */
post: operations["sharePlaylist"];
/** Отозвать ссылку (только владелец); повторная публикация создаёт новую ссылку */
delete: operations["revokePlaylistShare"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/shared/playlists/{shareId}": {
parameters: {
query?: never;
header?: never;
path: {
shareId: string;
};
cookie?: never;
};
/** Прочитать плейлист по действующей ссылке (требуется вход) */
get: operations["getSharedPlaylist"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
} }
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { 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;
};
PlaylistInput: { PlaylistInput: {
name: string; name: string;
description: string; description: string;
@@ -1466,6 +1646,11 @@ export interface components {
updated_at: string; updated_at: string;
}; };
Playlist: components["schemas"]["PlaylistSummary"] & { Playlist: components["schemas"]["PlaylistSummary"] & {
/**
* Format: uuid
* @description Действующая ссылка; возвращается только владельцу через /me/playlists
*/
readonly share_id?: string | null;
tracks: components["schemas"]["Track"][]; tracks: components["schemas"]["Track"][];
}; };
PlaylistList: { PlaylistList: {
@@ -1794,6 +1979,8 @@ export interface components {
username: string; username: string;
display_name: string | null; display_name: string | null;
role: components["schemas"]["Role"]; role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */ /** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */
reviews_uploads: boolean; reviews_uploads: boolean;
locale: string; locale: string;
@@ -1823,6 +2010,8 @@ export interface components {
username: string; username: string;
display_name: string | null; display_name: string | null;
role: components["schemas"]["Role"]; role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */ /** @description Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу */
reviews_uploads: boolean; reviews_uploads: boolean;
locale: string; locale: string;
@@ -1842,6 +2031,8 @@ export interface components {
username: string; username: string;
display_name?: string; display_name?: string;
role: components["schemas"]["Role"]; role: components["schemas"]["Role"];
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Сразу выдать аудит загрузок. Может задать только админ */ /** @description Сразу выдать аудит загрузок. Может задать только админ */
reviews_uploads?: boolean; reviews_uploads?: boolean;
}; };
@@ -1857,6 +2048,8 @@ export interface components {
display_name?: string; display_name?: string;
role?: components["schemas"]["Role"]; role?: components["schemas"]["Role"];
is_blocked?: boolean; is_blocked?: boolean;
/** @description Управление обновлениями сервера. У admin всегда; другим выдаёт только admin */
manages_updates?: boolean;
/** @description Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя */ /** @description Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя */
reviews_uploads?: boolean; reviews_uploads?: boolean;
}; };
@@ -1895,6 +2088,7 @@ export interface components {
/** @enum {string} */ /** @enum {string} */
TagPriority: "tags_first" | "path_first"; TagPriority: "tags_first" | "path_first";
Source: { Source: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
name: string; name: string;
@@ -1923,6 +2117,7 @@ export interface components {
items: components["schemas"]["Source"][]; items: components["schemas"]["Source"][];
}; };
SourceInput: { SourceInput: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name: string; name: string;
type: components["schemas"]["SourceType"]; type: components["schemas"]["SourceType"];
nfs_server?: string; nfs_server?: string;
@@ -1935,6 +2130,7 @@ export interface components {
write_template?: string; write_template?: string;
}; };
SourcePatch: { SourcePatch: {
excluded_dirs?: components["schemas"]["ExcludedDirs"];
name?: string; name?: string;
nfs_server?: string; nfs_server?: string;
nfs_export?: string; nfs_export?: string;
@@ -1953,6 +2149,8 @@ export interface components {
*/ */
write_template?: string; 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: { SourceTestResult: {
ok: boolean; ok: boolean;
message: string; message: string;
@@ -2030,6 +2228,15 @@ export interface components {
full?: boolean; full?: boolean;
}; };
ScanProgress: { 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; running: boolean;
full: boolean; full: boolean;
/** Format: date-time */ /** Format: date-time */
@@ -2079,6 +2286,21 @@ export interface components {
items: components["schemas"]["LibraryError"][]; items: components["schemas"]["LibraryError"][];
next_cursor: string | null; 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: { HostDirList: {
path: string; path: string;
dirs: string[]; dirs: string[];
@@ -2469,6 +2691,146 @@ export interface components {
} }
export type $defs = Record<string, never>; export type $defs = Record<string, never>;
export interface operations { 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: { getGeniusStats: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3712,6 +4074,31 @@ export interface operations {
default: components["responses"]["Problem"]; 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: { listHostDirs: {
parameters: { parameters: {
query?: { query?: {
@@ -4607,4 +4994,71 @@ export interface operations {
default: components["responses"]["Problem"]; default: components["responses"]["Problem"];
}; };
}; };
sharePlaylist: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Идентификатор ссылки для /shared/playlist/{shareId}; для открытия требуется вход */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PlaylistShare"];
};
};
default: components["responses"]["Problem"];
};
};
revokePlaylistShare: {
parameters: {
query?: never;
header?: never;
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Доступ по прежней ссылке отключён */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getSharedPlaylist: {
parameters: {
query?: never;
header?: never;
path: {
shareId: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Плейлист с реакциями и ссылками на стрим для текущего слушателя */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Playlist"];
};
};
default: components["responses"]["Problem"];
};
};
} }
+3 -2
View File
@@ -22,10 +22,11 @@ export {
type Track, type Track,
} from './player'; } from './player';
export { normalizeGain, TARGET_LUFS } from './gain'; export { normalizeGain, TARGET_LUFS } from './gain';
export { trackShareUrl } from './share'; export { playlistShareUrl, trackShareUrl } from './share';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server'; export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, waveScale, type Rgb, type WaveState } from './wave-anim'; export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave'; export { WaveCoordinator, EARLY_SKIP_MS, WAVE_REFILL_AT, type WaveInfo, type WaveOutcome, type WaveFeedback, type WaveGateway } from './wave';
export { AGE_GROUPS, GENDERS } from './onboarding'; 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();
}
+6 -1
View File
@@ -1,9 +1,14 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { trackShareUrl } from './share'; import { playlistShareUrl, trackShareUrl } from './share';
describe('trackShareUrl', () => { describe('trackShareUrl', () => {
it('builds a page URL without stream credentials', () => { it('builds a page URL without stream credentials', () => {
expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd'); expect(trackShareUrl('https://music.example', 'ab/cd')).toBe('https://music.example/track/ab%2Fcd');
}); });
}); });
it('builds a shared playlist URL from the server without stream credentials', () => {
expect(playlistShareUrl('https://music.example/api/v1', 'ab/cd')).toBe('https://music.example/shared/playlist/ab%2Fcd');
});
+5
View File
@@ -2,3 +2,8 @@
export function trackShareUrl(server: string, trackId: string): string { export function trackShareUrl(server: string, trackId: string): string {
return new URL(`/track/${encodeURIComponent(trackId)}`, server).href; return new URL(`/track/${encodeURIComponent(trackId)}`, server).href;
} }
/** Revocable read-only playlist page; requires a listener account on this server. */
export function playlistShareUrl(server: string, shareId: string): string {
return new URL(`/shared/playlist/${encodeURIComponent(shareId)}`, server).href;
}
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import { rgb, wavePalette, WAVE_COLORS } from './wave-anim';
function hsl(color: string) {
const [r, g, b] = rgb(color).map((v) => v / 255) as [number, number, number];
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const c = max - min;
const l = (max + min) / 2;
const h = c === 0 ? 0 : max === r ? (g - b) / c : max === g ? (b - r) / c + 2 : (r - g) / c + 4;
return { h: (h * 60 + 360) % 360, s: c === 0 ? 0 : c / (1 - Math.abs(2 * l - 1)), l };
}
describe('wave cover palette', () => {
it.each([
undefined, [], ['#fff'], ['#000', '#888', '#fff'],
['#010203', '#fefdfc', '#808182'], ['#ff0000', 'invalid', '#0000ff'],
])('uses the default palette for missing, neutral or malformed colors: %j', (colors) => {
expect(wavePalette(colors)).toEqual(WAVE_COLORS);
});
it('ignores neutral colors and preserves the strongest cover hue', () => {
const colors = ['#ffffff', '#007733', '#222222'];
const before = [...colors];
expect(hsl(wavePalette(colors)[0]).h).toBeCloseTo(hsl('#007733').h, 0);
expect(wavePalette(colors)).toEqual(wavePalette(['#007733', '#222222', '#ffffff']));
expect(colors).toEqual(before);
expect(wavePalette(['#fff', '#073', '#222'])).toEqual(wavePalette(colors));
});
it.each([
['#040e08', '#18281d', '#374450'], // dark, muted cover like the reported case
['#ff0000', '#00ff00', '#0000ff'], // opposing hues must not mix to gray
['#ffff00', '#ffffff', '#000000'],
['#ff0080', '#540029', '#fefefe'], // hue offsets wrap around red
])('keeps a visible, saturated, harmonious palette for %j', (...colors) => {
const palette = wavePalette(colors);
const base = hsl(palette[0]);
for (const color of palette) {
expect(color).toMatch(/^#[\da-f]{6}$/i);
const { h, s, l } = hsl(color);
expect(s).toBeGreaterThanOrEqual(0.70);
expect(s).toBeLessThanOrEqual(0.84);
expect(l).toBeGreaterThanOrEqual(0.45);
expect(l).toBeLessThanOrEqual(0.59);
const distance = Math.abs(h - base.h);
expect(Math.min(distance, 360 - distance)).toBeLessThan(29);
}
});
});
+33
View File
@@ -30,6 +30,39 @@ export interface WaveState {
export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF']; export const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
/** Keep the cover's strongest hue, but avoid muddy or complementary additive blends. */
export function wavePalette(colors?: readonly string[]): [string, string, string] {
if (colors?.length !== 3 || !colors.every((c) => /^#(?:[\da-f]{3}|[\da-f]{6})$/i.test(c))) {
return [...WAVE_COLORS];
}
let hue = 0;
let strongest = 0;
for (const color of colors) {
const [r, g, b] = rgb(color).map((v) => v / 255) as Rgb;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const chroma = max - min;
// Absolute chroma rejects both near-black noise and nearly white/gray covers.
if (chroma <= Math.max(0.08, strongest)) continue;
strongest = chroma;
hue = max === r ? ((g - b) / chroma) % 6 : max === g ? (b - r) / chroma + 2 : (r - g) / chroma + 4;
hue = (hue * 60 + 360) % 360;
}
if (!strongest) return [...WAVE_COLORS];
// A narrow hue range retains color when the translucent layers overlap.
return [paletteColor(hue, 0.78, 0.50), paletteColor(hue + 28, 0.72, 0.46), paletteColor(hue - 28, 0.82, 0.58)];
}
function paletteColor(hue: number, saturation: number, lightness: number): string {
const h = ((hue % 360) + 360) % 360 / 60;
const c = (1 - Math.abs(2 * lightness - 1)) * saturation;
const x = c * (1 - Math.abs(h % 2 - 1));
const m = lightness - c / 2;
const channels = h < 1 ? [c, x, 0] : h < 2 ? [x, c, 0] : h < 3 ? [0, c, x]
: h < 4 ? [0, x, c] : h < 5 ? [x, 0, c] : [c, 0, x];
return `#${channels.map((v) => Math.round((v + m) * 255).toString(16).padStart(2, '0')).join('')}`;
}
export function initialWaveState(size: number): WaveState { export function initialWaveState(size: number): WaveState {
return { return {
hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0, hover: 0, hoverT: 0, press: 0, pressV: 0, pressT: 0, mx: 0, my: 0, ang: 0, t: 0, amp: 0, pulse: 0,
+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_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", "recs_done": "{{username}}'s recommendations were reset",
"confirm": "Confirm", "confirm": "Confirm",
"you": "you" "you": "you",
"manage_updates": "Server updates",
"grant_updates": "Grant update management",
"revoke_updates": "Revoke update management"
}, },
"create": { "create": {
"title": "New user", "title": "New user",
@@ -71,7 +74,8 @@
"user.unblock": "User unblocked", "user.unblock": "User unblocked",
"user.password_reset": "Password reset", "user.password_reset": "Password reset",
"user.recommendations_reset": "Recommendations reset", "user.recommendations_reset": "Recommendations reset",
"user.delete": "User deleted" "user.delete": "User deleted",
"user.manages_updates": "Server update permission changed"
} }
}, },
"sources": { "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.", "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}}", "scanning": "Scanning: {{processed}} of {{total}}",
"scan_walking": "Walking folders…", "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}}", "scan_failed": "Scan stopped: {{error}}",
"test_ok": "Reachable, audio files found: {{count}}", "test_ok": "Reachable, audio files found: {{count}}",
"test_fail": "Not reachable: {{message}}", "test_fail": "Not reachable: {{message}}",
@@ -172,7 +184,15 @@
"wave_likes": "Likes per 100", "wave_likes": "Likes per 100",
"wave_model": "Model version", "wave_model": "Model version",
"wave_by_model": "Wave model versions compared", "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": { "write_template": {
"title": "Uploads and path template", "title": "Uploads and path template",
@@ -362,5 +382,85 @@
"server_settings": { "server_settings": {
"title": "Server settings", "title": "Server settings",
"subtitle": "Settings that apply to all users" "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", "copy_server": "Copy the server address",
"step_updates": { "step_updates": {
"android": "Sign in with your account. The app offers new versions by itself.", "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.", "windows": "Sign in with your account. The app will offer new versions.",
"linux": "Sign in with your account. Get a new version from this page.", "linux": "Sign in with your account. The app will offer new versions through your system package installer.",
"macos": "Sign in with your account. Get a new version from this page." "macos": "Sign in with your account. The app will offer new versions."
}, },
"checksum": "SHA-256 checksum", "checksum": "SHA-256 checksum",
"unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).", "unavailable": "Apps are not available yet: release downloads are not configured on this server (docs/deployment.md).",
+28 -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": { "genius": {
"description": "Lyrics, annotations and missing artwork from Genius. Local artwork takes priority.", "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.", "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", "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", "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": { "app": {
"name": "pipu", "name": "pipu",
@@ -44,7 +62,9 @@
"suggest": "Suggest tracks", "suggest": "Suggest tracks",
"my_uploads": "My uploads", "my_uploads": "My uploads",
"upload_queue": "Queue", "upload_queue": "Queue",
"admin_settings": "Settings" "admin_settings": "Settings",
"server_updates": "Updates",
"server_config": "Configuration"
}, },
"profile": { "profile": {
"menu": "Profile menu", "menu": "Profile menu",
@@ -340,6 +360,11 @@
} }
}, },
"playlists": { "playlists": {
"share": "Share playlist",
"link_copied": "Playlist link copied",
"shared": "Shared by link",
"revoke_share": "Disable link access",
"share_hint": "Anyone with an account on this server and this link can listen. Only you can edit the playlist.",
"name": "Name", "name": "Name",
"delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.", "delete_confirm": "Delete playlist “{{name}}”? Tracks will remain in your library.",
"manage": "Manage tracks", "manage": "Manage tracks",
+17
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Pattern error: {{reason}}", "pattern_invalid": "Pattern error: {{reason}}",
"nfs_invalid": "Invalid value", "nfs_invalid": "Invalid value",
"local_path_invalid": "Folder not found", "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", "name_invalid": "The name must be 1 to {{max}} characters",
"scan_interval_invalid": "The interval must be at least {{min}} minutes", "scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type", "source_type_invalid": "Unknown storage type",
@@ -100,5 +101,21 @@
"invalid_tracks": "Tracks must be unique and exist in the library", "invalid_tracks": "Tracks must be unique and exist in the library",
"too_large": "Maximum 2000 tracks and 2000 description characters", "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." "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_confirm": "Сбросить рекомендации {{username}}? Волна начнёт подбирать музыку с нуля и снова спросит любимых исполнителей. Лайки и история останутся.",
"recs_done": "Рекомендации {{username}} сброшены", "recs_done": "Рекомендации {{username}} сброшены",
"confirm": "Подтвердить", "confirm": "Подтвердить",
"you": "вы" "you": "вы",
"manage_updates": "Обновление сервера",
"grant_updates": "Дать управление обновлениями",
"revoke_updates": "Забрать управление обновлениями"
}, },
"create": { "create": {
"title": "Новый пользователь", "title": "Новый пользователь",
@@ -71,7 +74,8 @@
"user.unblock": "Пользователь разблокирован", "user.unblock": "Пользователь разблокирован",
"user.password_reset": "Сброшен пароль", "user.password_reset": "Сброшен пароль",
"user.recommendations_reset": "Сброшены рекомендации", "user.recommendations_reset": "Сброшены рекомендации",
"user.delete": "Пользователь удалён" "user.delete": "Пользователь удалён",
"user.manages_updates": "Изменены права обновления сервера"
} }
}, },
"sources": { "sources": {
@@ -98,7 +102,15 @@
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.", "delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
"scanning": "Сканирование: {{processed}} из {{total}}", "scanning": "Сканирование: {{processed}} из {{total}}",
"scan_walking": "Обход папок…", "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}}", "scan_failed": "Скан прервался: {{error}}",
"test_ok": "Доступно, найдено аудиофайлов: {{count}}", "test_ok": "Доступно, найдено аудиофайлов: {{count}}",
"test_fail": "Нет доступа: {{message}}", "test_fail": "Нет доступа: {{message}}",
@@ -172,7 +184,15 @@
"wave_likes": "Лайков на 100", "wave_likes": "Лайков на 100",
"wave_model": "Версия модели", "wave_model": "Версия модели",
"wave_by_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": { "write_template": {
"title": "Приём загрузок и шаблон пути", "title": "Приём загрузок и шаблон пути",
@@ -362,5 +382,85 @@
"server_settings": { "server_settings": {
"title": "Настройки сервера", "title": "Настройки сервера",
"subtitle": "Параметры, которые действуют для всех пользователей" "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": "Скопировать адрес сервера", "copy_server": "Скопировать адрес сервера",
"step_updates": { "step_updates": {
"android": "Войдите своим логином. Новые версии приложение предложит установить само.", "android": "Войдите своим логином. Новые версии приложение предложит установить само.",
"windows": "Войдите своим логином. Новую версию скачайте с этой страницы.", "windows": "Войдите своим логином. Приложение предложит установить новые версии.",
"linux": "Войдите своим логином. Новую версию скачайте с этой страницы.", "linux": "Войдите своим логином. Приложение предложит установить новые версии через системный установщик пакетов.",
"macos": "Войдите своим логином. Новую версию скачайте с этой страницы." "macos": "Войдите своим логином. Приложение предложит установить новые версии."
}, },
"checksum": "Контрольная сумма SHA-256", "checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).", "unavailable": "Приложения пока недоступны: на сервере не настроена загрузка релизов (docs/deployment.md).",
+28 -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": { "genius": {
"description": "Тексты, пояснения и недостающие картинки из 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.", "configure": "Добавьте GENIUS_ACCESS_TOKEN в deploy/.env и пересоздайте контейнер API.",
"total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные", "total": "Всего треков", "pending": "В очереди", "matched": "Найдено в Genius", "lyrics": "Тексты", "annotations": "С пояснениями", "images": "С картинками", "unmatched": "Нет точного совпадения", "errors": "Ошибки / неполные данные",
"last_error": "Последняя ошибка", "token_error": "Genius отклонил токен. Проверьте его и перезапустите API.", "access_error": "Genius ограничил доступ. Обработка приостановлена.", "no_lyrics": "Текст пока недоступен.", "request_error": "Не удалось получить все данные. Повтор запланирован автоматически.", "resume_at": "Повтор не раньше", "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": { "app": {
"name": "pipu", "name": "pipu",
@@ -44,7 +62,9 @@
"suggest": "Предложить треки", "suggest": "Предложить треки",
"my_uploads": "Мои предложения", "my_uploads": "Мои предложения",
"upload_queue": "Очередь", "upload_queue": "Очередь",
"admin_settings": "Настройки" "admin_settings": "Настройки",
"server_updates": "Обновления",
"server_config": "Конфигурация"
}, },
"profile": { "profile": {
"menu": "Меню профиля", "menu": "Меню профиля",
@@ -340,6 +360,11 @@
} }
}, },
"playlists": { "playlists": {
"share": "Поделиться плейлистом",
"link_copied": "Ссылка на плейлист скопирована",
"shared": "Доступ по ссылке",
"revoke_share": "Отключить доступ по ссылке",
"share_hint": "Любой пользователь этого сервера со ссылкой сможет слушать плейлист. Изменять его можете только вы.",
"name": "Название", "name": "Название",
"delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.", "delete_confirm": "Удалить плейлист «{{name}}»? Треки останутся в библиотеке.",
"manage": "Управлять треками", "manage": "Управлять треками",
+17
View File
@@ -42,6 +42,7 @@
"pattern_invalid": "Ошибка в шаблоне: {{reason}}", "pattern_invalid": "Ошибка в шаблоне: {{reason}}",
"nfs_invalid": "Недопустимое значение", "nfs_invalid": "Недопустимое значение",
"local_path_invalid": "Папка не найдена", "local_path_invalid": "Папка не найдена",
"excluded_dirs_invalid": "Укажите до 100 папок: относительные пути без .., по одной папке в строке (до 1000 символов).",
"name_invalid": "Название — от 1 до {{max}} символов", "name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут", "scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища", "source_type_invalid": "Неизвестный тип хранилища",
@@ -100,5 +101,21 @@
"invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке", "invalid_tracks": "Треки не должны повторяться и должны существовать в библиотеке",
"too_large": "Максимум 2000 треков и 2000 символов описания", "too_large": "Максимум 2000 треков и 2000 символов описания",
"conflict": "Плейлист изменён на другом устройстве. Закройте редактор и повторите изменение с обновлёнными данными." "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/buildinfo"
"pipu/api/internal/genius" "pipu/api/internal/genius"
"pipu/api/internal/library" "pipu/api/internal/library"
"pipu/api/internal/management"
"pipu/api/internal/platform/config" "pipu/api/internal/platform/config"
"pipu/api/internal/platform/db" "pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx" "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) Client: &http.Client{}}, libStore, dto, log)
wh.Warm = pb.Prewarm wh.Warm = pb.Prewarm
return server.NewRouter(server.Deps{ return server.NewRouter(server.Deps{
Management: management.NewHandler(management.Mailbox{Dir: cfg.ManagementDir}),
Genius: genius.NewHandler(geniusSvc), Genius: genius.NewHandler(geniusSvc),
Log: log, Log: log,
System: sys, 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 Username string
DisplayName string DisplayName string
Role Role Role Role
ManagesUpdates bool
ReviewsUploads 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) { if !CanAssignRole(p.Role, in.Role) || !CanManageUser(actorOf(p), Actor{Role: in.Role}, UserCreate) {
return User{}, "", forbidden() return User{}, "", forbidden()
} }
if in.ReviewsUploads && p.Role != RoleAdmin { if (in.ReviewsUploads || in.ManagesUpdates) && p.Role != RoleAdmin {
return User{}, "", forbidden() return User{}, "", forbidden()
} }
username := NormalizeUsername(in.Username) username := NormalizeUsername(in.Username)
@@ -114,7 +115,7 @@ func (s *Service) CreateUser(ctx context.Context, p Principal, in CreateUserInpu
} }
u := User{ u := User{
ID: s.ids.New(), Username: username, DisplayName: name, PasswordHash: hash, Role: in.Role, 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(), MustChangePassword: true, CreatedAt: s.clock.Now(),
} }
err = s.store.WithTx(ctx, func(tx Store) error { 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{ return tx.InsertAudit(ctx, s.ids.New(), AuditEntry{
ActorID: &p.UserID, Action: "user.create", TargetType: "user", TargetID: &u.ID, 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) { if errors.Is(err, ErrUsernameTaken) {
@@ -154,6 +155,7 @@ type UserPatch struct {
DisplayName *string DisplayName *string
Role *Role Role *Role
IsBlocked *bool IsBlocked *bool
ManagesUpdates *bool
ReviewsUploads *bool ReviewsUploads *bool
} }
@@ -218,6 +220,15 @@ func (s *Service) UpdateUser(ctx context.Context, p Principal, id uuid.UUID, pat
upd.ReviewsUploads = patch.ReviewsUploads upd.ReviewsUploads = patch.ReviewsUploads
audit("user.reviews_uploads", map[string]any{"reviews_uploads": *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 blockChange := patch.IsBlocked != nil && *patch.IsBlocked != target.IsBlocked
if blockChange { if blockChange {
if !CanManageUser(actor, tgt, UserBlock) { 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 return nil, err
} }
in := CreateUserInput{Username: req.Body.Username, Role: Role(req.Body.Role)} 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 { if req.Body.ReviewsUploads != nil {
in.ReviewsUploads = *req.Body.ReviewsUploads in.ReviewsUploads = *req.Body.ReviewsUploads
} }
@@ -63,7 +66,7 @@ func (h *Handler) UpdateUser(ctx context.Context, req gen.UpdateUserRequestObjec
if err != nil { if err != nil {
return nil, err return nil, err
} }
patch := UserPatch{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 { if req.Body.Role != nil {
r := Role(*req.Body.Role) r := Role(*req.Body.Role)
patch.Role = &r patch.Role = &r
@@ -151,6 +154,7 @@ func AdminUserDTO(u User) gen.AdminUser {
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Role: gen.Role(u.Role), Role: gen.Role(u.Role),
ReviewsUploads: u.ReviewsUploads, ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: updatePermission(u),
Locale: u.Locale, Locale: u.Locale,
IsBlocked: u.IsBlocked, IsBlocked: u.IsBlocked,
MustChangePassword: u.MustChangePassword, 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)) me = e.do(http.MethodGet, "/api/v1/me", nil, bearer(mod))
assert.Equal(t, true, me.body["reviews_uploads"]) 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 { if upd.ReviewsUploads != nil {
u.ReviewsUploads = *upd.ReviewsUploads u.ReviewsUploads = *upd.ReviewsUploads
} }
if upd.ManagesUpdates != nil {
u.ManagesUpdates = *upd.ManagesUpdates
}
if upd.RevokeTokens { if upd.RevokeTokens {
u.TokenVersion++ u.TokenVersion++
} }
+3
View File
@@ -232,6 +232,7 @@ func MeDTO(u User) gen.Me {
DisplayName: u.DisplayName, DisplayName: u.DisplayName,
Role: gen.Role(u.Role), Role: gen.Role(u.Role),
ReviewsUploads: u.ReviewsUploads, ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: updatePermission(u),
Locale: u.Locale, Locale: u.Locale,
UiPrefs: uiPrefsDTO(u.UIPrefs), UiPrefs: uiPrefsDTO(u.UIPrefs),
MustChangePassword: u.MustChangePassword, MustChangePassword: u.MustChangePassword,
@@ -271,3 +272,5 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
Normalize: p.Normalize, 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 DisplayName *string
PasswordHash string PasswordHash string
Role Role Role Role
ManagesUpdates bool
ReviewsUploads bool // аудит очереди на добавление; не следует из роли ReviewsUploads bool // аудит очереди на добавление; не следует из роли
Locale string Locale string
UIPrefs UIPrefs UIPrefs UIPrefs
@@ -96,6 +97,7 @@ type Session struct {
type Principal struct { type Principal struct {
UserID uuid.UUID UserID uuid.UUID
Role Role Role Role
ManagesUpdates bool
ReviewsUploads bool ReviewsUploads bool
SessionID uuid.UUID SessionID uuid.UUID
MustChangePassword bool 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, 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, 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) { func scanUser(row pgx.Row) (User, error) {
var ( 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, 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.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) { if errors.Is(err, pgx.ErrNoRows) {
return User{}, ErrNotFound return User{}, ErrNotFound
} }
@@ -82,10 +82,10 @@ func (s *PgStore) CreateUser(ctx context.Context, u User, createdBy *uuid.UUID)
} }
_, err = s.q.Exec(ctx, ` _, err = s.q.Exec(ctx, `
INSERT INTO users (id, username, display_name, password_hash, role, locale, ui_prefs, 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) 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)`, 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.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) { if isUniqueViolation(err) {
return ErrUsernameTaken return ErrUsernameTaken
} }
@@ -347,10 +347,11 @@ func (s *PgStore) UpdateUser(ctx context.Context, id uuid.UUID, upd UserUpdate)
role = COALESCE($4::user_role, role), role = COALESCE($4::user_role, role),
is_blocked = COALESCE($5, is_blocked), is_blocked = COALESCE($5, is_blocked),
reviews_uploads = COALESCE($7, reviews_uploads), reviews_uploads = COALESCE($7, reviews_uploads),
manages_updates = COALESCE($9, manages_updates),
token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END, token_version = token_version + CASE WHEN $6 THEN 1 ELSE 0 END,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username)) RETURNING `+userColumns, id, setName, name, role, upd.IsBlocked, upd.RevokeTokens, upd.ReviewsUploads, upd.Username, upd.ManagesUpdates))
if isUniqueViolation(err) { if isUniqueViolation(err) {
return User{}, ErrUsernameTaken return User{}, ErrUsernameTaken
} }
+8
View File
@@ -16,6 +16,7 @@ const (
PermSuggestTracks // предлагать треки, видеть свои предложения PermSuggestTracks // предлагать треки, видеть свои предложения
PermReviewUploads // очередь, одобрение, правка метаданных; даёт флаг, не роль PermReviewUploads // очередь, одобрение, правка метаданных; даёт флаг, не роль
PermSystemStatus // статус рекомендательной системы, аудит PermSystemStatus // статус рекомендательной системы, аудит
PermManageUpdates // обновление сервера; admin или выданное право
) )
var permissions = map[Permission][]Role{ var permissions = map[Permission][]Role{
@@ -25,6 +26,7 @@ var permissions = map[Permission][]Role{
PermViewLibraryStatus: {RoleModerator, RoleAdmin}, PermViewLibraryStatus: {RoleModerator, RoleAdmin},
PermSuggestTracks: {RoleModerator, RoleAdmin}, PermSuggestTracks: {RoleModerator, RoleAdmin},
PermSystemStatus: {RoleAdmin}, PermSystemStatus: {RoleAdmin},
PermManageUpdates: {RoleAdmin},
} }
// Can сообщает, есть ли у роли глобальное право. // Can сообщает, есть ли у роли глобальное право.
@@ -40,6 +42,12 @@ func Can(r Role, p Permission) bool {
// Allowed — право конкретного пользователя. Аудит загрузок не вытекает из роли admin. // Allowed — право конкретного пользователя. Аудит загрузок не вытекает из роли admin.
func (p Principal) Allowed(perm Permission) bool { 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 { if perm == PermReviewUploads {
return p.ReviewsUploads return p.ReviewsUploads
} }
@@ -21,6 +21,7 @@ func TestCan(t *testing.T) {
{PermViewLibraryStatus, "library status", [3]bool{false, true, true}}, {PermViewLibraryStatus, "library status", [3]bool{false, true, true}},
{PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}}, {PermSuggestTracks, "suggest tracks", [3]bool{false, true, true}},
{PermSystemStatus, "system status", [3]bool{false, false, true}}, {PermSystemStatus, "system status", [3]bool{false, false, true}},
{PermManageUpdates, "manage updates", [3]bool{false, false, true}},
} }
roles := [3]Role{RoleUser, RoleModerator, RoleAdmin} roles := [3]Role{RoleUser, RoleModerator, RoleAdmin}
for _, row := range matrix { 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, UserID: u.ID,
Role: u.Role, Role: u.Role,
ReviewsUploads: u.ReviewsUploads, ReviewsUploads: u.ReviewsUploads,
ManagesUpdates: u.ManagesUpdates || u.Role == RoleAdmin,
SessionID: claims.SessionID, SessionID: claims.SessionID,
MustChangePassword: u.MustChangePassword, MustChangePassword: u.MustChangePassword,
MustChangeCredentials: u.MustChangeCredentials, MustChangeCredentials: u.MustChangeCredentials,
+1
View File
@@ -35,6 +35,7 @@ type UserUpdate struct {
DisplayName **string DisplayName **string
Role *Role Role *Role
IsBlocked *bool IsBlocked *bool
ManagesUpdates *bool
ReviewsUploads *bool ReviewsUploads *bool
RevokeTokens 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")
}
Loaded 100 of 119 files, more files were not shown because too many files have changed in this diff. Show more