Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a29a8b18ad | ||
|
|
e33abc948c | ||
|
|
ab49e22357 | ||
|
|
75ae2092ad | ||
|
|
0dc0384c78 | ||
|
|
17b6dd2041 | ||
|
|
0a59059738 | ||
|
|
bfcf6e48b2 |
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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`.
|
||||||
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
|
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}),[
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
@@ -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));
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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('/');
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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; } }
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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={() => {
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
});
|
||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 = [];
|
||||||
|
|||||||
@@ -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(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 }),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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'
|
||||||
@@ -118,4 +118,6 @@ if [ -n "$LIBRARY_HOST" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
bash ./install-management.sh
|
||||||
|
|
||||||
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
|
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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) | Справочник по развёртыванию: сервисы, каталоги, переменные |
|
||||||
|
|
||||||
## Стек в одном абзаце
|
## Стек в одном абзаце
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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 использует отдельные адаптивный цветной
|
||||||
|
|||||||
@@ -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` этим потоком не поддерживаются.
|
||||||
@@ -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
@@ -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
@@ -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 и названием плейлиста.
|
||||||
|
|
||||||
## Проверки
|
## Проверки
|
||||||
|
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -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';
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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).",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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 продолжат иметь приоритет."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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).",
|
||||||
|
|||||||
@@ -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": "Управлять треками",
|
||||||
|
|||||||
@@ -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": "Операция управления сервером не поддерживается."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user