Compare commits

..
27 Commits
Author SHA1 Message Date
mrleo1nid 66d3897f66 feat(admin): group navigation into media, uploads and system
ci / ts / ts (packages + web) (push) Failing after 4m33s
release / android (APK в релиз) (push) Successful in 4m45s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 8s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m14s
release / prune (старые файлы и прогоны) (push) Successful in 12s
2026-10-28 23:47:17 +03:00
mrleo1nid 17bb0e1846 fix(uploads): allow WebAssembly hashing under CSP
ci / ts / ts (packages + web) (push) Failing after 4m29s
ci / desktop / rust (desktop proxy) (push) Successful in 5m54s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 3m23s
2026-10-28 23:34:42 +03:00
mrleo1nidandClaude Sonnet 5.5 17c85493b7 fix(library): always split comma-separated artists even if glued name exists
ci / api / api (push) Successful in 34s
ci / db / db (push) Successful in 42s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 15:26:49 +03:00
mrleo1nidandClaude Sonnet 5.5 75f9c57eca fix(library): split compound album artist and re-split artists on rescan
ci / api / api (push) Successful in 37s
ci / db / db (push) Successful in 55s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:29:50 +03:00
mrleo1nidandClaude Sonnet 5.5 8a007be036 feat(web): show spinner on play button while track is loading
ci / ts / ts (packages + web) (push) Successful in 3m15s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 14:23:19 +03:00
mrleo1nidandClaude Sonnet 5.5 f77148df2c fix(web): show notification count as menu badge and mark read on open
ci / ts / ts (packages + web) (push) Successful in 2m52s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:56:05 +03:00
mrleo1nidandClaude Sonnet 5.5 139dd226fa feat(genius): fetch artist avatars by name and match names with transliteration suffix
ci / api / api (push) Successful in 27s
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:50:55 +03:00
mrleo1nidandClaude Sonnet 5.5 795bb7de46 fix(library): split comma-separated main artists
ci / module boundaries / boundaries (push) Successful in 8s
ci / api / api (push) Successful in 1m17s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m21s
ci / ts / ts (packages + web) (push) Successful in 3m43s
ci / db / db (push) Canceled after 11m9s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-28 13:39:32 +03:00
mrleo1nid 11b890c2e7 docs: record architecture and correctness review 2026-10-24 02:16:05 +03:00
mrleo1nid 59983ed1b3 fix(social): complete shared listening request lifecycle 2026-10-24 02:16:00 +03:00
mrleo1nid cb22f15912 fix(web): polish social UI and prevent doubled shared audio
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m20s
release / desktop (Linux и Windows в релиз) (push) Successful in 19m22s
ci / ts / ts (packages + web) (push) Successful in 3m9s
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 4m7s
release / prune (старые файлы и прогоны) (push) Successful in 11s
2026-10-23 19:54:10 +03:00
mrleo1nid 6450027b88 feat(web): add desktop mini chat and move social controls to profile
ci / ts / ts (packages + web) (push) Successful in 4m55s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m37s
2026-10-23 18:37:32 +03:00
mrleo1nid f087ec9f88 fix(ci): serialize integration suites sharing a database
ci / db / db (push) Successful in 1m6s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m59s
release / desktop (Linux и Windows в релиз) (push) Successful in 12m17s
release / prune (старые файлы и прогоны) (push) Successful in 6s
2026-10-23 12:55:53 +03:00
mrleo1nid cd84ceecb2 feat(social): show friend presence and require online listening
ci / api / api (push) Successful in 1m11s
ci / db / db (push) Failing after 23s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m29s
ci / ts / ts (packages + web) (push) Successful in 3m15s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m27s
2026-10-23 12:52:00 +03:00
mrleo1nid 582c0dc3d4 fix(ci): align onboarding assertion with generated enum
ci / db / db (push) Successful in 57s
ci / api / api (push) Successful in 58s
2026-10-23 12:03:23 +03:00
mrleo1nid 1a693848f2 Redesign social conversations and shared music
ci / api / api (push) Successful in 19m12s
ci / ts / ts (packages + web) (push) Successful in 2m44s
ci / module boundaries / boundaries (push) Successful in 18m23s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 59s
ci / db / db (push) Failing after 18s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
2026-10-23 11:27:47 +03:00
mrleo1nid 11a16cd023 feat(social): add listening activity, shared rooms and modern chat
ci / api / api (push) Canceled after 0s
ci / module boundaries / boundaries (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 11m29s
ci / db / db (push) Canceled after 11m22s
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 6m43s
ci / recommender / recommender (push) Canceled after 6m47s
ci / ts / ts (packages + web) (push) Canceled after 1s
2026-10-23 10:45:40 +03:00
mrleo1nid 0a05bfe2cd feat(social): add friends, private chats, inbox and music sharing
ci / module boundaries / boundaries (push) Successful in 7s
ci / api / api (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m8s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / db / db (push) Successful in 2m29s
ci / ts / ts (packages + web) (push) Successful in 2m55s
2026-10-23 03:19:29 +03:00
mrleo1nid f1528de948 feat(admin): transfer user accounts and show local cover coverage
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m17s
ci / ts / ts (packages + web) (push) Successful in 3m13s
ci / api / api (push) Successful in 59s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m31s
ci / db / db (push) Successful in 2m50s
2026-10-23 02:17:06 +03:00
mrleo1nid 0a4cd4bff1 fix(library): parallelize cover processing and retry missing artwork
ci / api / api (push) Successful in 50s
ci / db / db (push) Successful in 1m1s
2026-10-23 02:02:36 +03:00
mrleo1nid eed7038109 fix(web): hide browser URL preview for player artists
ci / ts / ts (packages + web) (push) Successful in 4m37s
2026-10-23 00:49:14 +03:00
mrleo1nid 7fa7ee2c7d fix(web): follow server job log output automatically
ci / ts / ts (packages + web) (push) Successful in 2m14s
2026-10-23 00:35:39 +03:00
mrleo1nid 24707a0bfc fix(admin): mask all stored configuration secrets and credential URLs
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 2m15s
ci / ts / ts (packages + web) (push) Successful in 2m19s
2026-10-23 00:27:20 +03:00
mrleo1nid a6c9fd8a00 fix(ui): reflect update availability and config changes; expand mobile player metadata
ci / api / api (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m0s
ci / db / db (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m39s
ci / ts / ts (packages + web) (push) Successful in 2m40s
2026-10-23 00:20:59 +03:00
mrleo1nid bc55528afd fix(deploy): provision management agent during server updates
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m21s
2026-10-22 23:43:11 +03:00
mrleo1nid a29a8b18ad feat(admin): manage server updates and local configuration
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
2026-10-22 23:01:36 +03:00
mrleo1nid e33abc948c docs(agents): limit local checks to affected areas 2026-10-22 22:46:44 +03:00
166 changed files with 15240 additions and 572 deletions

No files matched your search

+3 -1
View File
@@ -71,7 +71,9 @@ jobs:
done
- name: Go — миграции и запросы store
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/
# These suites share one database; dbtest also rolls all migrations down
# and cleans orphan catalog rows. Do not run the packages concurrently.
run: go test -p 1 -tags integration -count=1 -v ./internal/dbtest/ ./internal/social/
- name: Go — сквозной сценарий пользователя (newApp на Postgres, Redis, Meilisearch)
working-directory: services/api
run: go test -tags integration -count=1 -v -run TestUserJourney ./cmd/api/
+2
View File
@@ -47,6 +47,8 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Обновление сервера, агент и конфигурация
run: python3 -m unittest discover -s deploy -p 'test_*.py' -v
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune`.
+2
View File
@@ -20,6 +20,8 @@ __pycache__/
.ruff_cache/
# env / secrets
deploy/config.local
deploy/.management.lock
.env
*.pem
*.keystore
+2 -2
View File
@@ -5,6 +5,6 @@
- Контракт HTTP — `api/openapi.yaml`. Читай нужную операцию и связанные схемы. Generated-код (`**/gen/**`, `**/generated/**`) и lock-файлы исследуй точечно; не редактируй вручную.
- После изменения OpenAPI: `pnpm --filter @pipu/api-client generate`, затем из `services/api`: `go generate ./...`. Проверяй и коммить результат генерации.
- Новые правила размещай у владельца сценария, IO — в адаптере. Сохраняй явные границы транзакций и платформенные особенности. Узкий интерфейс вводи для конкретного потребителя.
- Промежуточно запускай проверки затронутой области; перед сдачей — необходимые связанные проверки. Команды запускаются из корня, если не сказано иначе.
- Общие проверки: `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
- Локально достаточно запускать проверки затронутой области и непосредственно связанных сценариев. Полный набор тестов и проверок повторно выполняется в CI/CD; не запускай все проверки проекта перед каждой сдачей без необходимости. Расширяй локальную проверку при изменении общих контрактов, межмодульных зависимостей или при обнаружении ошибок. Команды запускаются из корня, если не сказано иначе.
- Справочник полных проверок (выбирай локально только относящиеся к изменению): `pnpm typecheck`, `pnpm test`, `pnpm i18n:check`, `pnpm test:scripts`. Go: из `services/api` — `go vet ./...`, `go test ./...`. Python: из `services/recommender` — `uv run ruff check .`, `uv run mypy pipu_rec`, `uv run pytest`.
- Не меняй пользовательские артефакты, локальные бинарники и посторонние незакоммиченные файлы.
+774
View File
@@ -22,6 +22,377 @@ tags:
security:
- bearerAuth: []
paths:
/api/v1/me/social/recommendations:
put:
operationId: setFriendsRecommendations
tags: [me]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [enabled]
properties:
enabled: {type: boolean}
responses:
'204': {description: Saved}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/listening:
get:
operationId: getListeningState
tags: [me]
responses:
'200':
description: Listening preferences, current room and pending requests
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningState'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/listening/preferences:
put:
operationId: setListeningPreferences
tags: [me]
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningPreferences'}
responses:
'204': {description: Saved}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/listening/actions:
post:
operationId: listeningAction
tags: [me]
description: Join requests target the host; invites target a guest and use the sender's own stream. Accept/decline/cancel target a request UUID. Leave/end target the room UUID. Automatic acceptance always respects the receiving user's admission policy. No control transfer.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningAction'}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/listening/state:
put:
operationId: publishListeningState
tags: [me]
description: Only the room owner's source device may publish. Monotonic sequence prevents stale updates; room expires after 30 seconds without host heartbeat.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningRoomInput'}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/activity:
put:
operationId: publishListeningActivity
tags: [me]
description: Ephemeral playback lease, expires after 90 seconds. Ignored when visibility is nobody. Sequence is monotonic per device; paused updates clear only that device. No offline replay.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ListeningActivityInput'}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/activity/visibility:
put:
operationId: setActivityVisibility
tags: [me]
description: Applies to all devices. Nobody clears all current activity immediately.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [visibility]
properties:
visibility: {$ref: '#/components/schemas/ActivityVisibility'}
responses:
'204': {description: Saved}
default: {$ref: '#/components/responses/Problem'}
/api/v1/social/activity/{userId}/playlist:
get:
operationId: getActivityPlaylist
tags: [me]
description: Read-only current playlist subject to live visibility, friendship, blocking and expiry. Does not create or reveal a permanent share token.
parameters:
- in: path
name: userId
required: true
schema: {type: string, format: uuid}
responses:
'200':
description: Read-only playlist
content:
application/json:
schema: {$ref: '#/components/schemas/Playlist'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social:
get:
operationId: getSocial
tags: [me]
description: Friends, incoming/outgoing requests, own blocks and up to 100 notifications, unread first. No private account fields.
responses:
'200':
description: Social inbox
content:
application/json:
schema: {$ref: '#/components/schemas/SocialInbox'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/people:
get:
operationId: searchPeople
tags: [me]
parameters:
- in: query
name: q
required: true
schema: {type: string, minLength: 2, maxLength: 80}
responses:
'200':
description: Up to 30 people, excluding blocked accounts and either direction of personal blocking
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/SocialPerson'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/actions:
post:
operationId: socialAction
tags: [me]
description: Request is idempotent; accept is recipient-only. Remove cancels, declines or unfriends. Block removes friendship. Unblock never restores it. Read chat acknowledges only messages through up_to_seq. Read notification uses notification ID as target_id.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SocialAction'}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/chats/{peerId}:
parameters:
- in: path
name: peerId
required: true
schema: {type: string, format: uuid}
get:
operationId: getDirectMessages
tags: [me]
description: Last 50 messages in ascending sequence order. Before loads older history. Requires current friendship, never marks messages read.
parameters:
- in: query
name: before
schema: {type: integer, format: int64, minimum: 1}
- in: query
name: q
schema: {type: string, maxLength: 100}
- in: query
name: music
description: Only non-deleted track and playlist attachments, with the same history pagination.
schema: {type: boolean, default: false}
responses:
'200':
description: Message page
content:
application/json:
schema:
type: array
items: {$ref: '#/components/schemas/DirectMessage'}
default: {$ref: '#/components/responses/Problem'}
post:
operationId: sendDirectMessage
tags: [me]
description: Friends only. Client-generated UUID makes retries idempotent; changed payload for the same ID returns 409. Playlist attachment is its active share UUID; track attachment is a catalog UUID. No arbitrary URLs.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/SendDirectMessage'}
responses:
'200':
description: Stored message
content:
application/json:
schema: {$ref: '#/components/schemas/DirectMessage'}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/chats/{peerId}/meta:
parameters:
- in: path
name: peerId
required: true
schema: {type: string, format: uuid}
get:
operationId: getChatMeta
tags: [me]
responses:
'200':
description: Ephemeral typing indicator, friends only
content:
application/json:
schema:
type: object
required: [typing]
properties:
typing: {type: boolean}
default: {$ref: '#/components/responses/Problem'}
put:
operationId: setChatTyping
tags: [me]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [typing]
properties:
typing: {type: boolean}
responses:
'204': {description: Applied, lease expires after 6 seconds}
default: {$ref: '#/components/responses/Problem'}
/api/v1/me/social/chats/{peerId}/messages/{messageId}:
patch:
operationId: updateChatMessage
tags: [me]
description: Friends only. Edit/delete are sender-only. Delete removes content for both participants; react toggles one allowed emoji. Reply targets must belong to the same conversation.
parameters:
- in: path
name: peerId
required: true
schema: {type: string, format: uuid}
- in: path
name: messageId
required: true
schema: {type: string, format: uuid}
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [action]
properties:
action: {type: string, enum: [edit, delete, react]}
text: {type: string, maxLength: 4000}
emoji: {type: string, enum: ['❤️', '👍', '🔥', '😂', '🎵', '👏']}
responses:
'204': {description: Applied}
default: {$ref: '#/components/responses/Problem'}
/api/v1/admin/server/updates:
get:
operationId: getServerUpdates
tags: [admin]
parameters:
- in: query
name: source_id
schema: {type: string}
- in: query
name: channel
schema: {type: string, enum: [latest, version, main], default: latest}
- in: query
name: version
schema: {type: string}
- in: query
name: fresh
schema: {type: boolean, default: false}
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerUpdates"
default:
$ref: "#/components/responses/Problem"
post:
operationId: startServerUpdate
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerUpdateRequest"
responses:
"202":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerJob"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/update-sources:
put:
operationId: saveServerUpdateSources
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerSources"
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerSources"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/config:
get:
operationId: getServerConfig
tags: [admin]
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfig"
default:
$ref: "#/components/responses/Problem"
patch:
operationId: saveServerConfig
tags: [admin]
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfigPatch"
responses:
"200":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerConfig"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/server/config/apply:
post:
operationId: applyServerConfig
tags: [admin]
responses:
"202":
description: Server management result
content:
application/json:
schema:
$ref: "#/components/schemas/ServerJob"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/genius:
get:
operationId: getGeniusStats
@@ -353,6 +724,61 @@ paths:
$ref: "#/components/schemas/CreatedUser"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/sources/{id}/cover-stats:
get:
operationId: getSourceCoverStats
tags: [admin]
summary: Покрытие аудиофайлов локальными обложками (admin)
parameters:
- $ref: "#/components/parameters/SourceId"
responses:
"200":
description: Доступные аудиофайлы без исключённых папок; внешние обложки не учитываются
content:
application/json:
schema:
$ref: "#/components/schemas/SourceCoverStats"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/users/export:
get:
operationId: exportUsers
tags: [admin]
summary: Экспорт учётных записей с ролями и хэшами паролей (admin)
description: Только учётные записи и права доступа, без рекомендаций, истории, лайков и сессий. Ответ не кэшируется.
responses:
"200":
description: Файл переноса пользователей
content:
application/json:
schema:
$ref: "#/components/schemas/UserArchive"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/users/import:
post:
operationId: importUsers
tags: [admin]
summary: Импорт учётных записей с сохранением паролей (admin)
description: |
Атомарное добавление пользователей из экспорта, до 10000 записей и 16 MiB.
Существующие логины пропускаются без изменений. Новые записи получают новые ID.
Рекомендации, история, лайки и сессии не импортируются.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UserArchive"
responses:
"200":
description: Результат импорта
content:
application/json:
schema:
$ref: "#/components/schemas/UserImportResult"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/users/{id}:
parameters:
- $ref: "#/components/parameters/UserId"
@@ -1832,6 +2258,278 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
ListeningPreferences:
type: object
required: [requests, auto_accept, autoplay]
properties:
requests: {$ref: '#/components/schemas/ActivityVisibility'}
auto_accept: {$ref: '#/components/schemas/ActivityVisibility'}
autoplay: {type: boolean, default: true}
ListeningAction:
type: object
required: [id, action, target_id]
properties:
id: {type: string, format: uuid}
action: {type: string, enum: [join, invite, accept, decline, cancel, leave, end]}
target_id: {type: string, format: uuid}
device_id: {type: string, format: uuid}
track_id: {type: string, format: uuid}
position_ms: {type: integer, minimum: 0}
playing: {type: boolean}
ListeningRoomInput:
type: object
required: [room_id, device_id, sequence, playing, position_ms]
properties:
room_id: {type: string, format: uuid}
device_id: {type: string, format: uuid}
sequence: {type: integer, format: int64, minimum: 1}
track_id: {type: string, format: uuid}
playing: {type: boolean}
position_ms: {type: integer, minimum: 0}
ListeningRoom:
type: object
required: [id, owner_id, owner_name, device_id, track_id, position_ms, playing, updated_at, participants]
properties:
id: {type: string, format: uuid}
owner_id: {type: string, format: uuid}
owner_name: {type: string}
device_id: {type: string, format: uuid}
track_id: {type: string, format: uuid, nullable: true}
position_ms: {type: integer}
playing: {type: boolean}
updated_at: {type: string, format: date-time}
participants:
type: array
items:
type: object
required: [id, name]
properties:
id: {type: string, format: uuid}
name: {type: string}
ListeningRequest:
type: object
required: [id, sender_id, sender_name, recipient_id, owner_id, kind, expires_at]
properties:
id: {type: string, format: uuid}
sender_id: {type: string, format: uuid}
sender_name: {type: string}
recipient_id: {type: string, format: uuid}
owner_id: {type: string, format: uuid}
kind: {type: string, enum: [join, invite]}
expires_at: {type: string, format: date-time}
ListeningState:
type: object
required: [preferences, room, requests, server_time]
properties:
preferences: {$ref: '#/components/schemas/ListeningPreferences'}
room:
nullable: true
allOf:
- {$ref: '#/components/schemas/ListeningRoom'}
requests:
type: array
items: {$ref: '#/components/schemas/ListeningRequest'}
server_time: {type: string, format: date-time}
ActivityVisibility:
type: string
enum: [nobody, friends, everyone]
default: nobody
ListeningActivityInput:
type: object
required: [device_id, sequence, playing, context]
properties:
device_id: {type: string, format: uuid}
sequence: {type: integer, format: int64, minimum: 1}
playing: {type: boolean}
position_ms: {type: integer, minimum: 0, default: 0}
track_id: {type: string, format: uuid}
context: {type: string, enum: [other, wave, playlist]}
playlist_id: {type: string, format: uuid}
ListeningActivity:
type: object
required: [track_id, track_title, artist, context]
properties:
track_id: {type: string, format: uuid}
track_title: {type: string}
artist: {type: string}
context: {type: string, enum: [other, wave, playlist]}
playlist_id: {type: string, format: uuid}
playlist_name: {type: string}
SocialPerson:
type: object
required: [id, username, display_name, relationship, unread, online]
properties:
id: {type: string, format: uuid}
username: {type: string}
display_name: {type: string}
relationship: {type: string, enum: [none, incoming, outgoing, friend, blocked]}
online:
type: boolean
description: A current friend has an active client heartbeat within 45 seconds. Independent of music visibility; false for non-friends and blocked people.
unread: {type: integer}
activity: {$ref: '#/components/schemas/ListeningActivity'}
last_message: {$ref: '#/components/schemas/SocialMessagePreview'}
SocialMessagePreview:
type: object
description: Latest message in a current friendship, without marking it read. Deleted content is never returned.
required: [id, sender_id, text, attachment_kind, created_at, deleted]
properties:
id: {type: string, format: uuid}
sender_id: {type: string, format: uuid}
text: {type: string}
attachment_kind: {type: string, enum: [none, track, playlist]}
created_at: {type: string, format: date-time}
deleted: {type: boolean}
SocialNotification:
type: object
required: [id, actor_id, actor_name, kind, created_at, read]
properties:
id: {type: string, format: uuid}
actor_id: {type: string, format: uuid}
actor_name: {type: string}
kind: {type: string, enum: [friend_request, friend_accepted, message, music, listening_request, listening_invite, listening_accepted]}
created_at: {type: string, format: date-time}
read: {type: boolean}
SocialInbox:
type: object
required: [people, notifications, unread, activity_visibility, friends_recommendations]
properties:
friends_recommendations: {type: boolean, default: true}
activity_visibility: {$ref: '#/components/schemas/ActivityVisibility'}
people:
type: array
items: {$ref: '#/components/schemas/SocialPerson'}
notifications:
type: array
items: {$ref: '#/components/schemas/SocialNotification'}
unread: {type: integer}
SocialAction:
type: object
required: [action, target_id]
properties:
action: {type: string, enum: [request, accept, remove, block, unblock, read_chat, read_notification]}
target_id: {type: string, format: uuid}
up_to_seq: {type: integer, format: int64, minimum: 1}
SendDirectMessage:
type: object
required: [id, text, attachment_kind]
properties:
id: {type: string, format: uuid}
text: {type: string, maxLength: 4000}
attachment_kind: {type: string, enum: [none, track, playlist]}
attachment_id: {type: string, format: uuid}
reply_to: {type: string, format: uuid}
DirectMessage:
type: object
required: [id, seq, sender_id, text, attachment_kind, created_at, read]
properties:
id: {type: string, format: uuid}
seq: {type: integer, format: int64}
sender_id: {type: string, format: uuid}
text: {type: string}
attachment_kind: {type: string, enum: [none, track, playlist]}
attachment_id: {type: string, format: uuid}
created_at: {type: string, format: date-time}
read: {type: boolean}
reply_to: {type: string, format: uuid}
reply_preview: {type: string}
edited_at: {type: string, format: date-time}
deleted: {type: boolean}
reactions:
type: array
items:
$ref: '#/components/schemas/MessageReaction'
MessageReaction:
type: object
required: [emoji, count, mine]
properties:
emoji: {type: string}
count: {type: integer}
mine: {type: boolean}
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, current_commit, target_version, target_commit, update_state]
properties:
sources:
type: array
items: {$ref: "#/components/schemas/ServerSource"}
versions:
type: array
items: {type: string}
current_version: {type: string}
current_commit: {type: string}
target_version: {type: string}
target_commit: {type: string}
update_state: {type: string, enum: [available, current, ahead, unknown, unavailable, select_version]}
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}
expected_commit: {type: string, pattern: '^[a-f0-9]{40,64}$'}
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]
@@ -2495,6 +3193,9 @@ components:
nullable: true
role:
$ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads:
type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
@@ -2536,6 +3237,70 @@ components:
type: string
new_password:
type: string
SourceCoverStats:
type: object
required: [files_total, files_with_cover, albums_pending]
properties:
files_total:
type: integer
format: int64
files_with_cover:
type: integer
format: int64
description: Файлы, альбом которых имеет готовую локальную обложку (встроенную или из папки)
albums_pending:
type: integer
format: int64
UserArchive:
type: object
additionalProperties: false
required: [version, users]
properties:
version:
type: integer
enum: [1]
users:
type: array
maxItems: 10000
items:
$ref: "#/components/schemas/UserArchiveEntry"
UserArchiveEntry:
type: object
additionalProperties: false
required: [username, display_name, role, password_hash, is_blocked, must_change_password, must_change_credentials, reviews_uploads, manages_updates]
properties:
username:
type: string
display_name:
type: string
nullable: true
maxLength: 64
role:
$ref: "#/components/schemas/Role"
password_hash:
type: string
maxLength: 512
description: Argon2id PHC. Секретные данные; файл нужно хранить в защищённом месте.
is_blocked:
type: boolean
must_change_password:
type: boolean
must_change_credentials:
type: boolean
reviews_uploads:
type: boolean
manages_updates:
type: boolean
UserImportResult:
type: object
required: [created, skipped]
properties:
created:
type: integer
skipped:
type: array
items:
type: string
AdminUser:
type: object
required: [id, username, display_name, role, reviews_uploads, locale, is_blocked, must_change_password, must_change_credentials, created_at, last_login_at]
@@ -2550,6 +3315,9 @@ components:
nullable: true
role:
$ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads:
type: boolean
description: Может рассматривать очередь на добавление. Не следует из роли и может быть у нескольких людей сразу
@@ -2590,6 +3358,9 @@ components:
maxLength: 64
role:
$ref: "#/components/schemas/Role"
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads:
type: boolean
description: Сразу выдать аудит загрузок. Может задать только админ
@@ -2616,6 +3387,9 @@ components:
$ref: "#/components/schemas/Role"
is_blocked:
type: boolean
manages_updates:
type: boolean
description: Управление обновлениями сервера. У admin всегда; другим выдаёт только admin
reviews_uploads:
type: boolean
description: Выдать или забрать аудит загрузок. Может менять только админ, в том числе у себя
+1 -1
View File
@@ -13,7 +13,7 @@
"withGlobalTauri": true,
"windows": [{ "label": "main", "title": "Pipu Music", "width": 1200, "height": 800, "minWidth": 720, "minHeight": 540 }],
"security": {
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
"csp": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http://127.0.0.1:*; font-src 'self' data:; media-src 'self' blob: http://127.0.0.1:*; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-src 'none'"
}
},
"bundle": {
+17 -5
View File
@@ -8,32 +8,38 @@ import { player } from '../../player/controller';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { Cover, Icon, type IconName } from '../../ui/kit';
import { NativeChatProvider, useSocialUnread } from '../../features/social/Social';
import { ListeningBanner } from '../../features/social/Listening';
import { UpdateDialog } from '../../ui/UpdateDialog';
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', profile: 'person' };
const ICONS: Record<string, IconName> = { index: 'home', search: 'search', collection: 'library-music', friends: 'people-outline', profile: 'person' };
export default function TabsLayout() {
const colors = useColors();
const { t } = useTranslation();
const unread = useSocialUnread();
const labels: Record<string, string> = {
index: t('nav.home'),
search: t('mobile.search'),
collection: t('nav.collection'),
profile: t('mobile.profile'),
friends: t('social.people'),
};
const net = useNetInfo();
return (
<>
<NativeChatProvider>
{net.isConnected === false && <OfflineBanner text={t('mobile.offline')} />}
<Tabs
screenOptions={{ headerShown: false, sceneStyle: { backgroundColor: colors.bg } }}
tabBar={({ state, navigation }) => (
<View>
<ListeningBanner />
<MiniPlayer />
<TabBar
routes={state.routes.map((r) => r.name)}
active={state.index}
labels={labels}
badges={{ friends: unread }}
onPress={(name) => navigation.navigate(name)}
/>
</View>
@@ -42,10 +48,11 @@ export default function TabsLayout() {
<Tabs.Screen name="index" />
<Tabs.Screen name="search" />
<Tabs.Screen name="collection" />
<Tabs.Screen name="friends" />
<Tabs.Screen name="profile" />
</Tabs>
<UpdateDialog />
</>
</NativeChatProvider>
);
}
@@ -53,11 +60,13 @@ function TabBar({
routes,
active,
labels,
badges,
onPress,
}: {
routes: string[];
active: number;
labels: Record<string, string>;
badges: Record<string, number>;
onPress: (name: string) => void;
}) {
const colors = useColors();
@@ -81,10 +90,13 @@ function TabBar({
onPress={() => onPress(name)}
accessibilityRole="tab"
accessibilityState={{ selected: on }}
accessibilityLabel={labels[name]}
accessibilityLabel={`${labels[name]}${badges[name] ? ` · ${badges[name]}` : ''}`}
style={{ flex: 1, alignItems: 'center', paddingVertical: 8, gap: 2 }}
>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
<View>
<Icon name={ICONS[name] ?? 'circle'} color={on ? colors.accent2 : colors.text3} />
{!!badges[name] && <Text style={{ position: 'absolute', top: -5, left: 14, minWidth: 16, paddingHorizontal: 4, borderRadius: 9, backgroundColor: colors['accent-fill'], color: '#fff', fontSize: 10, textAlign: 'center' }}>{badges[name]! > 99 ? '99+' : badges[name]}</Text>}
</View>
<Text style={{ fontSize: 11, color: on ? colors.text : colors.text3, fontWeight: on ? '600' : '400' }}>
{labels[name]}
</Text>
+2
View File
@@ -0,0 +1,2 @@
import { SocialScreen } from '../../features/social/Social';
export default function FriendsTab() { return <SocialScreen root />; }
+3
View File
@@ -110,6 +110,9 @@ function Screens() {
<Stack.Screen name="album/[id]" />
<Stack.Screen name="artist/[id]" />
<Stack.Screen name="playlists" />
<Stack.Screen name="social" />
<Stack.Screen name="social-track" />
<Stack.Screen name="social-playlist" />
<Stack.Screen name="player" options={{ presentation: 'modal', animation: 'slide_from_bottom' }} />
</Stack.Protected>
</Stack>
+11 -16
View File
@@ -1,7 +1,9 @@
import { useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, Pressable, Share, Text, View, type LayoutChangeEvent } from 'react-native';
import { Pressable, Text, View, type LayoutChangeEvent } from 'react-native';
import { MusicShareSheet } from '../features/social/Social';
import { ListeningBanner } from '../features/social/Listening';
import { trackShareUrl } from '@pipu/core';
import { useSessionCtx, useUser } from '../lib/session';
@@ -21,6 +23,7 @@ export default function PlayerScreen() {
const { t } = useTranslation();
const router = useRouter();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const prefs = useUser().ui_prefs;
const { server } = useSessionCtx();
const [sharing, setSharing] = useState(false);
@@ -39,20 +42,12 @@ export default function PlayerScreen() {
const duration = st.durationMs || track.duration_ms;
const progress = duration ? Math.min(st.positionMs / duration, 1) : 0;
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
if (!server || sharing) return;
setSharing(true);
try {
await Share.share({ title: track.title, message: trackShareUrl(server, track.id) }, { dialogTitle: t('player.share') });
} catch {
Alert.alert(t('player.share'), t('player.share_error'));
} finally {
setSharing(false);
}
};
const share = () => setSharing(true);
return (
<Screen>
{sharing && server && <MusicShareSheet attachment={{ kind: 'track', id: track.id, title: track.title }} url={trackShareUrl(server, track.id)} close={() => setSharing(false)} />}
<ListeningBanner />
<View style={{ flex: 1, padding: 24, gap: 24, justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Pressable onPress={() => router.back()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('mobile.close_player')}>
@@ -102,7 +97,7 @@ export default function PlayerScreen() {
<View style={{ gap: 6 }}>
<Pressable
onLayout={(e: LayoutChangeEvent) => setBarWidth(e.nativeEvent.layout.width || 1)}
onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
disabled={locked} onPress={(e) => player.seek((e.nativeEvent.locationX / barWidth) * duration)}
accessibilityRole="adjustable"
accessibilityLabel={t('player.progress')}
accessibilityValue={{ min: 0, max: Math.round(duration / 1000), now: Math.round(st.positionMs / 1000) }}
@@ -124,18 +119,18 @@ export default function PlayerScreen() {
<Pressable onPress={() => react('dislike')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')}>
<Icon name="heart-broken" size={28} color={track.reaction === 'dislike' ? colors.danger : colors.text2} />
</Pressable>
<Pressable onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Pressable disabled={locked} onPress={() => void player.prev()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.prev')}>
<Icon name="skip-previous" size={40} color={colors.text} />
</Pressable>
<Pressable
onPress={() => player.toggle()}
disabled={locked} onPress={() => player.toggle()}
accessibilityRole="button"
accessibilityLabel={t(st.playing ? 'player.pause' : 'player.play')}
style={{ width: 72, height: 72, borderRadius: 36, backgroundColor: colors.text, alignItems: 'center', justifyContent: 'center' }}
>
<Icon name={st.playing ? 'pause' : 'play-arrow'} size={40} color={colors.bg} />
</Pressable>
<Pressable onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Pressable disabled={locked} onPress={() => player.next()} hitSlop={10} accessibilityRole="button" accessibilityLabel={t('player.next')}>
<Icon name="skip-next" size={40} color={colors.text} />
</Pressable>
<Pressable onPress={() => react('like')} hitSlop={10} accessibilityRole="button" accessibilityLabel={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')}>
+5 -3
View File
@@ -1,10 +1,11 @@
import type { components } from '@pipu/api-client';
import { MusicShareSheet } from '../features/social/Social';
import { playlistShareUrl, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, FlatList, Modal, ScrollView, Share, View } from 'react-native';
import { Alert, FlatList, Modal, ScrollView, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
import { player } from '../player/controller';
import { Button, ErrorBox, Field, H1, Muted, Screen, TrackRow, formatDuration, useErrorText } from '../ui/kit';
@@ -77,6 +78,7 @@ function Editor({ playlist, track, onClose, onSaved }: { playlist?: Playlist; tr
}
function Detail({ id }: { id: string }) {
const [shareToken, setShareToken] = useState<string | null>(null);
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
@@ -94,7 +96,7 @@ function Detail({ id }: { id: string }) {
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') });
setShareToken(token);
} });
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); } });
@@ -112,7 +114,7 @@ function Detail({ id }: { id: string }) {
const move = (index: number, delta: number) => {
const ids = p!.tracks.map(t => t.id); [ids[index],ids[index+delta]]=[ids[index+delta]!,ids[index]!]; save.mutate(ids);
};
return <Screen><FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
return <Screen>{shareToken && server && p && <MusicShareSheet attachment={{ kind: 'playlist', id: shareToken, title: p.name }} url={playlistShareUrl(server, shareToken)} close={() => setShareToken(null)} />}<FlatList data={p?.tracks ?? []} keyExtractor={t => t.id} onRefresh={() => void query.refetch()} refreshing={query.isRefetching}
ListHeaderComponent={<View style={{ padding:16,gap:12 }}>
<Button title={t('playlists.title')} variant="ghost" onPress={() => router.back()} />
<ErrorBox>{query.error || save.error || deletion.error || playError || sharing.error || revoke.error ? errorText(query.error ?? save.error ?? deletion.error ?? playError ?? sharing.error ?? revoke.error) : null}</ErrorBox>
+2
View File
@@ -0,0 +1,2 @@
import { SharedMusic } from '../features/social/SharedMusic';
export default function SharedPlaylistScreen() { return <SharedMusic kind="playlist" />; }
+2
View File
@@ -0,0 +1,2 @@
import { SharedMusic } from '../features/social/SharedMusic';
export default function SharedTrackScreen() { return <SharedMusic kind="track" />; }
+1
View File
@@ -0,0 +1 @@
export { SocialScreen as default } from '../features/social/Social';
@@ -0,0 +1,61 @@
import { CHAT_REACTIONS, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'expo-router';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AccessibilityInfo, Animated, KeyboardAvoidingView, Modal, Pressable, ScrollView, Text, View } from 'react-native';
import { eventId } from '../../player/eventId';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
import { readDraft, saveDraft } from './drafts';
function Bubble({ children, mine, animate }: { children: ReactNode; mine: boolean; animate: boolean }) {
const colors = useColors(); const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => { if (!animate) return; opacity.setValue(0); const animation = Animated.timing(opacity,{toValue:1,duration:180,useNativeDriver:true});animation.start();return()=>animation.stop(); },[animate,opacity]);
return <Animated.View style={{ alignSelf: mine ? 'flex-end' : 'flex-start', maxWidth: '90%', padding: 14, borderRadius: 16, backgroundColor: mine ? colors['accent-soft'] : colors.surface2, gap: 8, opacity, transform:[{translateY:opacity.interpolate({inputRange:[0,1],outputRange:[6,0]})}] }}>{children}</Animated.View>;
}
export function ChatConversation({ person, api, queryKey, userId, server, active }: { person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; server: string; active: boolean }) {
const colors=useColors();const {t,i18n}=useTranslation();const router=useRouter();const cache=useQueryClient();const errorText=useErrorText();
const [text,setText]=useState('');const [ready,setReady]=useState(false);const touched=useRef(false);const [search,setSearch]=useState('');const [q,setQ]=useState('');
const [reply,setReply]=useState<DirectMessage|null>(null);const [editing,setEditing]=useState<DirectMessage|null>(null);const [menu,setMenu]=useState<DirectMessage|null>(null);const [emojis,setEmojis]=useState(false);const [reduced,setReduced]=useState(true);
const beforeEdit=useRef('');const pending=useRef<{id:string;text:string;reply_to?:string}|null>(null);
useEffect(()=>{let live=true;void readDraft(server,userId,person.id).then(value=>{if(live){if(!touched.current)setText(value);setReady(true);}});return()=>{live=false;};},[server,userId,person.id]);
useEffect(()=>{if(!ready||editing)return;const timer=setTimeout(()=>{void saveDraft(server,userId,person.id,text).catch(()=>undefined);},300);return()=>clearTimeout(timer);},[text,ready,editing,server,userId,person.id]);
useEffect(()=>{void AccessibilityInfo.isReduceMotionEnabled().then(setReduced);const sub=AccessibilityInfo.addEventListener('reduceMotionChanged',setReduced);return()=>sub.remove();},[]);
useEffect(()=>{const timer=setTimeout(()=>setQ(search.trim()),250);return()=>clearTimeout(timer);},[search]);
useEffect(()=>{const timer=setTimeout(()=>{void api.typing(person.id,active&&touched.current&&!!text.trim()&&!editing).catch(()=>undefined);},400);return()=>clearTimeout(timer);},[text,active,editing,api,person.id]);
useEffect(()=>()=>{void api.typing(person.id,false).catch(()=>undefined);},[api,person.id]);
const meta=useQuery({queryKey:[...queryKey,'typing',person.id],queryFn:()=>api.chatMeta(person.id),enabled:active,refetchInterval:active?2000:false});
const messages=useInfiniteQuery({queryKey:[...queryKey,'chat',person.id,q],initialPageParam:undefined as number|undefined,queryFn:({pageParam})=>api.messages(person.id,pageParam,q),getNextPageParam:last=>last.length===50?last[0]?.seq:undefined,enabled:active,refetchInterval:active?3000:false});
const list=[...(messages.data?.pages??[])].reverse().flat();const last=list.at(-1)?.seq;const unread=list.some(m=>m.sender_id!==userId&&!m.read);
useEffect(()=>{if(last&&unread&&active&&!q)void api.action({action:'read_chat',target_id:person.id,up_to_seq:last}).then(()=>cache.invalidateQueries({queryKey})).catch(()=>undefined);},[last,unread,active,q,api,person.id,messages.dataUpdatedAt]);
const refresh=()=>cache.invalidateQueries({queryKey});
const update=useMutation({mutationFn:(input:{id:string;body:ChatMessageUpdate})=>api.updateMessage(person.id,input.id,input.body),onSuccess:refresh});
const send=useMutation({mutationFn:async()=>{if(editing){await api.updateMessage(person.id,editing.id,{action:'edit',text});return;}if(!pending.current||pending.current.text!==text||pending.current.reply_to!==reply?.id)pending.current={id:eventId(Date.now()),text,...(reply?{reply_to:reply.id}:{})};await api.send(person.id,{...pending.current,attachment_kind:'none'});},onSuccess:async()=>{setText(editing?beforeEdit.current:'');setEditing(null);setReply(null);pending.current=null;await refresh();}});
const scroll=useRef<ScrollView>(null);const [bottom,setBottom]=useState(true);useEffect(()=>setBottom(true),[last]);
const cancelEdit=()=>{setText(beforeEdit.current);setEditing(null);};
const mutate=(m:DirectMessage,body:ChatMessageUpdate)=>{if(!update.isPending)update.mutate({id:m.id,body});setMenu(null);};
return <KeyboardAvoidingView behavior="padding" style={{flex:1}}>
<View style={{padding:12,gap:8}}><H1>{person.display_name}</H1><Muted>{t(person.online ? 'social.online' : 'social.offline')}</Muted><Field label={t('social.chat_search')} value={search} onChangeText={setSearch} maxLength={100}/><ErrorBox>{messages.error||send.error||update.error?errorText(messages.error??send.error??update.error):null}</ErrorBox></View>
<ScrollView ref={scroll} keyboardShouldPersistTaps="handled" contentContainerStyle={{padding:14,gap:12}} onContentSizeChange={()=>{if(bottom&&!q)scroll.current?.scrollToEnd({animated:!reduced});}}>
{messages.hasNextPage&&<Button title={t('social.older')} variant="ghost" busy={messages.isFetchingNextPage} onPress={()=>{setBottom(false);void messages.fetchNextPage();}}/>}
{!list.length&&<Muted>{t(messages.isPending?'social.loading':q?'social.chat_no_results':'social.first_message')}</Muted>}
{list.map((m,i)=><View key={m.id} style={{gap:8}}>
{(i===0||new Date(list[i-1]!.created_at).toDateString()!==new Date(m.created_at).toDateString())&&<Text style={{color:colors.text3,fontSize:11,textAlign:'center'}}>{new Date(m.created_at).toLocaleDateString(i18n.language,{day:'numeric',month:'long'})}</Text>}
<Bubble mine={m.sender_id===userId} animate={!reduced}>
<Pressable disabled={m.deleted} onLongPress={()=>setMenu(m)} accessibilityLabel={t('social.message_actions')}>
{m.reply_to&&<View style={{borderLeftWidth:2,borderLeftColor:colors.accent,paddingLeft:8,marginBottom:8}}><Muted>{m.reply_preview||t('social.message_deleted')}</Muted></View>}
<Text selectable style={{color:m.deleted?colors.text3:colors.text,fontSize:15,fontStyle:m.deleted?'italic':'normal'}}>{m.deleted?t('social.message_deleted'):m.text}</Text>
</Pressable>
{!m.deleted&&m.attachment_id&&m.attachment_kind!=='none'&&<Button title={t(m.attachment_kind==='track'?'social.listen_track':'social.listen_playlist')} variant="secondary" onPress={()=>router.push({pathname:m.attachment_kind==='track'?'/social-track':'/social-playlist',params:{id:m.attachment_id}})}/>}
{!m.deleted&&!!m.reactions?.length&&<View style={{flexDirection:'row',gap:6,flexWrap:'wrap'}}>{m.reactions.map(r=><Pressable key={r.emoji} accessibilityRole="button" accessibilityState={{selected:r.mine}} onPress={()=>mutate(m,{action:'react',emoji:r.emoji as typeof CHAT_REACTIONS[number]})} style={{padding:6,borderRadius:12,backgroundColor:r.mine?colors['accent-soft']:colors.surface3}}><Text style={{color:colors.text}}>{r.emoji} {r.count}</Text></Pressable>)}</View>}
<View style={{flexDirection:'row',gap:8,alignItems:'center'}}><Text style={{color:colors.text3,fontSize:10,flex:1}}>{new Date(m.created_at).toLocaleTimeString(i18n.language,{hour:'2-digit',minute:'2-digit'})}{m.edited_at&&!m.deleted?` · ${t('social.edited')}`:''}{m.sender_id===userId?` · ${t(m.read?'social.read':'social.sent')}`:''}</Text>{!m.deleted&&<Pressable accessibilityRole="button" accessibilityLabel={t('social.message_actions')} hitSlop={10} onPress={()=>setMenu(m)}><Text style={{color:colors.text3}}>•••</Text></Pressable>}</View>
</Bubble>
</View>)}
</ScrollView>
<Text accessibilityLiveRegion="polite" style={{minHeight:18,paddingHorizontal:14,color:colors.text3,fontSize:11}}>{active&&meta.data?.typing?`${person.display_name} ${t('social.typing')}`:''}</Text>
{(reply||editing)&&<View style={{padding:10,backgroundColor:colors['accent-soft'],gap:4}}><Muted>{t(editing?'social.editing':'social.replying')}</Muted><Text numberOfLines={2} style={{color:colors.text2}}>{(editing??reply)?.text}</Text><Button title={t(editing?'social.cancel_edit':'social.cancel_reply')} variant="ghost" onPress={()=>editing?cancelEdit():setReply(null)}/></View>}
<View style={{padding:12,gap:8,borderTopWidth:1,borderTopColor:colors.line}}><Field label={t('social.message')} value={text} onChangeText={value=>{touched.current=true;setText(value);}} maxLength={4000} multiline editable={!send.isPending}/><View style={{flexDirection:'row',gap:8}}><Button title="☺" variant="ghost" onPress={()=>setEmojis(v=>!v)}/><View style={{flex:1}}><Button title={t(editing?'playlists.save':'social.send')} busy={send.isPending} disabled={!text.trim()} onPress={()=>send.mutate()}/></View></View>{emojis&&<View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>{setText(v=>(v+emoji).slice(0,4000));setEmojis(false);}}/>)}</View>}</View>
<Modal transparent visible={!!menu} animationType={reduced?'none':'fade'} onRequestClose={()=>setMenu(null)}><View style={{flex:1,justifyContent:'flex-end',backgroundColor:colors.overlay}}><Screen style={{flex:0,maxHeight:'80%',borderTopLeftRadius:24,borderTopRightRadius:24}}><ScrollView contentContainerStyle={{padding:20,gap:10}}><Button title={t('form.close')} variant="ghost" onPress={()=>setMenu(null)}/>{menu&&<><Button title={t('social.reply')} variant="secondary" onPress={()=>{if(editing)cancelEdit();setReply(menu);setMenu(null);}}/><View style={{flexDirection:'row',gap:8,flexWrap:'wrap'}}>{CHAT_REACTIONS.map(emoji=><Button key={emoji} title={emoji} variant="ghost" onPress={()=>mutate(menu,{action:'react',emoji})}/>)}</View>{menu.sender_id===userId&&<><Button title={t('social.edit_message')} variant="secondary" onPress={()=>{beforeEdit.current=text;setEditing(menu);setText(menu.text);setReply(null);setMenu(null);}}/><Button title={t('social.delete_message')} variant="danger" onPress={()=>mutate(menu,{action:'delete'})}/></>}</>}</ScrollView></Screen></View></Modal>
</KeyboardAvoidingView>;
}
@@ -0,0 +1,60 @@
import { socialApi, type ListeningPreferences, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal, ScrollView, Switch, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { eventId } from '../../player/eventId';
import { usePlayerState } from '../../player/hooks';
import { useColors } from '../../theme';
import { Button, ErrorBox, H1, Muted, Screen, useErrorText } from '../../ui/kit';
function useListening() {
const session = useSession(); const { server } = useSessionCtx(); const user = useUser();
const api = useMemo(() => socialApi(session), [session]); const key = ['m-listening', server, user.id] as const;
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
return { api, key, query, user };
}
export function ListeningPanel() {
const { api, key, query, user } = useListening(); const { t } = useTranslation(); const cache = useQueryClient(); const errorText = useErrorText(); const colors = useColors();
const [expanded, setExpanded] = useState(false); const prefs = query.data?.preferences;
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const action = useMutation({ mutationFn: (a: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: eventId(Date.now()), action: a.action, target_id: a.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 12 }}><Button title={t('social.together')} variant="secondary" onPress={() => setExpanded(v => !v)} />
{expanded && <View style={{ padding: 16, borderRadius: 16, gap: 14, backgroundColor: colors.surface }}><Muted>{t('social.stream_prefs_hint')}</Muted>
{(['requests', 'auto_accept'] as const).map(field => <View key={field} style={{ gap: 8 }}><Muted>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</Muted><View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} disabled={!prefs || save.isPending} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onPress={() => prefs && save.mutate({ ...prefs, [field]: v })} />)}</View></View>)}
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}><Text style={{ color: colors.text, flex: 1 }}>{t('social.stream_autoplay')}</Text><Switch accessibilityLabel={t('social.stream_autoplay')} value={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onValueChange={autoplay => prefs && save.mutate({ ...prefs, autoplay })} /></View>
</View>}
{query.data?.requests.map(r => <View key={r.id} style={{ padding: 16, borderRadius: 16, gap: 10, backgroundColor: colors.surface }}><Text style={{ color: colors.text }}>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</Text>{r.recipient_id === user.id ? <><Button title={t('social.accept')} disabled={action.isPending} onPress={() => action.mutate({ action: 'accept', target: r.id })} /><Button title={t('social.decline')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'decline', target: r.id })} /></> : <Button title={t('form.cancel')} disabled={action.isPending} variant="ghost" onPress={() => action.mutate({ action: 'cancel', target: r.id })} />}</View>)}
<ErrorBox>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</ErrorBox>
</View>;
}
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
const { t } = useTranslation(); const [open, setOpen] = useState(false);
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
return <><Button title={t('social.together')} disabled={!person.online} variant="ghost" onPress={() => setOpen(true)} />{!person.online && <Muted>{t('social.together_offline')}</Muted>}{open && person.online && <TogetherSheet person={person} close={() => setOpen(false)} />}</>;
}
function TogetherSheet({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const { api, key } = useListening(); const cache = useQueryClient(); const state = usePlayerState(); const errorText = useErrorText();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite'); const [id] = useState(() => eventId(Date.now()));
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 20, gap: 16 }}><Button title={t('form.close')} variant="ghost" onPress={close} /><H1>{t('social.together')}</H1><Muted>{person.display_name}</Muted><Muted>{t('social.stream_choice')}</Muted>
<Button title={t('social.join_stream')} variant={kind === 'join' ? 'primary' : 'ghost'} disabled={!person.activity || action.isPending || action.isSuccess} onPress={() => setKind('join')} />
<Button title={t('social.invite_stream')} variant={kind === 'invite' ? 'primary' : 'ghost'} disabled={!state.track || state.listening?.locked || action.isPending || action.isSuccess} onPress={() => setKind('invite')} />
<Muted>{t('social.stream_hint')}</Muted><ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
{action.isSuccess ? <Muted>{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</Muted> : <Button title={t('social.send')} busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onPress={() => action.mutate()} />}
</ScrollView></Screen></Modal>;
}
export function ListeningBanner() {
const state = usePlayerState(); const session = useSession(); const user = useUser(); const colors = useColors(); const { t } = useTranslation(); const errorText = useErrorText(); const room = state.listening?.room;
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: eventId(Date.now()), action: room.owner_id === user.id ? 'end' : 'leave', target_id: room.id }) });
if (!room) return null;
return <View style={{ padding: 12, gap: 6, backgroundColor: colors.surface, borderTopWidth: 1, borderTopColor: colors.ok }}><Text style={{ color: colors.text, fontWeight: '600' }}>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</Text>
<Muted>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</Muted>
<Muted>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</Muted>
{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button title={t('social.stream_start_audio')} variant="secondary" onPress={() => player.beginListeningAudio()} />}
<Button title={t(room.owner_id === user.id ? 'social.stream_end' : 'social.stream_leave')} variant="ghost" busy={action.isPending} onPress={() => action.mutate()} />
<ErrorBox>{action.error ? errorText(action.error) : null}</ErrorBox>
</View>;
}
@@ -0,0 +1,38 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FlatList, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { player } from '../../player/controller';
import { Button, ErrorBox, H1, Muted, Screen, TrackRow, useErrorText } from '../../ui/kit';
export function SharedMusic({ kind }: { kind: 'track' | 'playlist' }) {
const { id, owner } = useLocalSearchParams<{ id: string; owner?: string }>();
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const router = useRouter();
const { t } = useTranslation();
const errorText = useErrorText();
const [playError, setPlayError] = useState<unknown>();
const query = useQuery({ queryKey: ['shared-music', server, user.id, kind, id, owner], refetchInterval: owner ? 15_000 : false, queryFn: async () => {
if (owner) return unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId: owner } } }));
if (kind === 'track') {
const track = unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } }));
return { id: track.id, name: track.title, tracks: [track] };
}
return unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } }));
} });
const visible = query.isError ? undefined : query.data;
return <Screen><FlatList data={visible?.tracks ?? []} keyExtractor={x => x.id}
ListHeaderComponent={<View style={{ padding: 20, gap: 16 }}>
<Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />
<H1>{visible?.name ?? t(kind === 'track' ? 'social.listen_track' : 'social.listen_playlist')}</H1>
{query.isPending && <Muted>{t('social.loading')}</Muted>}
<ErrorBox>{query.error || playError ? errorText(query.error ?? playError) : null}</ErrorBox>
</View>}
renderItem={({ item, index }) => <TrackRow track={item} onPress={() => void player.playList(visible?.tracks ?? [], index, { type: kind === 'track' ? 'queue' : 'collection', id: visible?.id, label: visible?.name }).catch(setPlayError)} />}
/></Screen>;
}
+149
View File
@@ -0,0 +1,149 @@
import { attachmentPath, socialApi, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { eventId } from '../../player/eventId';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useFocusEffect, useRouter } from 'expo-router';
import { createContext, useContext, useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { AppState, Clipboard, KeyboardAvoidingView, Modal, Pressable, ScrollView, Share, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../../lib/session';
import { useColors } from '../../theme';
import { Button, ErrorBox, Field, H1, Muted, Screen, useErrorText } from '../../ui/kit';
function useSocial() {
const session = useSession();
const { server } = useSessionCtx();
const user = useUser();
const api = useMemo(() => socialApi(session), [session]);
const [active, setActive] = useState(AppState.currentState === 'active');
useEffect(() => { const sub = AppState.addEventListener('change', s => setActive(s === 'active')); return () => sub.remove(); }, []);
const key = ['m-social', server, user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, enabled: active, refetchInterval: active ? 15_000 : false });
return { api, key, inbox, user, active };
}
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
const ChatDockContext = createContext<(id: string) => boolean>(() => false);
export function NativeChatProvider({ children }: { children: ReactNode }) {
const { inbox } = useSocial(); const colors = useColors(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [peer, setPeer] = useState<string | null>(null);
const friends = inbox.data?.people.filter(p => p.relationship === 'friend') ?? [];
const selected = friends.find(p => p.id === peer); const unread = friends.reduce((sum,p) => sum+p.unread,0);
return <ChatDockContext.Provider value={id => { setPeer(id); setOpen(true); return true; }}>{children}
<View style={{ position: 'absolute', bottom: 158, right: 16 }}><Button title={`${t('social.messages')}${unread ? ` · ${unread}` : ''}`} variant="secondary" onPress={() => setOpen(true)} /></View>
<Modal transparent visible={open} animationType="slide" onRequestClose={() => setOpen(false)}>
<View style={{ flex: 1, justifyContent: 'flex-end', backgroundColor: colors.overlay }}><View style={{ height: '82%', borderTopLeftRadius: 24, borderTopRightRadius: 24, overflow: 'hidden', backgroundColor: colors.bg }}>
<Screen><View style={{ padding: 12, flexDirection: 'row', justifyContent: 'space-between' }}><Button title={t('social.people')} variant="ghost" onPress={() => setPeer(null)} /><Button title={t('social.minimize')} variant="ghost" onPress={() => setOpen(false)} /></View>
{selected ? <Chat key={selected.id} person={selected} active={open} /> : <ScrollView contentContainerStyle={{ padding: 16, gap: 12 }}><H1>{t('social.messages')}</H1>{friends.map(p => <Button key={p.id} title={`${p.display_name}${p.unread ? ` · ${p.unread}` : ''}`} variant="secondary" onPress={() => setPeer(p.id)} />)}{!friends.length && <Muted>{t('social.no_friends_share')}</Muted>}</ScrollView>}
</Screen>
</View></View>
</Modal>
</ChatDockContext.Provider>;
}
export function FriendRecommendations() {
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <View style={{ gap: 8 }}><Button title={t('social.friends_recommendations')} variant={(inbox.data?.friends_recommendations ?? true) ? 'primary' : 'secondary'} disabled={!inbox.data || save.isPending} onPress={() => save.mutate(!(inbox.data?.friends_recommendations ?? true))} /><Muted>{t('social.friends_recommendations_hint')}</Muted><ErrorBox>{save.error ? errorText(save.error) : null}</ErrorBox></View>;
}
export function MusicShareSheet({ attachment, url, close }: { attachment: MusicAttachment; url: string; close: () => void }) {
const { t } = useTranslation();
const { inbox, api, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [sent, setSent] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const ids = useRef(new Map<string, string>());
const send = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { Clipboard.setString(url); setCopied(true); return; }
if (peer === 'external') { await Share.share({ title: attachment.title, message: url }); return; }
let id = ids.current.get(peer); if (!id) { id = eventId(Date.now()); ids.current.set(peer, id); }
await api.send(peer, { id, text: attachment.title, attachment_kind: attachment.kind, attachment_id: attachment.id });
setSent(v => [...v, peer]); await cache.invalidateQueries({ queryKey: key });
} });
return <Modal animationType="slide" onRequestClose={close}><Screen><ScrollView contentContainerStyle={{ padding: 24, gap: 16 }}>
<Button title={t('form.cancel')} variant="ghost" onPress={close} />
<H1>{t('player.share')}</H1><Muted>{attachment.title}</Muted>
<Button title={t(copied ? 'social.copied' : 'social.copy')} disabled={send.isPending} onPress={() => send.mutate('copy')} />
<Button title={t('social.external')} variant="secondary" disabled={send.isPending} onPress={() => send.mutate('external')} />
<H1>{t('social.send_friend')}</H1>
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <Muted>{t('social.no_friends_share')}</Muted>}
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} title={`${p.display_name} · ${t(sent.includes(p.id) ? 'social.sent' : 'social.send')}`} variant="secondary" disabled={send.isPending || sent.includes(p.id)} onPress={() => send.mutate(p.id)} />)}
<ErrorBox>{send.error || inbox.error ? errorText(send.error ?? inbox.error) : null}</ErrorBox>
</ScrollView></Screen></Modal>;
}
export function SocialScreen({ root = false }: { root?: boolean }) {
const dock = useContext(ChatDockContext);
const { t } = useTranslation();
const colors = useColors();
const router = useRouter();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [peer, setPeer] = useState('');
const [notifications, setNotifications] = useState(false);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: q.length >= 2 });
const act = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const selected = inbox.data?.people.find(p => p.id === peer && p.relationship === 'friend');
if (selected) return <Screen><Button title={t('social.back')} variant="ghost" onPress={() => setPeer('')} /><Chat key={selected.id} person={selected} /></Screen>;
const people = q.length >= 2 ? results.data ?? [] : inbox.data?.people ?? [];
return <Screen><ScrollView keyboardShouldPersistTaps="handled" contentContainerStyle={{ padding: 20, gap: 16 }}>
{!root && <Button title={t('social.back')} variant="ghost" onPress={() => router.back()} />}
<H1>{t('social.title')}</H1><Muted>{t('social.subtitle')}</Muted>
<View style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Muted>{t('social.activity_visibility')}</Muted>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} title={t(`social.visibility_${v}`)} variant={(inbox.data?.activity_visibility ?? 'nobody') === v ? 'primary' : 'ghost'} disabled={!inbox.data || visibility.isPending} onPress={() => visibility.mutate(v)} />)}</View>
<Muted>{t('social.activity_privacy_hint')}</Muted>
<ErrorBox>{visibility.error ? errorText(visibility.error) : null}</ErrorBox>
</View>
<ListeningPanel />
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
<Button title={t('social.people')} variant={notifications ? 'ghost' : 'primary'} onPress={() => setNotifications(false)} />
<Button title={`${t('social.notifications')} ${inbox.data?.unread || ''}`} variant={notifications ? 'primary' : 'ghost'} onPress={() => setNotifications(true)} />
</View>
<ErrorBox>{inbox.error || results.error || act.error ? errorText(inbox.error ?? results.error ?? act.error) : null}</ErrorBox>
{inbox.isPending && <Muted>{t('social.loading')}</Muted>}
{notifications ? <>
{inbox.data?.notifications.length === 0 && <Muted>{t('social.no_notifications')}</Muted>}
{inbox.data?.notifications.map(n => <View key={n.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10, borderWidth: 1, borderColor: n.read ? colors.line : colors.accent }}>
<Text style={{ color: colors.text, fontWeight: '600' }}>{n.actor_name}</Text><Muted>{t(`social.kind_${n.kind}`)}</Muted><Muted>{new Date(n.created_at).toLocaleString()}</Muted>
<Button title={t('social.open')} variant="secondary" onPress={() => { setNotifications(false); setSearch(''); setQ(''); setPeer(n.actor_id); }} />
{!n.read && <Button title={t('social.mark_read')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'read_notification', target_id: n.id })} />}
</View>)}
</> : <>
<Field label={t('social.search')} value={search} onChangeText={setSearch} maxLength={80} />
{q.length >= 2 && results.isFetching && <Muted>{t('social.loading')}</Muted>}
{!people.length && inbox.data && !results.isFetching && <Muted>{t(q.length >= 2 ? 'social.no_results' : 'social.empty')}</Muted>}
{people.map(p => <View key={p.id} style={{ padding: 16, borderRadius: 16, backgroundColor: colors.surface, gap: 10 }}>
<Text style={{ color: colors.text, fontWeight: '600', fontSize: 17 }}>{p.display_name}</Text><Muted>@{p.username} · {t(`social.rel_${p.relationship}`)}</Muted>
{p.relationship === 'friend' && <Text style={{ color: colors.text2, fontSize: 12 }}>{t(p.online ? 'social.online' : 'social.offline')}</Text>}
{p.activity && <View style={{ borderLeftWidth: 2, borderLeftColor: colors.ok, paddingLeft: 12, gap: 6 }}>
<Text style={{ color: colors.ok, fontSize: 12 }}>{t('social.listening')}</Text>
<Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-track', params: { id: p.activity!.track_id } })}><Text style={{ color: colors.accent2 }}>{p.activity.track_title}</Text></Pressable>
{!!p.activity.artist && <Muted>{p.activity.artist}</Muted>}
{p.activity.context === 'wave' && <Muted>{t('wave.title')}</Muted>}
{p.activity.context === 'playlist' && p.activity.playlist_id && <Pressable accessibilityRole="link" onPress={() => router.push({ pathname: '/social-playlist', params: { owner: p.id, id: p.activity!.playlist_id } })}><Text style={{ color: colors.accent2 }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Text></Pressable>}
</View>}
{p.relationship === 'friend' && <Button title={`${t('social.chat')}${p.unread ? ` · ${p.unread}` : ''}`} onPress={() => { if (!dock(p.id)) setPeer(p.id); }} />}
{p.relationship === 'friend' && <ListenTogetherButton person={p} />}
{p.relationship === 'none' && <Button title={t('social.add')} disabled={act.isPending} onPress={() => act.mutate({ action: 'request', target_id: p.id })} />}
{p.relationship === 'incoming' && <Button title={t('social.accept')} disabled={act.isPending} onPress={() => act.mutate({ action: 'accept', target_id: p.id })} />}
{['friend', 'incoming', 'outgoing'].includes(p.relationship) && <Button title={t(p.relationship === 'incoming' ? 'social.decline' : p.relationship === 'outgoing' ? 'social.cancel' : 'social.remove')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: 'remove', target_id: p.id })} />}
<Button title={t(p.relationship === 'blocked' ? 'social.unblock' : 'social.block')} variant="ghost" disabled={act.isPending} onPress={() => act.mutate({ action: p.relationship === 'blocked' ? 'unblock' : 'block', target_id: p.id })} />
</View>)}
</>}
</ScrollView></Screen>;
}
function Chat({ person, active: visible = true }: { person: SocialPerson; active?: boolean }) {
const {api,key,user,active}=useSocial(); const {server}=useSessionCtx();
const [focused,setFocused]=useState(false);
useFocusEffect(useCallback(()=>{setFocused(true);return()=>setFocused(false);},[]));
return <ChatConversation person={person} api={api} queryKey={key} userId={user.id} server={server??''} active={visible&&active&&focused}/>;
}
+14
View File
@@ -0,0 +1,14 @@
import * as FileSystem from 'expo-file-system';
import { sha256 } from 'js-sha256';
import { chatDraftKey } from '@pipu/core';
const directory = `${FileSystem.documentDirectory}chat-drafts/`;
const path = (server: string, user: string, peer: string) => `${directory}${sha256(chatDraftKey(server,user,peer))}.txt`;
export async function readDraft(server: string, user: string, peer: string) {
try { return await FileSystem.readAsStringAsync(path(server,user,peer)); } catch { return ''; }
}
export async function saveDraft(server: string, user: string, peer: string, text: string) {
if (!text) { await FileSystem.deleteAsync(path(server,user,peer), { idempotent: true }); return; }
await FileSystem.makeDirectoryAsync(directory, { intermediates: true });
await FileSystem.writeAsStringAsync(path(server,user,peer),text);
}
+86 -5
View File
@@ -1,6 +1,10 @@
import NetInfo from '@react-native-community/netinfo';
import {
EventBuffer,
createActivityPublisher,
type ActivityPublisher,
ListeningCoordinator,
type ListeningRoom,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveInfo,
@@ -32,6 +36,7 @@ export { offlineWave };
export type { WaveInfo } from '@pipu/core';
export interface PlayerState {
listening?: import('@pipu/core').ListeningClientState;
ready: boolean;
track: Track | null;
queue: Track[];
@@ -72,6 +77,11 @@ async function quality(): Promise<Quality> {
* реакции и бесконечная волна. Один экземпляр на процесс: им пользуются и экраны, и фоновый сервис.
*/
class Controller {
private activity: ActivityPublisher | null = null;
private listening: ListeningCoordinator | null = null;
private interrupted = false;
private remoteRoom: string | null = null;
private queueVersion = 0;
private session: AuthSession | null = null;
private server = '';
private events: EventBuffer | null = null;
@@ -104,12 +114,27 @@ class Controller {
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
this.activity?.update(this.state);
this.listening?.update();
for (const fn of this.listeners) fn();
}
/** Привязка к вошедшему пользователю: события и реакции уходят на его сервер, без сети — копятся в SQLite. */
attach(session: AuthSession | null, server: string, userId: string | null) {
if (this.session === session) return;
this.activity?.dispose();
this.listening?.dispose();
this.listening = null;
this.activity = session && userId ? createActivityPublisher(session, eventId(Date.now())) : null;
if (session && this.activity) {
this.listening = new ListeningCoordinator(session, this.activity.deviceId, {
read: this.getState,
pause: () => { this.queueVersion++; this.leaveWave(); void TrackPlayer.pause().catch(() => undefined); },
apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }),
});
this.listening.start();
}
void this.events?.flush();
this.session = session;
this.server = server;
@@ -240,35 +265,49 @@ class Controller {
}
async playList(tracks: Track[], index: number, context: PlayContext) {
if (this.listening?.locked) return;
const version = ++this.queueVersion;
const current = () => version === this.queueVersion && !this.listening?.locked;
const list = tracks.filter(playable);
const target = tracks[index];
if (!target || !list.length) return;
await this.setup();
if (!current()) return;
const native = await this.toRntp(list);
if (!current()) return;
this.leaveWave();
this.finishAttempt(this.lastPositionMs / 1000);
const start = Math.max(list.indexOf(target), 0);
this.update({ queue: list, context, index: start, track: list[start] ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(list));
if (!current()) return;
await TrackPlayer.add(native);
if (!current()) return;
await TrackPlayer.skip(start);
if (!current()) return;
await TrackPlayer.play();
}
toggle() {
if (this.listening?.locked) return;
void (this.state.playing ? TrackPlayer.pause() : TrackPlayer.play());
}
next() {
if (this.listening?.locked) return;
void TrackPlayer.skipToNext().catch(() => undefined);
}
async prev() {
if (this.listening?.locked) return;
const { position } = await TrackPlayer.getProgress();
if (this.listening?.locked) return;
if (position > 3) await TrackPlayer.seekTo(0);
else await TrackPlayer.skipToPrevious().catch(() => TrackPlayer.seekTo(0));
}
seek(ms: number) {
if (this.listening?.locked) return;
const t = this.state.track;
if (t) this.events?.push({ type: 'seek', track_id: t.id, from_ms: this.lastPositionMs, position_ms: ms });
this.attempt?.seek(ms);
@@ -306,6 +345,7 @@ class Controller {
// --- «Моя волна» ---
async startWave(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
if (!this.session) return;
await this.setup();
this.leaveWave();
@@ -335,6 +375,7 @@ class Controller {
}
async setWaveContext(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
const wave = this.state.wave;
if (!wave || !this.session) return this.startWave(activity, mood);
const version = this.wavePolicy.changeContext(wave.sessionId);
@@ -422,18 +463,28 @@ class Controller {
/** Восстановить последний трек и позицию (на паузе, без автозапуска). */
async restore() {
if (!this.session || this.state.track) return;
if (!this.session || this.state.track || this.listening?.locked) return;
const session = this.session;
const initial = this.state;
const current = () => this.session === session && this.state.queue === initial.queue && !this.state.track && !this.listening?.locked;
await this.setup();
if (!current()) return;
try {
const st = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
const st = unwrap(await session.client.GET('/api/v1/me/playback-state'));
if (!current()) return;
if (!st.track_id || !st.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
const res = unwrap(await session.client.POST('/api/v1/tracks/batch', { body: { ids: st.queue } }));
if (!current()) return;
const tracks = res.items.filter(playable);
const idx = Math.max(tracks.findIndex((t) => t.id === st.track_id), 0);
if (!tracks.length) return;
const native = await this.toRntp(tracks);
if (!current()) return;
this.update({ queue: tracks, index: idx, track: tracks[idx] ?? null, context: (st.context as PlayContext | null) ?? null });
await TrackPlayer.reset();
await TrackPlayer.add(await this.toRntp(tracks));
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.add(native);
if (this.state.queue !== tracks || this.listening?.locked) return;
await TrackPlayer.skip(idx, tracks[idx]?.id === st.track_id ? st.position_ms / 1000 : 0);
} catch {
// офлайн или нет сохранённого состояния
@@ -441,6 +492,7 @@ class Controller {
}
async reset() {
this.queueVersion++;
this.wavePolicy.activate(null);
this.attempt = null;
this.update({ queue: [], track: null, index: 0, playing: false, positionMs: 0, wave: null, context: null });
@@ -451,6 +503,35 @@ class Controller {
this.save();
void this.events?.flush();
}
play() { if (!this.listening?.locked) void TrackPlayer.play(); }
pause() { if (!this.listening?.locked) void TrackPlayer.pause(); }
setInterrupted(paused: boolean) { this.interrupted = paused; }
beginListeningAudio() { this.listening?.beginAudio(); }
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { await TrackPlayer.pause(); return; }
await this.setup();
if (!current()) return;
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session!.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
const native = await this.toRntp([track]);
if (!current()) return;
this.remoteRoom = room.id;
this.leaveWave(); this.finishAttempt(this.lastPositionMs / 1000);
await TrackPlayer.pause();
if (!current()) return;
this.update({ queue: [track], track, index: 0, durationMs: track.duration_ms, positionMs: 0, context: { type: 'queue' }, wave: null });
await TrackPlayer.reset();
if (!current()) return;
await TrackPlayer.add(native);
}
if (!current()) return;
const target = Math.min(position, Math.max(0, this.state.durationMs - 100));
if (Math.abs(this.state.positionMs - target) > 1500) await TrackPlayer.seekTo(target / 1000);
if (!current()) { await TrackPlayer.pause(); return; }
if (room.playing && !this.interrupted) { if (!this.state.playing) await TrackPlayer.play(); }
else if (this.state.playing) await TrackPlayer.pause();
}
}
export const player = new Controller();
+4 -3
View File
@@ -7,14 +7,15 @@ import { player } from './controller';
* команды уходят прямо в ExoPlayer, а учёт прослушиваний ведёт тот же контроллер.
*/
export async function PlaybackService(): Promise<void> {
TrackPlayer.addEventListener(Event.RemotePlay, () => void TrackPlayer.play());
TrackPlayer.addEventListener(Event.RemotePause, () => void TrackPlayer.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => void TrackPlayer.stop());
TrackPlayer.addEventListener(Event.RemotePlay, () => player.play());
TrackPlayer.addEventListener(Event.RemotePause, () => player.pause());
TrackPlayer.addEventListener(Event.RemoteStop, () => player.pause());
TrackPlayer.addEventListener(Event.RemoteNext, () => player.next());
TrackPlayer.addEventListener(Event.RemotePrevious, () => void player.prev());
TrackPlayer.addEventListener(Event.RemoteSeek, ({ position }) => player.seek(position * 1000));
// Звонок / голосовой ассистент: приостанавливаем и продолжаем сами (autoHandleInterruptions — для фокуса).
TrackPlayer.addEventListener(Event.RemoteDuck, ({ paused, permanent }) => {
player.setInterrupted(paused || permanent);
if (permanent) void TrackPlayer.pause();
else if (paused) void TrackPlayer.pause();
});
+2
View File
@@ -3,6 +3,7 @@ import { AGE_GROUPS, GENDERS, unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { FriendRecommendations } from '../features/social/Social';
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
import { useSession, useSessionCtx, useUser } from '../lib/session';
@@ -38,6 +39,7 @@ export function RecommendationSettings() {
const [editing, setEditing] = useState(false);
const errorText = useErrorText();
return <View>
<FriendRecommendations />
{editing && query.data ? <Onboarding key={JSON.stringify(query.data.choice)} options={query.data} editing onDone={() => {
setEditing(false); void client.invalidateQueries({ queryKey: ['onboarding'] });
}} /> : <Button title={t('onboarding.edit')} variant="secondary" busy={query.isFetching} onPress={() => { void query.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }} />}
+15
View File
@@ -70,3 +70,18 @@ it('does not let a late context response replace a manually selected playlist',
expect(player.getState().queue.map((t) => t.id)).toEqual(['manual']);
expect(player.getState().context?.type).toBe('album');
});
it('does not restore a stale saved queue after manual playback starts', async () => {
const { player } = await import('../src/player/controller');
const saved = deferred<unknown>();
const get = vi.fn().mockReturnValue(saved.promise);
const post = vi.fn();
player.attach({ client: { GET: get, POST: post } } as unknown as AuthSession, 'http://localhost', null);
const restoring = player.restore();
await vi.waitFor(() => expect(get).toHaveBeenCalled());
await player.playList([track('chosen')], 0, { type: 'queue' });
saved.resolve({ data: { track_id: 'old', queue: ['old'], position_ms: 500 }, response: new Response() });
await restoring;
expect(player.getState().track?.id).toBe('chosen');
expect(post).not.toHaveBeenCalledWith('/api/v1/tracks/batch', expect.anything());
});
+73
View File
@@ -0,0 +1,73 @@
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('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
test('admin groups related pages and remembers collapsed sections', async ({ page }, info) => {
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
await nav.getByRole('button', { name: 'Админка', exact: true }).click();
await expect(nav.getByRole('link', { name: 'Пользователи', exact: true })).toBeVisible();
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(nav.getByRole('button', { name: title, exact: true })).toHaveAttribute('aria-expanded', 'false');
}
await nav.getByRole('button', { name: 'Система', exact: true }).click();
const system = nav.getByRole('group', { name: 'Система', exact: true });
await expect(system.getByRole('link')).toHaveCount(4);
for (const title of ['Обновления', 'Конфигурация', 'Настройки', 'Журнал']) {
await expect(system.getByRole('link', { name: title, exact: true })).toBeVisible();
}
await nav.screenshot({ path: info.outputPath('admin-groups.png') });
// The fake API resets authentication on a full document load; sign in again.
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'true');
await nav.getByRole('button', { name: 'Система', exact: true }).click();
await login(page);
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveAttribute('aria-expanded', 'false');
await login(page, 'mobile-admin', '/admin/audit');
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toBeVisible();
await expect(system.getByRole('link', { name: 'Журнал', exact: true })).toHaveAttribute('data-status', 'active');
await nav.getByRole('button', { name: 'Медиатека', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link')).toHaveCount(2);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link')).toHaveCount(3);
});
test('moderator sees only permitted groups and pages', async ({ page }) => {
await login(page, 'moderator', '/admin/library');
const nav = page.getByRole('navigation', { name: 'Навигация' });
await expect(nav.getByRole('group', { name: 'Медиатека', exact: true }).getByRole('link', { name: 'Библиотека' })).toBeVisible();
await expect(nav.getByRole('button', { name: 'Система', exact: true })).toHaveCount(0);
await expect(nav.getByRole('link', { name: 'Хранилища' })).toHaveCount(0);
await nav.getByRole('button', { name: 'Загрузки', exact: true }).click();
await expect(nav.getByRole('group', { name: 'Загрузки', exact: true }).getByRole('link', { name: 'Очередь', exact: true })).toBeVisible();
});
test('mobile menu exposes grouped pages and closes after navigation', async ({ page }, info) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page);
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Навигация' });
for (const title of ['Медиатека', 'Загрузки', 'Система']) {
await expect(dialog.getByRole('heading', { name: title, exact: true })).toBeVisible();
}
await dialog.screenshot({ path: info.outputPath('admin-groups-mobile.png') });
await dialog.getByRole('group', { name: 'Система', exact: true }).getByRole('link', { name: 'Обновления', exact: true }).click();
await expect(dialog).not.toBeVisible();
await expect(page).toHaveURL(/\/admin\/updates$/);
});
test('tablet icon sidebar keeps all admin destinations accessible', async ({ page }) => {
await page.setViewportSize({ width: 900, height: 900 });
await login(page);
const nav = page.getByRole('navigation', { name: 'Навигация' });
for (const title of ['Библиотека', 'Хранилища', 'Предложить треки', 'Очередь', 'Журнал']) {
await expect(nav.getByRole('link', { name: title, exact: true })).toBeVisible();
}
});
+49
View File
@@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
for (const scenario of ['pending', 'automatic', 'restricted', 'incoming']) {
test(`listening request lifecycle: ${scenario}`, async ({ page }, info) => {
await page.addInitScript(value => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.listening', value);
}, scenario);
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const banner = page.getByRole('region', { name: 'Слушать вместе', exact: true });
await expect(banner).toHaveCount(0);
if (scenario === 'incoming') {
await expect(page.getByRole('button', { name: 'Принять', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('pending-invite.png'), fullPage: true });
await page.getByRole('button', { name: 'Принять', exact: true }).click();
} else {
await page.getByRole('button', { name: 'Слушать вместе', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Слушать вместе', exact: true });
await expect(dialog.getByRole('radio', { name: 'Подключиться к другу', exact: true })).toBeChecked();
await dialog.getByRole('button', { name: 'Отправить', exact: true }).click();
if (scenario === 'restricted') {
await expect(dialog.getByRole('alert')).toContainText('Совместное прослушивание недоступно');
await expect(dialog.getByRole('alert')).not.toContainText('Чат доступен только друзьям');
await page.screenshot({ path: info.outputPath('request-restricted.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
await expect(dialog).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
await expect(dialog).toHaveCount(0);
if (scenario === 'pending') {
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toBeVisible();
await expect(banner).toHaveCount(0);
await page.getByRole('button', { name: 'Отмена', exact: true }).click();
await expect(page.getByRole('button', { name: 'Отмена', exact: true })).toHaveCount(0);
await expect(banner).toHaveCount(0);
return;
}
}
await expect(banner).toContainText('Слушаете вместе с moderator');
await expect(page.getByRole('contentinfo', { name: 'Плеер', exact: true }).getByRole('button', { name: 'Следующий', exact: true })).toBeDisabled();
await banner.getByRole('button', { name: 'Выйти из прослушивания', exact: true }).click();
await expect(banner).toHaveCount(0);
});
}
+72
View File
@@ -0,0 +1,72 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const { width, height, light } of [{ width: 1280, height: 900, light: false }, { width: 1920, height: 900, light: false }, { width: 1024, height: 600, light: false }, { width: 1280, height: 720, light: true }]) {
test(`mini chat keeps the page and draft at ${width}x${height} ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
await page.setViewportSize({ width, height });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (width === 1920) {
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
await page.getByRole('button', { name: 'Остров', exact: true }).click();
await expect(page.getByRole('button', { name: 'Остров', exact: true })).toHaveAttribute('aria-pressed', 'true');
await page.getByRole('link', { name: 'Главная', exact: true }).first().click();
}
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
const dock = page.getByRole('complementary', { name: 'Друзья и чат', exact: true });
const toggle = dock.getByRole('button', { name: 'Друзья и чат', exact: true });
await toggle.click();
const panel = dock.getByRole('region', { name: 'Друзья и общение', exact: true });
await panel.getByRole('textbox', { name: 'Найти диалог', exact: true }).fill('moderator');
await expect(panel.getByRole('textbox', { name: 'Найти диалог', exact: true })).toHaveCSS('outline-style', 'none');
await panel.getByRole('button', { name: /moderator/ }).click();
const composer = panel.getByRole('textbox', { name: 'Сообщение', exact: true });
await composer.fill('Черновик из мини-чата');
await expect(page).toHaveURL(/\/$/);
await panel.getByRole('button', { name: 'Свернуть чат', exact: true }).click();
await expect(panel).toBeHidden();
await expect(toggle).toBeFocused();
await page.getByRole('link', { name: 'История', exact: true }).first().click();
await toggle.click();
await expect(composer).toHaveValue('Черновик из мини-чата');
await panel.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(panel.getByText('Черновик из мини-чата', { exact: true })).toBeVisible();
await expect(composer).toBeEmpty();
await expect(page).toHaveURL(/\/collection\/history$/);
const panelBox = (await panel.boundingBox())!;
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер', exact: true }).boundingBox())!;
expect(panelBox.width).toBeLessThanOrEqual(600);
expect(panelBox.x).toBeGreaterThanOrEqual(width - 640);
expect(panelBox.y).toBeGreaterThanOrEqual(60);
expect(panelBox.y + panelBox.height).toBeLessThan(playerBox.y);
await panel.evaluate(async element => {
await Promise.all(element.getAnimations({ subtree: true })
.filter(animation => animation.effect?.getTiming().iterations !== Infinity)
.map(animation => animation.finished.catch(() => undefined)));
});
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (selector) => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => ({ target: node.target, summary: node.failureSummary })) }));
}, '[id="mini-social-panel"]');
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('mini-chat.png'), fullPage: true });
await composer.fill('Продолжить в полном чате');
await composer.press('Escape');
await expect(panel).toBeHidden();
await toggle.click();
await expect(composer).toHaveValue('Продолжить в полном чате');
await page.setViewportSize({ width: 800, height: 900 });
await expect(dock).toHaveCount(0);
await page.setViewportSize({ width, height });
await expect(composer).toHaveValue('Продолжить в полном чате');
await panel.getByRole('button', { name: 'Открыть раздел', exact: true }).click();
await expect(page).toHaveURL(/\/friends\?/);
await expect(dock).toHaveCount(0);
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveValue('Продолжить в полном чате');
});
}
+34 -1
View File
@@ -92,6 +92,31 @@ for (const [width, username] of [[320, 'mobile-admin'], [390, 'moderator'], [430
});
}
for (const width of [390, 1280]) {
test(`player artist opens without a browser URL preview at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await login(page, 'alice');
await page.setViewportSize({ width, height: 844 });
const box = page.getByRole('combobox', { name: 'Поиск', exact: true });
await box.fill('кино');
await box.press('Enter');
await page.getByRole('link', { name: /Группа крови/ }).first().click();
await page.getByRole('listitem').first().click();
const artist = page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('link', { name: 'Кино', exact: true });
await artist.hover();
await expect(artist).not.toHaveAttribute('href');
expect(await artist.evaluate((el) => Boolean(el.closest('[title], a[href]')))).toBe(false);
if (width === 390) {
await artist.focus();
await page.keyboard.press('Enter');
} else {
await artist.click();
}
await expect(page).toHaveURL(/\/artist\//);
await expect(page.getByRole('heading', { name: 'Кино', exact: true })).toBeVisible();
});
}
test('mobile player keeps the island above navigation and exposes the queue', async ({ page }, info) => {
await page.setViewportSize({ width: 320, height: 740 });
await login(page, 'alice');
@@ -102,7 +127,15 @@ test('mobile player keeps the island above navigation and exposes the queue', as
await page.getByRole('listitem').first().click();
const player = page.getByRole('contentinfo', { name: 'Плеер' });
await expect(player.getByRole('button', { name: 'Очередь', exact: true })).toBeVisible();
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(40);
expect((await player.getByText('Группа крови', { exact: true }).boundingBox())!.width).toBeGreaterThan(220);
const artist = player.getByRole('link', { name: 'Кино', exact: true });
await expect(artist).toBeVisible();
const artistArea = await artist.locator('..').boundingBox();
expect(artistArea!.width).toBeGreaterThan(220);
const trackInfo = await player.getByText('Группа крови', { exact: true }).boundingBox();
const play = await player.getByRole('button', { name: /^(Пауза|Слушать)$/ }).boundingBox();
expect(trackInfo!.y + trackInfo!.height).toBeLessThanOrEqual(play!.y);
await page.screenshot({ path: info.outputPath('player-readable.png') });
await fits(page);
await page.getByRole('button', { name: 'Меню профиля' }).click();
await page.getByRole('menuitem', { name: 'Настройки', exact: true }).click();
+45
View File
@@ -0,0 +1,45 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const width of [390, 1280]) {
test(`notification history scrolls inside the dialog at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => {
localStorage.setItem('pipu.locale', 'ru');
localStorage.setItem('pipu.e2e.notifications', '1');
});
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await page.getByRole('menuitem', { name: 'Уведомления 4', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Уведомления', exact: true });
const list = dialog.getByRole('region', { name: 'Уведомления', exact: true });
await expect(list.getByRole('article')).toHaveCount(28);
const box = (await dialog.boundingBox())!;
expect(box.height).toBeLessThanOrEqual(680);
expect(box.width).toBeLessThanOrEqual(Math.min(600, width - (width < 768 ? 24 : 32)));
const close = dialog.getByRole('button', { name: 'Закрыть', exact: true });
const closeBefore = (await close.boundingBox())!;
await list.evaluate(element => { element.scrollTop = element.scrollHeight; });
await expect(close).toBeVisible();
expect((await close.boundingBox())!.y).toBe(closeBefore.y);
await list.evaluate(element => { element.scrollTop = 0; });
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async (selector) => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector(selector)!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
}, '[role="dialog"]');
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('notifications.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Непрочитанные 4', exact: true }).click();
await expect(list.getByRole('article')).toHaveCount(4);
await list.getByRole('button', { name: 'Прочитано', exact: true }).first().click();
await expect(list.getByRole('article')).toHaveCount(3);
await dialog.getByRole('button', { name: 'Все', exact: true }).click();
await expect(list.getByRole('article')).toHaveCount(28);
await close.click();
await expect(dialog).toBeHidden();
});
}
+9 -3
View File
@@ -32,9 +32,13 @@ for (const width of [320, 1280]) {
await page.getByRole('button', { name: 'Управлять треками' }).click();
await expect(page.getByRole('button', { name: /Убрать .* из плейлиста/ })).toBeVisible();
await page.getByRole('button', { name: 'Поделиться плейлистом', exact: true }).click();
await expect(page.getByRole('status')).toHaveText('Ссылка на плейлист скопирована');
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
const sharedUrl = await page.evaluate(() => navigator.clipboard.readText());
expect(sharedUrl).toMatch(/^http:\/\/127\.0\.0\.1:4173\/shared\/playlist\/[a-f0-9-]+$/);
const shared = new URL(sharedUrl);
expect(shared.origin).toBe(new URL(page.url()).origin);
expect(shared.pathname).toMatch(/^\/shared\/playlist\/[a-f0-9-]+$/);
await expect(page.getByRole('button', { name: 'Отключить доступ по ссылке', exact: true })).toBeVisible();
await expect.poll(() => page.evaluate(() => {
@@ -45,7 +49,9 @@ for (const width of [320, 1280]) {
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('Ссылка на плейлист скопирована');
await page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true }).click();
await expect(page.getByRole('button', { name: 'Ссылка скопирована', exact: true })).toBeVisible();
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
expect(await page.evaluate(() => navigator.clipboard.readText())).not.toBe(sharedUrl);
});
+221
View File
@@ -0,0 +1,221 @@
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('job log follows new output and pauses while reading older lines', async ({ page }) => {
await login(page);
await page.evaluate(() => {
let lines = 100;
window.addEventListener('test-job-lines', (event) => { lines = (event as CustomEvent<number>).detail; });
const original = window.fetch;
window.fetch = async (input, init) => {
const response = await original(input, init);
const request = new Request(input, init);
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/updates') {
const data = await response.json();
data.job = {
id: 'scroll-job', kind: 'update', state: 'running', target: 'main',
started_at: '2026-10-22T09:00:00Z', finished_at: '',
log: Array.from({ length: lines }, (_, i) => `Build line ${i + 1}`).join('\n'),
};
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
}
return response;
};
history.pushState({}, '', '/admin/updates');
window.dispatchEvent(new PopStateEvent('popstate'));
});
const log = page.getByLabel('Журнал выполнения');
const bottomGap = () => log.evaluate((el) => el.scrollHeight - el.clientHeight - el.scrollTop);
await expect(log).toContainText('Build line 100');
expect(await log.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(true);
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 120 })));
await expect(log).toContainText('Build line 120');
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await log.evaluate((el) => { el.scrollTop = 100; });
await expect.poll(() => log.evaluate((el) => el.scrollTop)).toBe(100);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 140 })));
await expect(log).toContainText('Build line 140');
expect(await log.evaluate((el) => el.scrollTop)).toBe(100);
await log.evaluate((el) => { el.scrollTop = el.scrollHeight; });
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
await page.evaluate(() => window.dispatchEvent(new CustomEvent('test-job-lines', { detail: 160 })));
await expect(log).toContainText('Build line 160');
await expect.poll(bottomGap).toBeLessThanOrEqual(4);
});
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 })).not.toBeVisible();
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 expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
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 })).not.toBeVisible();
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Сохранить', exact: true })).not.toBeVisible();
await expect(page.getByRole('button', { name: 'Отменить правки', exact: true })).not.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('configuration masks every stored secret even if an older API returns plaintext', async ({ page }) => {
await login(page);
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const response = await original(input, init);
const request = new Request(input, init);
if (request.method === 'GET' && new URL(request.url).pathname === '/api/v1/admin/server/config') {
const data = await response.json();
const common = { group: 'api', type: 'string', secret: true, readonly: false };
data.entries = [
{ ...common, key: 'ENV_SECRET', env: 'env-canary', local: 'local-canary', effective: 'env-canary', default_value: 'default-canary', source: 'env' },
{ ...common, key: 'DEFAULT_SECRET', env: null, local: null, effective: 'default-canary', default_value: 'default-canary', source: 'default' },
{ ...common, key: 'READONLY_SECRET', readonly: true, env: 'readonly-canary', local: null, effective: 'readonly-canary', default_value: '', source: 'env' },
];
return new Response(JSON.stringify(data), { headers: { 'Content-Type': 'application/json' } });
}
return response;
};
history.pushState({}, '', '/admin/config');
window.dispatchEvent(new PopStateEvent('popstate'));
});
const env = page.getByLabel('ENV_SECRET · config.local', { exact: true });
const defaults = page.getByLabel('DEFAULT_SECRET · config.local', { exact: true });
await expect(env).toHaveValue('');
await expect(env).toHaveAttribute('type', 'password');
await expect(defaults).toHaveValue('');
await expect(defaults).toHaveAttribute('placeholder', '');
await expect(page.getByText('READONLY_SECRET', { exact: true })).toBeVisible();
for (const canary of ['env-canary', 'local-canary', 'default-canary', 'readonly-canary']) {
expect(await page.getByRole('main').innerHTML()).not.toContain(canary);
}
await page.getByRole('button', { name: 'Показать пароль', exact: true }).first().click();
await expect(env).toHaveValue('');
});
test('a replacement secret is hidden and cleared from the form after saving', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
const token = page.getByLabel('GITEA_TOKEN · config.local', { exact: true });
await token.fill('replacement-canary');
await expect(token).toHaveAttribute('type', 'password');
await page.getByRole('button', { name: 'Сохранить', exact: true }).click();
await expect(page.getByText('Изменения сохранены в config.local', { exact: true })).toBeVisible();
await expect(token).toHaveValue('');
await expect(token).toHaveAttribute('placeholder', 'Секрет сохранён');
expect(await page.getByRole('main').innerHTML()).not.toContain('replacement-canary');
});
test('ordinary user is redirected away from server management', async ({ page }) => {
await login(page, 'alice', '/admin/updates');
await expect(page).toHaveURL('/');
});
test('installed main and release show no install action; another version can be installed', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'main', exact: true }).click();
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.getByRole('button', { name: 'Выбрать версию', exact: true }).click();
await expect(page.getByText('Выберите версию для проверки', { exact: true })).toBeVisible();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.9.0');
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await page.getByLabel('Версия', { exact: true }).selectOption('v1.10.0');
await expect(page.getByText('Доступно обновление', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
});
test('explicit check bypasses cache and hides install action until the check finishes', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/updates');
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).toBeVisible();
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const request = new Request(input, init);
if (request.url.includes('/api/v1/admin/server/updates') && request.url.includes('fresh=true')) {
await new Promise((resolve) => setTimeout(resolve, 1000));
const response = await original(input, init);
const data = await response.json();
return new Response(JSON.stringify({ ...data, update_state: 'current', target_commit: data.current_commit }), { headers: { 'Content-Type': 'application/json' } });
}
return original(input, init);
};
});
await page.getByRole('button', { name: 'Проверить обновления', exact: true }).click();
await expect(page.getByText('Проверяем выбранную версию…', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Установить обновление', exact: true })).not.toBeVisible();
await expect(page.getByText('Эта версия уже установлена — обновлений нет', { exact: true })).toBeVisible();
});
test('saving panel disappears when all edited values return to their original state', async ({ page }) => {
await login(page, 'mobile-admin', '/admin/config');
await expect(page.getByRole('heading', { name: 'Конфигурация', exact: true })).toBeVisible();
const save = page.getByRole('button', { name: 'Сохранить', exact: true });
const apply = page.getByRole('button', { name: 'Применить и перезапустить', exact: true });
await expect(save).not.toBeVisible();
await expect(apply).not.toBeVisible();
const field = page.getByLabel('TRAIN_HOUR · config.local', { exact: true });
await field.fill('6');
await expect(save).toBeVisible();
await field.clear();
await expect(save).not.toBeVisible();
await expect(apply).not.toBeVisible();
await field.fill('7');
await page.getByRole('button', { name: 'Отменить правки', exact: true }).click();
await expect(save).not.toBeVisible();
await expect(field).toHaveValue('');
});
+43
View File
@@ -0,0 +1,43 @@
import axe from 'axe-core';
import { expect, test } from '@playwright/test';
for (const { width, light } of [{ width: 390, light: false }, { width: 1280, light: false }, { width: 1280, light: true }]) {
test(`social profile settings at ${width}px ${light ? 'light' : 'dark'}`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
if (light) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: 'Меню профиля', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Уведомления', exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: 'Моя музыка скрыта', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Приватность и общение', exact: true });
const visibility = dialog.getByRole('group', { name: 'Кто видит мою музыку', exact: true });
const friends = visibility.getByRole('button', { name: 'Друзьям', exact: true });
await friends.click();
await expect(friends).toHaveAttribute('aria-pressed', 'true');
await expect(friends).toBeEnabled();
await expect(friends).toBeFocused();
await dialog.getByText('Слушать вместе', { exact: true }).click();
await expect(dialog.getByRole('group', { name: 'Принимать запросы и приглашения от', exact: true })).toBeVisible();
expect((await dialog.boundingBox())!.width).toBeLessThanOrEqual(width - 24);
await expect.poll(() => dialog.evaluate(element => element.scrollWidth - element.clientWidth)).toBeLessThanOrEqual(1);
await page.addScriptTag({ content: axe.source });
const violations = await page.evaluate(async () => {
const result = await (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[role="dialog"]')!, { runOnly: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } });
return result.violations.map(violation => ({ id: violation.id, nodes: violation.nodes.map(node => node.target) }));
});
expect(violations).toEqual([]);
await page.screenshot({ path: info.outputPath('privacy.png'), fullPage: true });
await dialog.getByRole('button', { name: 'Закрыть', exact: true }).click();
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
const heading = page.getByRole('heading', { name: 'Друзья и общение', exact: true });
await expect(heading).toBeVisible();
expect((await heading.locator('..').boundingBox())!.height).toBeLessThanOrEqual(40);
await expect(page.getByRole('button', { name: 'Моя музыка скрыта', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Уведомления', exact: true })).toHaveCount(0);
});
}
+80
View File
@@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
for (const width of [320, 800, 1280]) {
test(`friends, messages and sharing at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await page.emulateMedia({ reducedMotion: width === 1280 ? 'no-preference' : 'reduce' });
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
if (width === 800) await page.addInitScript(() => Object.defineProperty(navigator, 'share', { configurable: true, value: async (data: ShareData) => { sessionStorage.setItem('shared-url', data.url ?? ''); } }));
await page.goto('/login');
await page.getByLabel('Логин', { exact: true }).fill('alice');
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill('alice-password');
await page.getByRole('button', { name: 'Войти', exact: true }).click();
const openFriends = async () => {
if (width < 768) await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('link', { name: 'Друзья и общение', exact: true }).click();
};
await openFriends();
if (width === 800) await page.evaluate(() => { document.documentElement.dataset.theme = 'light'; });
await page.getByRole('button', { name: /moderator Начать разговор/ }).click();
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByText('Не в сети', { exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: 'Написать', exact: true }).getByRole('button', { name: 'Слушать вместе', exact: true })).toBeDisabled();
await expect(page.getByRole('region', { name: 'Написать', exact: true })).toHaveCSS('animation-name', width === 1280 ? /conversationIn/ : 'none');
if (width === 800) expect((await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!.width).toBeGreaterThan(180);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill('Привет! Нашёл музыку для нашей поездки 🎵');
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByText('Привет! Нашёл музыку для нашей поездки 🎵', { exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeEmpty();
await page.getByRole('button', { name: 'Действия с сообщением', exact: true }).click();
await expect(page.getByRole('menuitem', { name: 'Удалить для всех', exact: true })).toBeVisible();
await page.getByRole('menuitem', { name: '❤️', exact: true }).click();
await expect(page.getByRole('button', { name: '❤️ 1', exact: true })).toBeVisible();
await page.screenshot({ path: info.outputPath('conversation.png'), fullPage: true });
await page.getByRole('combobox', { name: 'Поиск', exact: true }).fill('кино');
await page.getByRole('combobox', { name: 'Поиск', exact: true }).press('Enter');
await page.getByRole('listitem').first().click();
await page.getByRole('contentinfo', { name: 'Плеер' }).getByRole('button', { name: 'Поделиться треком', exact: true }).click();
await expect(page.getByRole('dialog').getByRole('button', { name: 'Скопировать ссылку', exact: true })).toBeVisible();
if (width === 800) {
await page.getByRole('dialog').getByRole('button', { name: 'Отправить в приложение', exact: true }).click();
await expect.poll(() => page.evaluate(() => sessionStorage.getItem('shared-url'))).toContain('/track/');
}
await page.getByRole('dialog').getByRole('button', { name: 'moderator · Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'moderator · Отправлено', exact: true })).toBeDisabled();
await page.screenshot({ path: info.outputPath('share-music.png') });
await page.getByRole('dialog').getByRole('button', { name: 'Закрыть', exact: true }).click();
await openFriends();
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Музыка из переписки', exact: true }).click();
await expect(page.getByRole('link', { name: 'Открыть трек', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toHaveCount(0);
await page.screenshot({ path: info.outputPath('shared-music.png'), fullPage: true });
await page.getByRole('button', { name: 'Переписка', exact: true }).click();
const longMessage = '%20%D0%BC%D1%83%D0%B7%D1%8B%D0%BA%D0%B0'.repeat(45);
await page.getByRole('textbox', { name: 'Сообщение', exact: true }).fill(longMessage);
await page.getByRole('button', { name: 'Отправить', exact: true }).click();
await expect(page.getByRole('button', { name: 'Показать полностью', exact: true })).toBeVisible();
await page.getByRole('button', { name: 'Показать полностью', exact: true }).click();
await expect(page.getByRole('button', { name: 'Свернуть текст', exact: true }).locator('..')).toContainText(longMessage);
await page.getByRole('button', { name: 'Свернуть текст', exact: true }).click();
if (width === 320) {
await page.getByRole('button', { name: 'Назад к сообщениям', exact: true }).click();
await expect(page.getByRole('searchbox', { name: 'Найти диалог', exact: true })).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Сообщение', exact: true })).toBeHidden();
await page.getByRole('button', { name: /moderator/ }).filter({ has: page.locator('strong') }).click();
await page.setViewportSize({ width, height: 560 });
}
await expect.poll(async () => {
const composerBox = (await page.getByRole('textbox', { name: 'Сообщение', exact: true }).boundingBox())!;
const playerBox = (await page.getByRole('contentinfo', { name: 'Плеер' }).boundingBox())!;
return composerBox.y + composerBox.height - playerBox.y;
}).toBeLessThanOrEqual(0);
await expect.poll(() => page.evaluate(() => {
const main = document.querySelector('main')!;
return Math.max(document.documentElement.scrollWidth - innerWidth, main.scrollWidth - main.clientWidth);
})).toBeLessThanOrEqual(1);
await page.getByRole('link', { name: 'Открыть трек', exact: true }).click();
await expect(page.getByRole('heading', { name: 'Группа крови', exact: true })).toBeVisible();
});
}
+48
View File
@@ -0,0 +1,48 @@
import { readFileSync } from 'node:fs';
import { expect, test } from '@playwright/test';
const nginxHeaders = readFileSync(new URL('../../../deploy/nginx/security-headers.conf', import.meta.url), 'utf8');
const desktopConfig = JSON.parse(readFileSync(new URL('../../desktop/src-tauri/tauri.conf.json', import.meta.url), 'utf8'));
const policies: Record<string, string> = {
web: nginxHeaders.match(/add_header Content-Security-Policy "([^"]+)" always;/)![1],
desktop: desktopConfig.app.security.csp,
};
for (const [platform, policy] of Object.entries(policies)) {
test(`upload hash worker runs under ${platform} CSP`, async ({ page }) => {
// Apply the deployed policy to both the document and worker responses.
await page.route('**/*', async (route) => {
if (new URL(route.request().url()).pathname === '/csp-upload-test') {
await route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>Upload CSP</title>', headers: { 'Content-Security-Policy': policy } });
return;
}
if (new URL(route.request().url()).pathname === '/csp-eval-test.js') {
await route.fulfill({ contentType: 'application/javascript', body: `
try { new Function('return 1')(); document.documentElement.dataset.evalBlocked = 'false'; }
catch { document.documentElement.dataset.evalBlocked = 'true'; }
` });
return;
}
const response = await route.fetch();
await route.fulfill({ response, headers: { ...response.headers(), 'Content-Security-Policy': policy } });
});
await page.goto('/csp-upload-test');
const result = await page.evaluate(() => new Promise((resolve, reject) => {
const worker = new Worker('/src/features/uploads/hash.worker.ts', { type: 'module' });
worker.onerror = (event) => { worker.terminate(); reject(new Error(event.message)); };
worker.onmessage = ({ data }) => {
if (data.type === 'progress') return;
worker.terminate();
resolve(data);
};
worker.postMessage({ id: 'csp-test', file: new File(['abc'], 'track.flac') });
}));
expect(result).toEqual({
id: 'csp-test', type: 'done',
sha256: 'ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad',
});
// A page script observes CSP; Playwright's evaluate bypasses the eval restriction.
await page.addScriptTag({ url: '/csp-eval-test.js' });
await expect(page.locator('html')).toHaveAttribute('data-eval-blocked', 'true');
});
}
+70
View File
@@ -0,0 +1,70 @@
import { expect, test, type Page } from '@playwright/test';
async function login(page: Page, username = 'mobile-admin', target = '/admin/users') {
await page.addInitScript(() => localStorage.setItem('pipu.locale', 'ru'));
await page.goto(`/login?redirect=${encodeURIComponent(target)}`);
await page.getByLabel('Логин', { exact: true }).fill(username);
await page.getByRole('textbox', { name: 'Пароль', exact: true }).fill(`${username}-password`);
await page.getByRole('button', { name: 'Войти', exact: true }).click();
await expect(page.getByRole('button', { name: 'Меню профиля' })).toBeVisible();
}
for (const width of [320, 1280]) {
test(`user transfer and cover counts at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 900 });
await login(page);
await page.evaluate(() => {
const original = window.fetch;
window.fetch = async (input, init) => {
const req = new Request(input, init);
const path = new URL(req.url).pathname;
if (path === '/api/v1/admin/users/export') return new Response(JSON.stringify({
version: 1, users: [{ username: 'alice', role: 'user', password_hash: '$argon2id$test' }],
}), { headers: { 'Content-Type': 'application/json' } });
if (path === '/api/v1/admin/users/import') {
const archive = await req.json();
if (archive.users[0].password_hash !== '$argon2id$test') throw new Error('lost hash');
return new Response(JSON.stringify({ created: 1, skipped: ['alice'] }), { headers: { 'Content-Type': 'application/json' } });
}
return original(input, init);
};
});
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Экспорт пользователей', exact: true }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('pipu-users.json');
await page.getByRole('button', { name: 'Импорт пользователей', exact: true }).click();
const dialog = page.getByRole('dialog', { name: 'Импорт пользователей' });
const file = dialog.getByLabel('Файл пользователей (.json)');
await file.setInputFiles({ name: 'bad.json', mimeType: 'application/json', buffer: Buffer.from('{}') });
await expect(dialog.getByRole('alert')).toContainText('Некорректный файл');
await expect(dialog.getByRole('button', { name: 'Импортировать', exact: true })).toBeDisabled();
await file.setInputFiles({ name: 'users.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify({
version: 1, users: [{ username: 'new-user', role: 'moderator', password_hash: '$argon2id$test' }],
})) });
await expect(dialog).toContainText('Учётных записей в файле: 1');
await page.screenshot({ path: info.outputPath('user-import.png') });
await dialog.getByRole('button', { name: 'Импортировать', exact: true }).click();
await expect(dialog.getByRole('status')).toContainText('Создано: 1. Пропущено существующих: 1.');
await expect(dialog.getByRole('status')).toContainText('alice');
await page.keyboard.press('Escape');
if (width < 768) {
await page.getByRole('button', { name: 'Ещё', exact: true }).click();
await page.getByRole('dialog').getByRole('link', { name: 'Хранилища', exact: true }).click();
} else await page.evaluate(() => {
history.pushState({}, '', '/admin/sources');
window.dispatchEvent(new PopStateEvent('popstate'));
});
await expect(page.getByRole('article')).toContainText('Локальные обложки: 87 из 120 файлов');
await expect(page.getByRole('article')).toContainText('Альбомов ожидают обработки: 3');
await page.screenshot({ path: info.outputPath('source-covers.png') });
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth - innerWidth)).toBeLessThanOrEqual(1);
});
}
test('moderator cannot see user transfer controls', async ({ page }) => {
await login(page, 'moderator');
await expect(page.getByRole('heading', { name: 'Пользователи', exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: 'Экспорт пользователей', exact: true })).toHaveCount(0);
await expect(page.getByRole('button', { name: 'Импорт пользователей', exact: true })).toHaveCount(0);
});
+257 -4
View File
@@ -1,4 +1,4 @@
import { AuthSession } from '@pipu/core';
import { AuthSession, refreshListening } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router';
@@ -14,6 +14,256 @@ import { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
describe('social', () => {
it.each([false, true])('closes a join dialog and follows only after admission (automatic=%s)', async (automatic) => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, session } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 1000 });
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: automatic ? 'friends' : 'nobody', autoplay: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
if (!automatic) {
expect(api.listeningMembers.has(alice.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
expect(api.listeningRequests.size).toBe(1);
const request = [...api.listeningRequests.values()][0]!;
const room = [...api.listeningRooms.values()][0]!;
api.listeningMembers.set(alice.id, room.id);
api.listeningRequests.delete(request.id);
refreshListening(session);
}
await screen.findByText('Слушаете вместе с bob');
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument());
});
it('closes a pending invitation without announcing a stream and allows cancellation', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], `/track/${albumTracks[0]!.id}`, { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.listeningPreferences.set(bob.id, { requests: 'friends', auto_accept: 'nobody', autoplay: true });
await router.navigate({ to: '/friends' });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(dialog).not.toBeInTheDocument());
expect(api.listeningMembers.has(bob.id)).toBe(false);
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
await user.click(await screen.findByRole('button', { name: 'Отмена' }));
await waitFor(() => expect(api.listeningRequests.size).toBe(0));
expect(screen.queryByRole('region', { name: 'Слушать вместе' })).not.toBeInTheDocument();
});
it('explains listening restrictions instead of reporting a chat error and can close after rejection', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
api.presence.set(bob.id, Date.now());
api.activityVisibility.set(bob.id, 'friends');
api.activity.set(bob.id, { device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Слушать вместе' }));
const dialog = await screen.findByRole('dialog', { name: 'Слушать вместе' });
await user.click(within(dialog).getByRole('button', { name: 'Отправить' }));
expect(await within(dialog).findByRole('alert')).toHaveTextContent('Совместное прослушивание недоступно');
expect(within(dialog).getByRole('alert')).not.toHaveTextContent('Чат доступен только друзьям');
expect(api.listeningRequests.size).toBe(0);
expect(api.listeningRooms.size).toBe(0);
await user.click(within(dialog).getByRole('button', { name: 'Закрыть' }));
expect(dialog).not.toBeInTheDocument();
});
it('enables listening together only while a friend is online, independently of music privacy', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
const conversation = await screen.findByRole('region', { name: 'Написать' });
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
api.presence.set(bob.id, Date.now());
await queryClient.invalidateQueries({ queryKey: ['social'] });
await within(conversation).findByText('В сети');
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeEnabled();
expect(api.activity.has(bob.id)).toBe(false);
await user.click(within(conversation).getByRole('button', { name: 'Слушать вместе' }));
await screen.findByRole('dialog', { name: 'Слушать вместе' });
api.presence.set(bob.id, Date.now() - 46_000);
await queryClient.invalidateQueries({ queryKey: ['social'] });
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'Слушать вместе' })).not.toBeInTheDocument());
expect(within(conversation).getByRole('button', { name: 'Слушать вместе' })).toBeDisabled();
expect(within(conversation).getByText('Не в сети')).toBeInTheDocument();
});
it('finds music beyond the last text page without acknowledging unread messages', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
for (let seq = 1; seq <= 61; seq++) api.messages.push({ id: crypto.randomUUID(), seq, sender_id: bob.id, recipient: alice.id, text: seq === 1 ? albumTracks[0]!.title : `Сообщение ${seq}`, attachment_kind: seq === 1 ? 'track' : 'none', ...(seq === 1 ? { attachment_id: albumTracks[0]!.id } : {}), created_at: new Date().toISOString(), read: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Музыка из переписки' }));
await screen.findByRole('link', { name: 'Открыть трек' });
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
const firstCall = api.calls.length;
api.messages.push({ id: crypto.randomUUID(), seq: 62, sender_id: bob.id, recipient: alice.id, text: 'Новая музыка', attachment_kind: 'track', attachment_id: albumTracks[0]!.id, created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByText('Новая музыка');
expect(api.messages.at(-1)?.read).toBe(false);
expect(api.calls.slice(firstCall).some(c => c.path.endsWith('/actions') && (c.body as { action?: string })?.action === 'read_chat')).toBe(false);
});
it('keeps the current conversation when another friend sends newer messages and confirms blocking', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const carol = makeUser('carol', 'user');
const { api, user, queryClient } = await renderApp([alice, bob, carol], '/friends', { signedInAs: alice });
for (const friend of [bob, carol]) api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByRole('heading', { name: 'bob' });
await user.type(screen.getByRole('textbox', { name: 'Сообщение' }), 'Черновик для bob');
api.messages.push({ id: crypto.randomUUID(), seq: 1, sender_id: carol.id, recipient: alice.id, text: 'Новое сообщение от carol', attachment_kind: 'none', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await screen.findByText('Новое сообщение от carol');
expect(screen.getByRole('heading', { name: 'bob' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Сообщение' })).toHaveValue('Черновик для bob');
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
const confirm = await screen.findByRole('dialog', { name: 'Заблокировать bob?' });
expect(api.friendships.size).toBe(2);
await user.click(within(confirm).getByRole('button', { name: 'Отмена' }));
expect(api.friendships.size).toBe(2);
await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' }));
await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' }));
await user.click(within(await screen.findByRole('dialog', { name: 'Заблокировать bob?' })).getByRole('button', { name: 'Заблокировать' }));
await waitFor(() => expect(api.friendships.size).toBe(1));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
});
it('follows a room with locked playback controls, independent volume and local exit', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
const id = crypto.randomUUID();
api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] });
api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id);
refreshListening(session);
await screen.findByText('Слушаете вместе с bob');
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled());
expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled();
await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' }));
await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument());
expect(api.listeningMembers.has(alice.id)).toBe(false);
});
it('edits, replies, reacts, searches, deletes and preserves a draft between sections', async () => {
const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user');
const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: /bob Начать разговор/ }));
const composer = () => screen.getByRole('textbox', { name: 'Сообщение' });
await user.type(composer(), 'Первый вариант');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await screen.findByText('Первый вариант');
const actions = async (name: string) => {
await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!);
await user.click(await screen.findByRole('menuitem', { name }));
};
await actions('Редактировать');
await user.clear(composer()); await user.type(composer(), 'Исправленный текст');
await user.click(screen.getByRole('button', { name: 'Сохранить' }));
await screen.findByText('Исправленный текст', { exact: true });
await actions('❤️');
await screen.findByRole('button', { name: '❤️ 1' });
await actions('Ответить');
await user.type(composer(), 'Ответ на музыку');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
await waitFor(() => expect(api.messages).toHaveLength(2));
expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id);
await user.click(screen.getByRole('button', { name: 'Поиск в переписке' }));
await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный');
await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument());
await actions('Удалить для всех');
await waitFor(() => expect(api.messages[0]?.deleted).toBe(true));
await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' }));
await user.type(composer(), 'Мой черновик');
await user.click(screen.getByRole('button', { name: 'Друзья' }));
expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Написать' }));
expect(composer()).toHaveValue('Мой черновик');
});
it('saves privacy and independent request admission preferences', async () => {
const alice = makeUser('alice', 'user');
const { api, user } = await renderApp([alice], '/', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Моя музыка скрыта' }));
const privacy = await screen.findByRole('group', { name: 'Кто видит мою музыку' });
expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true');
await user.click(within(privacy).getByRole('button', { name: 'Друзьям' }));
await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends'));
await user.click(screen.getByText('Слушать вместе', { exact: true }));
const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' });
const auto = screen.getByRole('group', { name: 'Автоматически принимать от' });
await user.click(within(requests).getByRole('button', { name: 'Друзьям' }));
await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends'));
await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled());
await user.click(within(auto).getByRole('button', { name: 'Всем' }));
await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' }));
});
it('shows persistent notifications and acknowledges only the chosen event', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user');
const { api, user, queryClient, router } = await renderApp([alice, bob], '/', { signedInAs: alice });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(await screen.findByRole('menuitem', { name: 'Уведомления 1' }));
await screen.findByText('Хочет добавить вас в друзья');
await user.click(screen.getByRole('button', { name: 'Прочитано' }));
await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument());
expect(api.notifications[0]?.read).toBe(true);
await user.click(screen.getByRole('button', { name: 'Открыть: bob' }));
await screen.findByRole('button', { name: 'Принять' });
expect(router.state.location.pathname).toBe('/friends');
expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id });
});
it('searches, requests, cancels, accepts and exchanges messages and music', async () => {
const alice = makeUser('alice', 'user');
const bob = makeUser('bob', 'user', { display_name: 'Борис' });
const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice });
await user.click((await screen.findAllByRole('button', { name: 'Найти друзей' }))[0]!);
await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob');
await user.click(await screen.findByRole('button', { name: 'В друзья' }));
await screen.findByRole('button', { name: 'Отменить заявку' });
await user.click(screen.getByRole('button', { name: 'Отменить заявку' }));
await screen.findByRole('button', { name: 'В друзья' });
api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false });
await queryClient.invalidateQueries({ queryKey: ['social'] });
await user.click(await screen.findByRole('button', { name: 'Принять' }));
await user.click(await screen.findByRole('button', { name: 'Написать' }));
await user.type(await screen.findByLabelText('Сообщение', { exact: true }), 'Привет, послушай это');
await user.click(screen.getByRole('button', { name: 'Отправить' }));
expect(await screen.findByText('Привет, послушай это')).toBeInTheDocument();
expect(api.messages).toHaveLength(1);
await router.navigate({ to: '/track/$id', params: { id: albumTracks[0]!.id } });
await user.click(await screen.findByRole('button', { name: 'Слушать' }));
const player = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(player).getByRole('button', { name: 'Поделиться треком' }));
await user.click(await screen.findByRole('button', { name: 'Борис · Отправить' }));
await screen.findByRole('button', { name: 'Борис · Отправлено' });
expect(api.messages.at(-1)).toMatchObject({ attachment_kind: 'track', attachment_id: albumTracks[0]!.id, recipient: bob.id });
expect(screen.getByRole('button', { name: 'Борис · Отправлено' })).toBeDisabled();
});
});
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
const api = fakeApi(users);
const i18n = await createI18n('ru');
@@ -27,7 +277,7 @@ async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: Fak
<App session={session} router={router} queryClient={queryClient} />
</I18nextProvider>,
);
return { api, router, session, i18n, user: userEvent.setup() };
return { api, router, session, i18n, queryClient, user: userEvent.setup() };
}
describe('sign-in', () => {
@@ -294,7 +544,8 @@ describe('player', () => {
await user.click(screen.getByRole('button', { name: 'Слушать' }));
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' }));
expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована');
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`);
});
@@ -517,7 +768,9 @@ describe('playlist sharing', () => {
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('Ссылка на плейлист скопирована');
await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' }));
await screen.findByRole('button', { name: 'Ссылка скопирована' });
await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Закрыть' }));
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: 'Поделиться плейлистом' }));
+18 -1
View File
@@ -9,6 +9,8 @@ import {
} from '@tanstack/react-router';
import { lazy } from 'react';
const ServerUpdatesPage = lazy(() => import('../features/server-management/UpdatesPage').then((m) => ({ default: m.UpdatesPage })));
const ServerConfigPage = lazy(() => import('../features/server-management/ConfigPage').then((m) => ({ default: m.ConfigPage })));
const AuditPage = lazy(() => import('../pages/admin/AuditPage').then((m) => ({ default: m.AuditPage })));
const LibraryPage = lazy(() => import('../pages/admin/LibraryPage').then((m) => ({ default: m.LibraryPage })));
const PatternsPage = lazy(() => import('../pages/admin/PatternsPage').then((m) => ({ default: m.PatternsPage })));
@@ -24,7 +26,7 @@ import { ConnectPage } from '../pages/ConnectPage';
import { TrackPage } from '../pages/TrackPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { ActivityPlaylistPage, PlaylistPage, PlaylistsPage } from '../pages/PlaylistsPage';
import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage';
@@ -34,6 +36,7 @@ import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
import { DesktopDownloadsPage } from '../features/desktop/DesktopFeatures';
import { SocialPage } from '../features/social/Social';
export interface RouterContext {
session: AuthSession;
@@ -145,6 +148,8 @@ const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const activityPlaylistRoute = createRoute({ getParentRoute: () => appRoute, path: '/activity/playlist/$userId', component: () => <ActivityPlaylistPage key={activityPlaylistRoute.useParams().userId} userId={activityPlaylistRoute.useParams().userId} /> });
const socialRoute = createRoute({ getParentRoute: () => appRoute, path: '/friends', validateSearch: (s: Record<string, unknown>): { peer?: string; tab?: 'messages' | 'people' | 'requests' } => ({ ...(typeof s.peer === 'string' ? { peer: s.peer } : {}), ...(s.tab === 'messages' || s.tab === 'people' || s.tab === 'requests' ? { tab: s.tab } : {}) }), component: () => { const search = socialRoute.useSearch(); return <SocialPage peer={search.peer} initialTab={search.tab} />; } });
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 });
@@ -274,6 +279,14 @@ const adminSettingsRoute = createRoute({
component: AdminSettingsPage,
});
const serverUpdatesRoute = createRoute({
getParentRoute: () => appRoute, path: '/admin/updates', component: ServerUpdatesPage,
beforeLoad: ({ context }) => {
if (context.user.role !== 'admin' && !context.user.manages_updates) throw redirect({ to: '/' });
},
});
const serverConfigRoute = createRoute({ getParentRoute: () => appRoute, path: '/admin/config', beforeLoad: adminOnly, component: ServerConfigPage });
const routeTree = rootRoute.addChildren([
downloadAppRoute,
androidAppRoute,
@@ -283,6 +296,8 @@ const routeTree = rootRoute.addChildren([
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
activityPlaylistRoute,
socialRoute,
trackRoute,
playlistsRoute,
downloadsRoute,
@@ -304,6 +319,8 @@ const routeTree = rootRoute.addChildren([
myUploadsRoute,
uploadQueueRoute,
adminSettingsRoute,
serverUpdatesRoute,
serverConfigRoute,
]),
]);
+8 -4
View File
@@ -1,6 +1,6 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { Link } from '@tanstack/react-router';
import { Link, useNavigate } from '@tanstack/react-router';
import { useVirtualizer } from '@tanstack/react-virtual';
import { Fragment, useRef, useState, type CSSProperties, type MouseEvent, type ReactNode } from 'react';
import { AddToPlaylist } from './playlists';
@@ -71,15 +71,19 @@ export function Cover({
}
/** Исполнители через запятую, каждый — ссылка. */
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
export function ArtistLinks({ artists, hideUrlPreview = false }: { artists: ArtistRef[]; hideUrlPreview?: boolean }) {
const navigate = useNavigate();
return (
<>
{artists.map((a, i) => (
<Fragment key={a.id}>
{i > 0 && ', '}
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{hideUrlPreview ? <button type="button" role="link" className={s.link}
onClick={() => void navigate({ to: '/artist/$id', params: { id: a.id } })}>
{a.name}
</Link>
</button> : <Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{a.name}
</Link>}
</Fragment>
))}
</>
@@ -0,0 +1,95 @@
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) => {
const entry = query.data!.entries.find((v) => v.key === key)!;
const unchanged = value === null ? entry.local === null
: entry.secret ? value === '' : value === (entry.local ?? '');
const next = { ...draft };
if (unchanged) delete next[key]; else next[key] = value;
setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null);
setDraft(next); 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 ?? '';
const display = (stored: string | null) => entry.secret && stored ? '••••••••' : stored;
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>{display(entry.effective) || '—'}</code></span><span>ENV: <code>{display(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'} autoComplete={entry.secret ? 'new-password' : 'off'} 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>}
{saved && !dirty && <p className={s.note} role="status">{t('management.saved')}</p>}
{dirty && <section className={s.saveBar} aria-label={t('management.unsaved_panel')}>
<div role="status">{t('management.unsaved', { count: Object.keys(draft).length })}</div>
<div className={s.actions}><Button disabled={save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button></div>
</section>}
{!dirty && query.data.restart_required && !running && <section className={s.saveBar} aria-label={t('management.pending_restart')}>
<div role="status">{t('management.pending_restart')}</div>
<Button busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button>
</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,33 @@
import type { components } from '@pipu/api-client';
import { useLayoutEffect, useRef } from 'react';
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');
const logRef = useRef<HTMLPreElement>(null);
const followLog = useRef(true);
useLayoutEffect(() => {
followLog.current = true;
}, [job?.id]);
useLayoutEffect(() => {
const log = logRef.current;
if (log && followLog.current) log.scrollTop = log.scrollHeight;
}, [job?.id, job?.log]);
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 ref={logRef} className={s.log} tabIndex={0} aria-label={t('management.log')} onScroll={(event) => {
const log = event.currentTarget;
followLog.current = log.scrollHeight - log.clientHeight - log.scrollTop <= 4;
}}>{job.log || t('management.waiting')}</pre>
</section>;
}
@@ -0,0 +1,109 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useRef, 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 fresh = useRef(false);
const queryKey = ['server-updates', sourceId, channel, version];
const query = useQuery({
queryKey,
queryFn: async () => {
const force = fresh.current;
fresh.current = false;
return unwrap(await session.client.GET('/api/v1/admin/server/updates', {
params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } },
}));
},
placeholderData: keepPreviousData,
refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000,
retry: false,
});
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
const running = query.data?.job?.state === 'running';
const checking = query.isFetching;
const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown';
const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !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, expected_commit: query.data!.target_commit },
})),
onSuccess: (job) => {
setConfirm(false);
qc.setQueryData<components['schemas']['ServerUpdates']>(queryKey, (old) => old ? { ...old, job } : old);
void refresh();
},
onError: () => { 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={() => { fresh.current = true; void query.refetch(); }} busy={query.isFetching}>{t('management.check_updates')}</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.updateAction}>
<div role="status" aria-live="polite">
<strong>{t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)}</strong>
{query.data.target_version && <p className={s.note}>{query.data.target_version} · {query.data.target_commit.slice(0, 12)}</p>}
</div>
{canInstall && <Button variant="primary" disabled={!source || !!query.data.source_error} 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: query.data?.target_version || version })}</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" disabled={!canInstall} 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,36 @@
.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; } }
.updateAction { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 16px; padding-top: 16px; border-top: 1px solid var(--line); font-size: 14px; }
@@ -0,0 +1,132 @@
import { CHAT_REACTIONS, chatDraftKey, uuidv7, type ChatMessageUpdate, type DirectMessage, type SocialPerson, type socialApi } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useDesktop } from '../../desktop';
import { useErrorText } from '../../shared/lib/errors';
import { arrowBackIcon, arrowDownIcon, arrowUpIcon, chatIcon, closeIcon, moreIcon, musicIcon, searchIcon, smileIcon } from '../../shared/ui/icons';
import { Button, EmptyState, Field, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
import { ListenTogetherButton } from './Listening';
import { ChatMusic, MessageText } from './ChatMusic';
import { Presence } from './Presence';
import s from './social.module.css';
export function ChatConversation({ person, api, queryKey, userId, active = true, onBack, actions }: {
person: SocialPerson; api: ReturnType<typeof socialApi>; queryKey: readonly unknown[]; userId: string; active?: boolean; onBack?: () => void; actions?: ReactNode;
}) {
const { t, i18n } = useTranslation();
const cache = useQueryClient();
const errorText = useErrorText();
const server = useDesktop()?.server ?? window.location.origin;
const draftKey = chatDraftKey(server, userId, person.id);
const [text, setText] = useState(() => { try { return localStorage.getItem(draftKey) ?? ''; } catch { return ''; } });
const [reply, setReply] = useState<DirectMessage | null>(null);
const [editing, setEditing] = useState<DirectMessage | null>(null);
const [view, setView] = useState<'conversation' | 'music'>('conversation');
const [searchOpen, setSearchOpen] = useState(false);
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [emojis, setEmojis] = useState(false);
const [atBottom, setAtBottom] = useState(true);
const beforeEdit = useRef('');
const pending = useRef<{ id: string; text: string; reply_to?: string } | null>(null);
const typedAt = useRef(0);
const composer = useRef<HTMLTextAreaElement>(null);
const scroll = useRef<HTMLDivElement>(null);
const previousLast = useRef<number | undefined>(undefined);
const [visible, setVisible] = useState(document.visibilityState === 'visible');
const music = view === 'music';
useEffect(() => { const change = () => setVisible(document.visibilityState === 'visible'); document.addEventListener('visibilitychange', change); return () => document.removeEventListener('visibilitychange', change); }, []);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 250); return () => clearTimeout(timer); }, [search]);
useEffect(() => {
if (editing) return;
try { if (text) localStorage.setItem(draftKey, text); else localStorage.removeItem(draftKey); } catch { /* Storage may be unavailable in private mode. */ }
}, [text, draftKey, editing]);
useEffect(() => {
const timer = setTimeout(() => { void api.typing(person.id, active && visible && !music && !!text.trim() && !editing && Date.now() - typedAt.current < 6000).catch(() => undefined); }, 400);
return () => clearTimeout(timer);
}, [text, active, visible, editing, music, person.id, api]);
useEffect(() => () => { void api.typing(person.id, false).catch(() => undefined); }, [api, person.id]);
useEffect(() => { if (composer.current) { composer.current.style.height = 'auto'; composer.current.style.height = `${Math.min(composer.current.scrollHeight, 144)}px`; } }, [text, music, active]);
const meta = useQuery({ queryKey: [...queryKey, 'typing', person.id], queryFn: () => api.chatMeta(person.id), enabled: active && visible && !music, refetchInterval: active && visible && !music ? 2000 : false });
const messages = useInfiniteQuery({ queryKey: [...queryKey, 'chat', person.id, q, music], initialPageParam: undefined as number | undefined,
queryFn: ({ pageParam }) => api.messages(person.id, pageParam, q, music), getNextPageParam: last => last.length === 50 ? last[0]?.seq : undefined,
enabled: active && visible, refetchInterval: active && visible ? 3000 : false });
const list = [...(messages.data?.pages ?? [])].reverse().flat();
const last = list.at(-1)?.seq;
const firstUnread = list.find(m => m.sender_id !== userId && !m.read)?.id;
useEffect(() => {
const el = scroll.current;
if (el && active && !q && !music && (atBottom || previousLast.current === undefined)) el.scrollTop = el.scrollHeight;
previousLast.current = last;
}, [last, active, q, music]);
useEffect(() => {
const el = scroll.current;
const latestVisible = el && el.scrollHeight - el.scrollTop - el.clientHeight < 64;
if (last && firstUnread && latestVisible && visible && active && !q && !music && atBottom) void api.action({ action: 'read_chat', target_id: person.id, up_to_seq: last }).then(() => cache.invalidateQueries({ queryKey })).catch(() => undefined);
}, [last, firstUnread, visible, active, q, music, atBottom, api, person.id, cache, messages.dataUpdatedAt]);
const refresh = () => cache.invalidateQueries({ queryKey });
const update = useMutation({ mutationFn: (input: { id: string; body: ChatMessageUpdate }) => api.updateMessage(person.id, input.id, input.body), onSuccess: refresh });
const send = useMutation({ mutationFn: async () => {
if (editing) { await api.updateMessage(person.id, editing.id, { action: 'edit', text }); return; }
if (!pending.current || pending.current.text !== text || pending.current.reply_to !== reply?.id) pending.current = { id: uuidv7(), text, ...(reply ? { reply_to: reply.id } : {}) };
await api.send(person.id, { ...pending.current, attachment_kind: 'none' });
}, onSuccess: async () => { setText(editing ? beforeEdit.current : ''); setEditing(null); setReply(null); pending.current = null; setAtBottom(true); await refresh(); if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; composer.current?.focus(); } });
const cancelEdit = () => { setText(beforeEdit.current); setEditing(null); };
const mutate = (id: string, body: ChatMessageUpdate) => { if (!update.isPending) update.mutate({ id, body }); };
const changeView = (next: typeof view) => { setView(next); setSearch(''); setQ(''); setSearchOpen(false); previousLast.current = undefined; setAtBottom(true); };
const older = async () => {
const el = scroll.current;
const height = el?.scrollHeight ?? 0;
const top = el?.scrollTop ?? 0;
await messages.fetchNextPage();
requestAnimationFrame(() => { if (el) el.scrollTop = top + el.scrollHeight - height; });
};
return <section className={s.chat} aria-label={t('social.chat')}>
<header className={s.chatHeader}>
{onBack && <Button className={`${s.iconButton} ${s.backButton}`} variant="ghost" icon={arrowBackIcon} aria-label={t('social.back_to_messages')} onClick={onBack} />}
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
<div className={s.chatIdentity}><h2>{person.display_name}</h2><Presence online={person.online} /></div>
<div className={s.chatTools}><ListenTogetherButton person={person} /><Button variant="ghost" className={s.iconButton} icon={searchIcon} aria-label={t('social.chat_search')} aria-expanded={searchOpen} onClick={() => { setSearchOpen(v => !v); setSearch(''); setQ(''); }} />{actions}</div>
</header>
{person.activity && <div className={s.nowListening}><Icon src={musicIcon} size={18} /><div><span>{t('social.listening')}</span><Link to="/track/$id" params={{ id: person.activity.track_id }}>{person.activity.track_title}{person.activity.artist ? ` · ${person.activity.artist}` : ''}</Link>{person.activity.context === 'playlist' && person.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: person.id }}>{t('social.from_playlist', { name: person.activity.playlist_name ?? '' })}</Link>}</div></div>}
<nav className={s.chatTabs} aria-label={t('social.conversation_content')}><button type="button" aria-pressed={!music} onClick={() => changeView('conversation')}><Icon src={chatIcon} size={16} />{t('social.conversation')}</button><button type="button" aria-pressed={music} onClick={() => changeView('music')}><Icon src={musicIcon} size={16} />{t('social.shared_music')}</button></nav>
{searchOpen && <div className={s.chatSearch}><Field label={t('social.chat_search')} value={search} onChange={setSearch} type="search" maxLength={100} autoFocus /><Button variant="ghost" icon={closeIcon} className={s.iconButton} aria-label={t('social.close_search')} onClick={() => { setSearchOpen(false); setSearch(''); setQ(''); }} /></div>}
<FormError>{messages.error || send.error || update.error ? errorText(messages.error ?? send.error ?? update.error) : null}</FormError>
<div ref={scroll} className={`${s.messages} ${music ? s.musicMessages : ''}`} onScroll={e => { const el = e.currentTarget; setAtBottom(el.scrollHeight - el.scrollTop - el.clientHeight < 64); }}>
{messages.hasNextPage && <Button className={s.older} busy={messages.isFetchingNextPage} onClick={() => void older()}>{t('social.older')}</Button>}
{messages.isPending && <div className={s.chatSkeleton} role="status" aria-label={t('social.loading')}><Skeleton style={{ height: 70, width: '70%' }} /><Skeleton style={{ height: 46, width: '55%', alignSelf: 'end' }} /><Skeleton style={{ height: 80, width: '65%' }} /></div>}
{!list.length && messages.isSuccess && <EmptyState icon={music ? musicIcon : chatIcon}>{t(q ? 'social.chat_no_results' : music ? 'social.no_shared_music' : 'social.first_message')}</EmptyState>}
{list.map((m, i) => <Fragment key={m.id}>
{(i === 0 || new Date(list[i - 1]!.created_at).toDateString() !== new Date(m.created_at).toDateString()) && <time className={s.date}>{new Date(m.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'long', year: 'numeric' })}</time>}
{m.id === firstUnread && !q && !music && <div className={s.newMessages}>{t('social.new_messages')}</div>}
<article id={`message-${m.id}`} className={`${s.bubble} ${m.sender_id === userId ? s.mine : ''} ${m.deleted ? s.deleted : ''} ${music ? s.mediaBubble : ''}`}>
{m.reply_to && !music && <blockquote className={s.quote}>{m.reply_preview || t('social.message_deleted')}</blockquote>}
{(m.deleted || m.text && m.attachment_kind === 'none') && <div className={s.messageText}>{m.deleted ? t('social.message_deleted') : <MessageText text={m.text} />}</div>}
{!m.deleted && m.attachment_id && m.attachment_kind !== 'none' && <ChatMusic message={m} scope={queryKey} />}
{!m.deleted && !!m.reactions?.length && <div className={s.reactions}>{m.reactions.map(r => <button key={r.emoji} type="button" aria-pressed={r.mine} disabled={update.isPending} onClick={() => mutate(m.id, { action: 'react', emoji: r.emoji as typeof CHAT_REACTIONS[number] })}>{r.emoji} {r.count}</button>)}</div>}
<div className={s.messageFoot}><small className={s.muted}>{new Date(m.created_at).toLocaleTimeString(i18n.language, { hour: '2-digit', minute: '2-digit' })}{m.edited_at && !m.deleted ? ` · ${t('social.edited')}` : ''}{m.sender_id === userId ? ` · ${t(m.read ? 'social.read' : 'social.sent')}` : ''}</small>
{!m.deleted && <Menu portal label={t('social.message_actions')} trigger={<Icon src={moreIcon} size={18} />} items={[
{ label: t('social.reply'), onSelect: () => { changeView('conversation'); if (editing) cancelEdit(); setReply(m); requestAnimationFrame(() => composer.current?.focus()); } },
...CHAT_REACTIONS.map(emoji => ({ label: emoji, onSelect: () => mutate(m.id, { action: 'react', emoji }) })),
...(m.sender_id === userId ? [{ label: t('social.edit_message'), onSelect: () => { changeView('conversation'); beforeEdit.current = text; setEditing(m); setReply(null); setText(m.text); requestAnimationFrame(() => composer.current?.focus()); } }, { label: t('social.delete_message'), danger: true, onSelect: () => mutate(m.id, { action: 'delete' }) }] : []),
]} />}
</div>
</article>
</Fragment>)}
{send.isPending && !!text.trim() && !editing && !music && !list.some(m => m.id === pending.current?.id) && <article className={`${s.bubble} ${s.mine} ${s.sending}`} role="status">{text} · …</article>}
</div>
{!atBottom && !music && <Button className={s.jumpToBottom} icon={arrowDownIcon} onClick={() => { if (scroll.current) scroll.current.scrollTop = scroll.current.scrollHeight; setAtBottom(true); }}>{t('social.latest_messages')}</Button>}
{!music && <>
<div className={s.typing} role="status" aria-live="polite">{active && meta.data?.typing ? <><span className={s.typingDots} aria-hidden><i /><i /><i /></span>{person.display_name} {t('social.typing')}</> : null}</div>
{(reply || editing) && <div className={s.replyBar}><div><small>{t(editing ? 'social.editing' : 'social.replying')}</small><p>{(editing ?? reply)?.text}</p></div><Button variant="ghost" icon={closeIcon} className={s.iconButton} onClick={() => editing ? cancelEdit() : setReply(null)} aria-label={t(editing ? 'social.cancel_edit' : 'social.cancel_reply')} /></div>}
{emojis && <div className={s.emojiPicker}>{CHAT_REACTIONS.map(emoji => <button key={emoji} type="button" aria-label={emoji} onClick={() => { setText(v => (v + emoji).slice(0, 4000)); setEmojis(false); composer.current?.focus(); }}>{emoji}</button>)}</div>}
<form className={s.composer} onSubmit={e => { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); }}>
<Button variant="ghost" icon={smileIcon} className={s.iconButton} aria-label={t('social.emoji')} aria-expanded={emojis} onClick={() => setEmojis(v => !v)} />
<textarea ref={composer} aria-label={t('social.message')} placeholder={t('social.message_placeholder')} maxLength={4000} rows={1} value={text} disabled={send.isPending} onChange={e => { typedAt.current = Date.now(); setText(e.target.value); }} onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); if (text.trim() && !send.isPending) send.mutate(); } }} />
<Button type="submit" variant="primary" className={s.iconButton} icon={arrowUpIcon} aria-label={t(editing ? 'playlists.save' : 'social.send')} busy={send.isPending} disabled={!text.trim()} />
</form>
</>}
</section>;
}
@@ -0,0 +1,33 @@
import { createI18n } from '@pipu/i18n';
import { cleanup, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest';
import { MessageText } from './ChatMusic';
afterEach(cleanup);
describe('message text', () => {
it('compacts a long URL while preserving its full destination when text is collapsed', async () => {
const i18n = await createI18n('ru');
const url = `https://example.com/music/${'long-path-'.repeat(90)}?share=abc`;
render(<I18nextProvider i18n={i18n}><MessageText text={`Послушай ${url}`} /></I18nextProvider>);
expect(screen.getByRole('link')).toHaveAttribute('href', url);
expect(screen.getByRole('link').textContent!.length).toBeLessThan(65);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'Показать полностью' }));
expect(screen.getByRole('link')).toHaveAttribute('href', url);
expect(screen.getByRole('button', { name: 'Свернуть текст' })).toHaveAttribute('aria-expanded', 'true');
});
it('keeps encoded text and markup literal, with expansion for long messages', async () => {
const i18n = await createI18n('ru');
const text = `<script>alert(1)</script> ${'%D0%BC%20'.repeat(100)}`;
const { container } = render(<I18nextProvider i18n={i18n}><MessageText text={text} /></I18nextProvider>);
expect(container.querySelector('script')).toBeNull();
expect(container.textContent).toContain('%D0%BC%20');
expect(screen.queryByRole('link')).not.toBeInTheDocument();
await userEvent.setup().click(screen.getByRole('button', { name: 'Показать полностью' }));
expect(container.textContent).toContain(text);
});
});
@@ -0,0 +1,63 @@
import { unwrap, type DirectMessage } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { useSession } from '../../app/session';
import { Cover } from '../../entities/catalog';
import { usePlayer, usePlayerState } from '../../player/context';
import { formatDuration } from '../../shared/lib/format';
import { musicIcon, pauseIcon, playIcon, queueIcon } from '../../shared/ui/icons';
import { Button, Icon } from '../../shared/ui/ui';
import s from './social.module.css';
/** Catalog reads stay in the client adapter; message text is never decoded into metadata. */
export function ChatMusic({ message, scope }: { message: DirectMessage; scope: readonly unknown[] }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const state = usePlayerState();
const id = message.attachment_id!;
const track = useQuery({ queryKey: [...scope, 'attachment', 'track', id], staleTime: 60_000, retry: false,
enabled: message.attachment_kind === 'track', queryFn: async () => unwrap(await session.client.GET('/api/v1/tracks/{id}', { params: { path: { id } } })) });
const playlist = useQuery({ queryKey: [...scope, 'attachment', 'playlist', id], retry: false,
enabled: message.attachment_kind === 'playlist', queryFn: async () => unwrap(await session.client.GET('/api/v1/shared/playlists/{shareId}', { params: { path: { shareId: id } } })) });
const isTrack = message.attachment_kind === 'track';
const query = isTrack ? track : playlist;
const tr = !track.isError ? track.data : undefined;
const pl = !playlist.isError ? playlist.data : undefined;
const metadataTitle = isTrack ? tr?.title : pl?.name;
const title = metadataTitle || message.text || t(isTrack ? 'social.shared_track' : 'social.shared_playlist');
const album = isTrack ? tr?.album : pl?.tracks[0]?.album;
const current = !!tr && tr.id === state.track?.id;
const available = !query.isError && (isTrack ? tr?.available : pl?.tracks.some(x => x.available));
const subtitle = query.isError ? t('social.attachment_unavailable') : isTrack ? tr ? `${tr.artists.map(a => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}` : t('social.shared_track') : t('social.shared_playlist');
return <><div className={`${s.musicCard} ${query.isError ? s.musicUnavailable : ''}`}>
{album ? <Cover album={album} size={48} /> : <span className={s.musicCover}><Icon src={isTrack ? musicIcon : queueIcon} size={24} /></span>}
<div className={s.musicInfo}>
<Link to={isTrack ? '/track/$id' : '/shared/playlist/$shareId'} params={{ id, shareId: id }} aria-label={t(isTrack ? 'social.listen_track' : 'social.listen_playlist')} title={title}>{title}</Link>
<span>{subtitle}</span>
</div>
<Button variant="ghost" className={s.iconButton} icon={current && state.playing ? pauseIcon : playIcon} aria-label={t(current && state.playing ? 'player.pause' : 'catalog.play')} disabled={!available || state.listening?.locked} onClick={() => {
if (isTrack && tr) { if (current) player.toggle(); else player.playList([tr], 0, { type: 'queue' }); }
else if (pl) player.playList(pl.tracks.filter(x => x.available), 0, { type: 'queue' });
}} />
</div>{message.text && message.text.trim() !== metadataTitle?.trim() && <span className={s.attachmentCaption}><MessageText text={message.text} /></span>}</>;
}
/** Compact only actual HTTP(S) URLs. Arbitrary encoded text remains literal. */
export function MessageText({ text }: { text: string }) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const long = text.length > 600;
const shown = long && !expanded ? `${text.slice(0, 400)}…` : text;
const originalParts = text.split(/(https?:\/\/[^\s<>]+)/g);
return <>{shown.split(/(https?:\/\/[^\s<>]+)/g).map((part, i) => {
if (!/^https?:\/\//.test(part)) return part;
try {
const original = originalParts[i] ?? part;
const url = new URL(original);
return <a key={i} href={url.href} target="_blank" rel="noopener noreferrer" title={original}>{url.hostname}{url.pathname === '/' ? '' : `${url.pathname.slice(0, 34)}${url.pathname.length > 34 || url.search ? '…' : ''}`}</a>;
} catch { return part; }
})}{long && <button type="button" className={s.expandText} aria-expanded={expanded} onClick={() => setExpanded(v => !v)}>{t(expanded ? 'social.show_less' : 'social.show_more')}</button>}</>;
}
@@ -0,0 +1,63 @@
import { socialApi, uuidv7, type ListeningPreferences, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
import { usePlayer, usePlayerState } from '../../player/context';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, FormError } from '../../shared/ui/ui';
import { waveHeadphonesIcon } from '../../shared/ui/icons';
import s from './social.module.css';
function useListening() {
const session = useSession(); const user = useUser(); const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
const key = ['listening', server, user.id] as const;
const query = useQuery({ queryKey: key, queryFn: api.listening, refetchInterval: 3000 });
return { api, key, query, user };
}
export function ListeningPanel({ showPreferences = true, showRequests = true }: { showPreferences?: boolean; showRequests?: boolean }) {
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
const { api, key, query, user } = useListening();
const prefs = query.data?.preferences;
const save = useMutation({ mutationFn: (body: ListeningPreferences) => api.setListeningPreferences(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const action = useMutation({ mutationFn: (input: { action: 'accept' | 'decline' | 'cancel'; target: string }) => api.listeningAction({ id: uuidv7(), action: input.action, target_id: input.target }), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <div className={s.stack}>
{showPreferences && <details className={s.privacy}><summary>{t('social.together')}</summary><p className={s.muted}>{t('social.stream_prefs_hint')}</p>
{(['requests', 'auto_accept'] as const).map(field => <fieldset key={field} className={s.privacy} disabled={!prefs || save.isPending}><legend>{t(field === 'requests' ? 'social.stream_requests' : 'social.stream_auto')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-pressed={(prefs?.[field] ?? 'nobody') === v} variant={(prefs?.[field] ?? 'nobody') === v ? 'primary' : 'ghost'} onClick={() => prefs && save.mutate({ ...prefs, [field]: v })}>{t(`social.visibility_${v}`)}</Button>)}</div></fieldset>)}
<label><input type="checkbox" checked={prefs?.autoplay ?? true} disabled={!prefs || save.isPending} onChange={e => prefs && save.mutate({ ...prefs, autoplay: e.target.checked })} /> {t('social.stream_autoplay')}</label>
</details>}
{showRequests && query.data?.requests.map(r => <article key={r.id} className={s.person}><strong>{r.recipient_id === user.id ? t(r.kind === 'join' ? 'social.stream_incoming_join' : 'social.stream_incoming_invite', { name: r.sender_name }) : t('social.stream_pending')}</strong><div className={s.actions}>{r.recipient_id === user.id ? <><Button disabled={action.isPending} onClick={() => action.mutate({ action: 'accept', target: r.id })}>{t('social.accept')}</Button><Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'decline', target: r.id })}>{t('social.decline')}</Button></> : <Button disabled={action.isPending} variant="ghost" onClick={() => action.mutate({ action: 'cancel', target: r.id })}>{t('form.cancel')}</Button>}</div></article>)}
<FormError>{query.error || save.error || action.error ? errorText(query.error ?? save.error ?? action.error) : null}</FormError>
</div>;
}
export function ListenTogetherButton({ person }: { person: SocialPerson }) {
const { t } = useTranslation(); const [open, setOpen] = useState(false);
useEffect(() => { if (!person.online) setOpen(false); }, [person.online]);
return <><Button disabled={!person.online} title={!person.online ? t('social.together_offline') : undefined} variant="ghost" icon={waveHeadphonesIcon} aria-label={t('social.together')} onClick={() => setOpen(true)}><span className={s.togetherLabel}>{t('social.together')}</span></Button>{open && person.online && <ListenTogetherDialog person={person} close={() => setOpen(false)} />}</>;
}
function ListenTogetherDialog({ person, close }: { person: SocialPerson; close: () => void }) {
const { t } = useTranslation(); const errorText = useErrorText(); const cache = useQueryClient();
const { api, key } = useListening(); const state = usePlayerState();
const [kind, setKind] = useState<'join' | 'invite'>(person.activity ? 'join' : 'invite');
const [id] = useState(uuidv7);
const action = useMutation({ mutationFn: () => api.listeningAction({ id, action: kind, target_id: person.id }), onSuccess: () => { close(); void cache.invalidateQueries({ queryKey: key }); } });
return <Dialog title={t('social.together')} onClose={close}><strong>{person.display_name}</strong><fieldset className={s.privacy} disabled={action.isPending || action.isSuccess}><legend>{t('social.stream_choice')}</legend>
<label className={s.choice}><input type="radio" checked={kind === 'join'} disabled={!person.activity} onChange={() => setKind('join')} />{t('social.join_stream')}</label>
<label className={s.choice}><input type="radio" checked={kind === 'invite'} disabled={!state.track || state.listening?.locked} onChange={() => setKind('invite')} />{t('social.invite_stream')}</label>
</fieldset><p className={s.muted}>{t('social.stream_hint')}</p>
<FormError>{action.error ? errorText(action.error) : null}</FormError>
{action.isSuccess ? <p role="status">{t(kind === 'join' ? 'social.stream_request_sent' : 'social.stream_invite_sent')}</p> : <Button variant="primary" busy={action.isPending} disabled={kind === 'join' ? !person.activity : !state.track || state.listening?.locked} onClick={() => action.mutate()}>{t('social.send')}</Button>}
</Dialog>;
}
export function ListeningBanner() {
const { t } = useTranslation(); const state = usePlayerState(); const player = usePlayer();
const session = useSession(); const errorText = useErrorText(); const room = state.listening?.room;
const action = useMutation({ mutationFn: async () => room && socialApi(session).listeningAction({ id: uuidv7(), action: room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'end' : 'leave', target_id: room.id }) });
if (!room) return null;
return <section className={s.stream} aria-label={t('social.together')}><div><strong>{t(state.listening?.locked ? 'social.stream_following' : 'social.stream_host', { name: room.owner_name })}</strong><p className={s.muted}>{t(state.listening?.disconnected ? 'social.stream_lost' : state.listening?.needsStart ? 'social.stream_waiting_audio' : 'social.stream_controls')}</p><details><summary>{t('social.stream_listeners', { count: Math.max(0, room.participants.length - 1) })}</summary>{room.participants.map(p => <div key={p.id}>{p.name}</div>)}</details></div>
<div className={s.actions}>{state.listening?.locked && !state.playing && !state.listening.disconnected && <Button onClick={() => player.beginListeningAudio()}>{t('social.stream_start_audio')}</Button>}<Button busy={action.isPending} variant="ghost" onClick={() => action.mutate()}>{t(room.owner_id === (session.state.status === 'signed_in' ? session.state.user.id : '') ? 'social.stream_end' : 'social.stream_leave')}</Button></div>
<FormError>{action.error ? errorText(action.error) : null}</FormError>
</section>;
}
@@ -0,0 +1,74 @@
import { useEffect, useRef, useState } from 'react';
import { useRouterState, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { Button, EmptyState, FormError, Icon } from '../../shared/ui/ui';
import { chatIcon, closeIcon, groupIcon, searchIcon } from '../../shared/ui/icons';
import { useErrorText } from '../../shared/lib/errors';
import { ChatConversation } from './ChatConversation';
import { Presence } from './Presence';
import { useSocialDialogOpen } from './Social';
import { useSocial } from './queries';
import s from './social.module.css';
/** Lives in the shell so browsing and minimizing never change the selected conversation. */
export function MiniSocial() {
const { t } = useTranslation();
const { api, inbox, key, user } = useSocial();
const errorText = useErrorText();
const navigate = useNavigate();
const pathname = useRouterState({ select: state => state.location.pathname });
const dialogOpen = useSocialDialogOpen();
const [desktop, setDesktop] = useState(() => window.matchMedia?.('(min-width: 1024px)').matches ?? false);
const [open, setOpen] = useState(false);
const [peer, setPeer] = useState('');
const [search, setSearch] = useState('');
const trigger = useRef<HTMLButtonElement>(null);
const panel = useRef<HTMLElement>(null);
const shown = desktop && pathname !== '/friends';
useEffect(() => {
const media = window.matchMedia?.('(min-width: 1024px)');
if (!media) return;
const change = () => setDesktop(media.matches);
media.addEventListener('change', change);
return () => media.removeEventListener('change', change);
}, []);
useEffect(() => {
if (open && shown) panel.current?.querySelector<HTMLInputElement>('input')?.focus();
}, [open, shown]);
const friends = (inbox.data?.people ?? []).filter(person => person.relationship === 'friend');
const selected = friends.find(person => person.id === peer);
const unread = friends.reduce((sum, person) => sum + person.unread, 0);
const filtered = [...friends].sort((a, b) => Number(b.online) - Number(a.online) || (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name))
.filter(person => `${person.display_name} ${person.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
const minimize = () => { setOpen(false); trigger.current?.focus(); };
if (!shown) return null;
return <aside className={`${s.miniDock} ${user.ui_prefs.web_player === 'island' ? s.miniDockIsland : ''}`} aria-label={t('social.mini_chat')}>
<section hidden={!open} ref={panel} id="mini-social-panel" className={s.miniPanel} aria-label={t('social.title')}
onKeyDown={event => { if (event.key === 'Escape' && !event.defaultPrevented && !dialogOpen) { event.stopPropagation(); minimize(); } }}>
<header className={s.miniHeader}>
<strong>{t('social.title')}</strong>
<Button variant="ghost" onClick={() => { setOpen(false); void navigate({ to: '/friends', search: selected ? { peer: selected.id, tab: 'messages' } : {} }); }}>{t('social.open_full')}</Button>
<Button variant="ghost" icon={closeIcon} aria-label={t('social.minimize_chat')} onClick={minimize} />
</header>
<FormError>{inbox.error ? errorText(inbox.error) : null}</FormError>
<div className={s.miniWorkspace}>
<div className={s.miniFriends}>
<label className={s.listSearch}><Icon src={searchIcon} size={16} /><input aria-label={t('social.find_conversation')} placeholder={t('social.find_conversation')} value={search} onChange={event => setSearch(event.target.value)} /></label>
{inbox.isPending && <p className={s.muted}>{t('social.loading')}</p>}
{inbox.isSuccess && !filtered.length && <p className={s.muted}>{t(search ? 'social.no_results' : 'social.no_friends_share')}</p>}
{filtered.map(person => <button key={person.id} type="button" className={`${s.dialogRow} ${peer === person.id ? s.selected : ''}`} aria-pressed={peer === person.id} onClick={() => setPeer(person.id)}>
<span className={`${s.avatar} ${person.online ? s.avatarListening : ''}`} aria-hidden>{person.display_name.slice(0, 1).toUpperCase()}</span>
<span className={s.dialogBody}><strong>{person.display_name}</strong><Presence online={person.online} /></span>
{person.unread > 0 && <span className={s.badge}>{person.unread}</span>}
</button>)}
</div>
{selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={open && !dialogOpen} />
: <div className={s.welcome}><EmptyState icon={groupIcon}>{t('social.choose_chat')}</EmptyState></div>}
</div>
</section>
<button ref={trigger} type="button" className={s.miniTrigger} aria-label={t('social.mini_chat')} title={t('social.mini_chat')} aria-expanded={open} aria-controls="mini-social-panel" onClick={() => setOpen(value => !value)}>
<Icon src={open ? closeIcon : chatIcon} size={22} />
{unread > 0 && <span className={s.badge}>{unread > 99 ? '99+' : unread}</span>}
</button>
</aside>;
}
@@ -0,0 +1,44 @@
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../../shared/lib/errors';
import { checkIcon, chevronRightIcon, inboxIcon } from '../../shared/ui/icons';
import { Dialog, EmptyState, FormError, Icon, Skeleton } from '../../shared/ui/ui';
import { useSocial } from './queries';
import s from './notifications.module.css';
export function NotificationsDialog({ onClose, onOpen }: { onClose: () => void; onOpen: (id: string) => void }) {
const { t, i18n } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [unreadOnly, setUnreadOnly] = useState(false);
const read = useMutation({ mutationFn: (id: string) => api.action({ action: 'read_notification', target_id: id }), onSuccess: () => cache.invalidateQueries({ queryKey: key }) });
const events = inbox.data?.notifications ?? [];
const unread = events.filter(event => !event.read).length;
const shown = unreadOnly ? events.filter(event => !event.read) : events;
return <Dialog title={t('social.notifications')} onClose={onClose} className={s.dialog}>
<nav className={s.filters} aria-label={t('social.notifications')}>
<button type="button" aria-pressed={!unreadOnly} onClick={() => setUnreadOnly(false)}>{t('social.notifications_all')}</button>
<button type="button" aria-pressed={unreadOnly} onClick={() => setUnreadOnly(true)}>{t('social.notifications_unread')}{unread > 0 && <span className={s.count}>{unread}</span>}</button>
</nav>
<FormError>{read.error || inbox.error ? errorText(read.error ?? inbox.error) : null}</FormError>
<div className={s.list} tabIndex={0} role="region" aria-label={t('social.notifications')}>
{inbox.isPending && <div className={s.loading} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(value => <Skeleton key={value} style={{ height: 68 }} />)}</div>}
{inbox.isSuccess && !shown.length && <div className={s.empty}><EmptyState icon={unreadOnly ? checkIcon : inboxIcon}>{t(unreadOnly ? 'social.notifications_caught_up' : 'social.no_notifications')}</EmptyState></div>}
{shown.map(event => {
const date = new Date(event.created_at);
return <article key={event.id} className={`${s.row} ${!event.read ? s.unread : ''}`}>
<button type="button" className={s.open} aria-label={t('social.open_notification', { name: event.actor_name })} onClick={() => { if (!event.read) read.mutate(event.id); onOpen(event.actor_id); }}>
<span className={s.avatar} aria-hidden>{event.actor_name.slice(0, 1).toUpperCase()}</span>
<span className={s.content}><strong>{event.actor_name}</strong><span>{t(`social.kind_${event.kind}`)}</span>
<time dateTime={event.created_at} title={date.toLocaleString(i18n.language)}>{date.toLocaleString(i18n.language, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })}</time>
</span>
<Icon src={chevronRightIcon} size={18} className={s.arrow} />
</button>
{!event.read && <button type="button" className={s.read} disabled={read.isPending} aria-label={t('social.mark_read')} title={t('social.mark_read')} onClick={() => read.mutate(event.id)}><span className={s.dot} /><Icon src={checkIcon} size={18} /></button>}
</article>;
})}
</div>
</Dialog>;
}
@@ -0,0 +1,7 @@
import { useTranslation } from 'react-i18next';
import s from './social.module.css';
export function Presence({ online }: { online: boolean }) {
const { t } = useTranslation();
return <span className={`${s.presence} ${online ? s.presenceOnline : ''}`}><span className={s.presenceDot} aria-hidden />{t(online ? 'social.online' : 'social.offline')}</span>;
}
+210
View File
@@ -0,0 +1,210 @@
import { uuidv7, type ActivityVisibility, type MusicAttachment, type SocialAction, type SocialPerson } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../../shared/lib/errors';
import { Button, Dialog, EmptyState, FormError, Icon, Menu, Skeleton } from '../../shared/ui/ui';
import { addIcon, groupIcon, inboxIcon, moreIcon, musicIcon, searchIcon, shieldIcon, visibilityOffIcon } from '../../shared/ui/icons';
import s from './social.module.css';
import { ListeningPanel, ListenTogetherButton } from './Listening';
import { ChatConversation } from './ChatConversation';
import { Presence } from './Presence';
import { useSocial } from './queries';
import { NotificationsDialog } from './NotificationsDialog';
export function useSocialUnread() { return useSocial().inbox.data?.unread ?? 0; }
export function FriendRecommendations() {
const { api, inbox, key } = useSocial(); const cache = useQueryClient(); const { t } = useTranslation(); const errorText = useErrorText();
const save = useMutation({ mutationFn: api.setFriendsRecommendations, onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
return <div><label><input type="checkbox" checked={inbox.data?.friends_recommendations ?? true} disabled={!inbox.data || save.isPending} onChange={e => save.mutate(e.target.checked)} /> {t('social.friends_recommendations')}</label><p className={s.muted}>{t('social.friends_recommendations_hint')}</p><FormError>{save.error ? errorText(save.error) : null}</FormError></div>;
}
type ShareValue = { attachment: MusicAttachment; url: string };
type AccountDialog = 'privacy' | 'notifications' | null;
const AccountDialogContext = createContext<{ dialog: AccountDialog; setDialog: (dialog: AccountDialog) => void }>({ dialog: null, setDialog: () => {} });
export const useSocialDialogOpen = () => useContext(AccountDialogContext).dialog !== null;
const ShareContext = createContext<(value: ShareValue) => void>(() => { throw new Error('Missing SocialProvider'); });
export const useMusicShare = () => useContext(ShareContext);
export function SocialProvider({ children }: { children: ReactNode }) {
const [share, setShare] = useState<ShareValue | null>(null);
const [dialog, setDialog] = useState<AccountDialog>(null);
return <AccountDialogContext.Provider value={{ dialog, setDialog }}><ShareContext.Provider value={setShare}>{children}{share && <ShareDialog key={share.url} value={share} close={() => setShare(null)} />}</ShareContext.Provider></AccountDialogContext.Provider>;
}
function ShareDialog({ value, close }: { value: ShareValue; close: () => void }) {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const errorText = useErrorText();
const [done, setDone] = useState<string[]>([]);
const [copied, setCopied] = useState(false);
const ids = useRef(new Map<string, string>());
const action = useMutation({ mutationFn: async (peer: string) => {
if (peer === 'copy') { await navigator.clipboard.writeText(value.url); setCopied(true); return; }
if (peer === 'external') {
try { await navigator.share({ title: value.attachment.title, url: value.url }); }
catch (error) { if (!(error instanceof DOMException && error.name === 'AbortError')) throw error; }
return;
}
let id = ids.current.get(peer); if (!id) { id = uuidv7(); ids.current.set(peer, id); }
await api.send(peer, { id, text: value.attachment.title, attachment_kind: value.attachment.kind, attachment_id: value.attachment.id });
setDone(old => [...old, peer]);
await cache.invalidateQueries({ queryKey: key });
} });
return <Dialog title={t('player.share')} onClose={close}>
<div className={s.stack}><strong>{value.attachment.title}</strong>
<Button busy={action.isPending && action.variables === 'copy'} onClick={() => action.mutate('copy')}>{t(copied ? 'social.copied' : 'social.copy')}</Button>
{!!navigator.share && <Button busy={action.isPending && action.variables === 'external'} onClick={() => action.mutate('external')}>{t('social.external')}</Button>}
<h3>{t('social.send_friend')}</h3>
{inbox.isPending && <p>{t('social.loading')}</p>}
{inbox.data && !inbox.data.people.some(p => p.relationship === 'friend') && <p>{t('social.no_friends_share')}</p>}
{inbox.data?.people.filter(p => p.relationship === 'friend').map(p => <Button key={p.id} disabled={done.includes(p.id) || action.isPending} onClick={() => action.mutate(p.id)}>{p.display_name} · {t(done.includes(p.id) ? 'social.sent' : 'social.send')}</Button>)}
<FormError>{action.error || inbox.error ? errorText(action.error ?? inbox.error) : null}</FormError>
</div>
</Dialog>;
}
export function useSocialProfileMenu() {
const { t } = useTranslation();
const { api, inbox, key } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const { dialog, setDialog } = useContext(AccountDialogContext);
const closeDialog = useCallback(() => setDialog(null), [setDialog]);
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const visibility = useMutation({ mutationFn: (v: ActivityVisibility) => api.setVisibility(v), onSuccess: async () => { await cache.invalidateQueries({ queryKey: key }); } });
const people = inbox.data?.people ?? [];
const currentVisibility = inbox.data?.activity_visibility ?? 'nobody';
const open = (id: string) => {
const relationship = people.find(p => p.id === id)?.relationship;
const tab = relationship === 'friend' ? 'messages' : relationship === 'incoming' || relationship === 'outgoing' ? 'requests' : 'people';
setDialog(null);
void navigate({ to: '/friends', search: { peer: id, tab } });
};
return {
items: [
{ label: t(currentVisibility === 'nobody' ? 'social.status_hidden' : 'social.privacy_settings'), icon: currentVisibility === 'nobody' ? visibilityOffIcon : shieldIcon, onSelect: () => setDialog('privacy') },
{ label: t('social.notifications'), badge: inbox.data?.unread, icon: inboxIcon, onSelect: () => setDialog('notifications') },
],
dialogs: <>
{dialog === 'privacy' && <Dialog title={t('social.privacy_settings')} onClose={closeDialog} className={s.preferencesDialog}>
<fieldset className={s.privacy} disabled={!inbox.data} aria-busy={visibility.isPending}><legend>{t('social.activity_visibility')}</legend><div className={s.actions}>{(['nobody', 'friends', 'everyone'] as const).map(v => <Button key={v} aria-disabled={visibility.isPending} aria-pressed={currentVisibility === v} variant={currentVisibility === v ? 'primary' : 'ghost'} onClick={() => { if (!visibility.isPending) visibility.mutate(v); }}>{t(`social.visibility_${v}`)}</Button>)}</div><p className={s.muted}>{t('social.activity_privacy_hint')}</p></fieldset>
<FormError>{visibility.error || action.error || inbox.error ? errorText(visibility.error ?? action.error ?? inbox.error) : null}</FormError>
<ListeningPanel showRequests={false} />
{people.some(p => p.relationship === 'blocked') && <section><h3>{t('social.blocked_people')}</h3>{people.filter(p => p.relationship === 'blocked').map(p => <Person key={p.id} person={p} busy={action.isPending} open={() => {}} act={op => action.mutate({ action: op, target_id: p.id })} />)}</section>}
</Dialog>}
{dialog === 'notifications' && <NotificationsDialog onClose={closeDialog} onOpen={open} />}
</>,
};
}
type SocialTab = 'messages' | 'people' | 'requests';
type ConfirmAction = { person: SocialPerson; action: 'remove' | 'block' };
export function SocialPage({ peer = '', initialTab }: { peer?: string; initialTab?: SocialTab }) {
const { dialog } = useContext(AccountDialogContext);
const { t, i18n } = useTranslation();
const { api, inbox, key, user } = useSocial();
const cache = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const [tab, setTab] = useState<SocialTab>('messages');
const [search, setSearch] = useState('');
const [q, setQ] = useState('');
const [confirm, setConfirm] = useState<ConfirmAction | null>(null);
const [narrow, setNarrow] = useState(() => window.matchMedia?.('(max-width: 767px)').matches ?? false);
const searchRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLElement>(null);
useEffect(() => {
const media = window.matchMedia?.('(max-width: 767px)');
if (!media) return;
const change = () => setNarrow(media.matches);
media.addEventListener('change', change);
return () => media.removeEventListener('change', change);
}, []);
useEffect(() => { const timer = setTimeout(() => setQ(search.trim()), 300); return () => clearTimeout(timer); }, [search]);
const results = useQuery({ queryKey: [...key, 'search', q], queryFn: () => api.search(q), enabled: tab === 'people' && q.length >= 2 });
const action = useMutation({ mutationFn: (body: SocialAction) => api.action(body), onSuccess: async () => { setConfirm(null); await cache.invalidateQueries({ queryKey: key }); } });
const people = inbox.data?.people ?? [];
const friends = people.filter(p => p.relationship === 'friend');
const conversations = [...friends].sort((a, b) => (b.last_message?.created_at ?? '').localeCompare(a.last_message?.created_at ?? '') || a.display_name.localeCompare(b.display_name));
useEffect(() => { if (initialTab) setTab(initialTab); }, [peer, initialTab]);
const initialPeer = conversations[0]?.id;
useEffect(() => {
// Pin the initial desktop selection: a newer message in another dialog must
// never switch the conversation the user is reading or composing in.
if (!narrow && !peer && initialPeer) void navigate({ to: '/friends', search: { peer: initialPeer }, replace: true });
}, [narrow, peer, initialPeer, navigate]);
const selected = friends.find(p => p.id === peer) ?? (!peer ? conversations[0] : undefined);
const requests = people.filter(p => p.relationship === 'incoming' || p.relationship === 'outgoing');
const incoming = requests.filter(p => p.relationship === 'incoming').length;
const messageUnread = friends.reduce((sum, p) => sum + p.unread, 0);
const filtered = (tab === 'messages' ? conversations : requests).filter(p => `${p.display_name} ${p.username}`.toLocaleLowerCase().includes(search.trim().toLocaleLowerCase()));
const shownPeople = tab === 'people' ? q.length >= 2 ? results.data ?? [] : friends : filtered;
const open = (id: string) => {
const person = people.find(p => p.id === id);
setSearch(''); setQ('');
setTab(person?.relationship === 'friend' ? 'messages' : person?.relationship === 'incoming' || person?.relationship === 'outgoing' ? 'requests' : 'people');
void navigate({ to: '/friends', search: { peer: id } });
};
const findPeople = () => { setTab('people'); setSearch(''); setQ(''); requestAnimationFrame(() => searchRef.current?.focus()); };
const back = () => { void navigate({ to: '/friends', search: {} }).then(() => listRef.current?.querySelector<HTMLButtonElement>('button[aria-pressed="true"]')?.focus()); };
const act = (person: SocialPerson, op: SocialAction['action']) => {
if (op === 'block' || op === 'remove' && person.relationship === 'friend') setConfirm({ person, action: op });
else action.mutate({ action: op, target_id: person.id });
};
const listLabel = t(tab === 'messages' ? 'social.find_conversation' : tab === 'requests' ? 'social.find_request' : 'social.search');
return <div className={`${s.page} ${tab === 'messages' && peer && selected ? s.conversationOpen : ''}`}>
<header className={s.pageHeader}>
<h1>{t('social.title')}</h1>
</header>
<ListeningPanel showPreferences={false} />
<section className={s.hub}>
<nav className={s.tabs} aria-label={t('social.title')}>
{(['messages', 'people', 'requests'] as const).map(value => <button key={value} type="button" aria-pressed={tab === value} onClick={() => { setTab(value); setSearch(''); setQ(''); }}>{t(`social.${value}`)}{(value === 'requests' ? incoming : value === 'messages' ? messageUnread : 0) > 0 && <span className={s.badge}>{value === 'requests' ? incoming : messageUnread}</span>}</button>)}
<Button className={s.addFriend} variant="ghost" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button>
</nav>
<FormError>{inbox.error || action.error || results.error ? errorText(inbox.error ?? action.error ?? results.error) : null}</FormError>
<div className={tab === 'messages' ? s.workspace : s.directory}>
<aside ref={listRef} className={s.peopleList} aria-label={t(tab === 'messages' ? 'social.messages' : 'social.people')}>
<div className={s.listSearch}><Icon src={searchIcon} size={18} /><input ref={searchRef} type="search" aria-label={listLabel} placeholder={listLabel} value={search} maxLength={80} onChange={e => setSearch(e.target.value)} /></div>
{inbox.isPending && <div className={s.stack} role="status" aria-label={t('social.loading')}>{[0, 1, 2].map(i => <Skeleton key={i} style={{ height: 64 }} />)}</div>}
{tab === 'people' && q.length >= 2 && results.isFetching && <p className={s.muted} role="status">{t('social.loading')}</p>}
{!shownPeople.length && inbox.data && !results.isFetching && <EmptyState icon={tab === 'requests' ? inboxIcon : groupIcon} action={!search && tab !== 'people' ? <Button onClick={findPeople}>{t('social.find_people')}</Button> : undefined}>{t(search ? 'social.no_results' : tab === 'requests' ? 'social.no_requests' : 'social.empty')}</EmptyState>}
{shownPeople.map(p => tab === 'messages' ? <button key={p.id} type="button" className={`${s.dialogRow} ${selected?.id === p.id ? s.selected : ''}`} aria-pressed={selected?.id === p.id} onClick={() => open(p.id)}>
<span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span>
<span className={s.dialogBody}><span className={s.dialogTitle}><strong>{p.display_name}</strong>{p.last_message && <time>{new Date(p.last_message.created_at).toLocaleDateString(i18n.language, { day: 'numeric', month: 'short' })}</time>}</span><span className={s.preview}>{p.last_message ? `${p.last_message.sender_id === user.id ? `${t('social.you')}: ` : ''}${p.last_message.deleted ? t('social.message_deleted') : p.last_message.attachment_kind !== 'none' ? t(p.last_message.attachment_kind === 'track' ? 'social.shared_track' : 'social.shared_playlist') : p.last_message.text}` : t('social.start_conversation')}</span><Presence online={p.online} />{p.activity && <span className={s.listeningPreview}><Icon src={musicIcon} size={13} />{p.activity.track_title}</span>}</span>
{p.unread > 0 && <span className={s.badge}>{p.unread}</span>}
</button> : <Person key={p.id} person={p} selected={peer === p.id} busy={action.isPending} open={() => open(p.id)} act={op => act(p, op)} />)}
</aside>
{tab === 'messages' && (selected ? <ChatConversation key={selected.id} person={selected} api={api} queryKey={key} userId={user.id} active={(!narrow || !!peer) && !dialog && !confirm} onBack={back} actions={<FriendMenu person={selected} act={op => act(selected, op)} />} /> : <div className={s.welcome}><span className={s.welcomeIcon}><Icon src={musicIcon} size={36} /></span><h2>{t('social.conversation_title')}</h2><p>{t('social.choose_chat')}</p><Button variant="primary" icon={addIcon} onClick={findPeople}>{t('social.find_people')}</Button></div>)}
</div>
</section>
{confirm && <Dialog title={t(confirm.action === 'block' ? 'social.confirm_block' : 'social.confirm_remove', { name: confirm.person.display_name })} onClose={() => { if (!action.isPending) setConfirm(null); }} footer={<><Button disabled={action.isPending} onClick={() => setConfirm(null)}>{t('form.cancel')}</Button><Button variant="danger" busy={action.isPending} onClick={() => action.mutate({ action: confirm.action, target_id: confirm.person.id })}>{t(`social.${confirm.action}`)}</Button></>}><p className={s.muted}>{t(confirm.action === 'block' ? 'social.block_hint' : 'social.remove_hint')}</p><FormError>{action.error ? errorText(action.error) : null}</FormError></Dialog>}
</div>;
}
function FriendMenu({ person, act }: { person: SocialPerson; act: (a: SocialAction['action']) => void }) {
const { t } = useTranslation();
return <Menu label={t('social.person_actions', { name: person.display_name })} trigger={<Icon src={moreIcon} />} items={[
...(person.relationship === 'friend' ? [{ label: t('social.remove'), danger: true, onSelect: () => act('remove') }] : []),
{ label: t(person.relationship === 'blocked' ? 'social.unblock' : 'social.block'), danger: person.relationship !== 'blocked', onSelect: () => act(person.relationship === 'blocked' ? 'unblock' : 'block') },
]} />;
}
function Person({ person: p, busy, open, act, selected }: { person: SocialPerson; busy: boolean; selected?: boolean; open: () => void; act: (a: SocialAction['action']) => void }) {
const { t } = useTranslation();
return <article className={`${s.person} ${selected ? s.selected : ''}`}>
<div className={s.identity}><span className={`${s.avatar} ${p.online ? s.avatarListening : ''}`} aria-hidden>{p.display_name.slice(0, 1).toUpperCase()}</span><div><strong>{p.display_name}</strong><div className={s.muted}>@{p.username} · {t(`social.rel_${p.relationship}`)}</div>{p.relationship === 'friend' && <Presence online={p.online} />}</div></div>
{p.activity && <div className={s.activity}><span className={s.listening}><Icon src={musicIcon} size={16} />{t('social.listening')}</span><Link to="/track/$id" params={{ id: p.activity.track_id }}>{p.activity.track_title}</Link>{p.activity.artist && <span className={s.muted}>{p.activity.artist}</span>}{p.activity.context === 'playlist' && p.activity.playlist_id && <Link to="/activity/playlist/$userId" params={{ userId: p.id }}>{t('social.from_playlist', { name: p.activity.playlist_name ?? '' })}</Link>}</div>}
<div className={s.actions}>
{p.relationship === 'friend' && <><Button onClick={open}>{t('social.chat')}{p.unread ? ` · ${p.unread}` : ''}</Button><ListenTogetherButton person={p} /></>}
{p.relationship === 'none' && <Button disabled={busy} onClick={() => act('request')}>{t('social.add')}</Button>}
{p.relationship === 'incoming' && <Button disabled={busy} variant="primary" onClick={() => act('accept')}>{t('social.accept')}</Button>}
{['incoming', 'outgoing'].includes(p.relationship) && <Button disabled={busy} variant="ghost" onClick={() => act('remove')}>{t(p.relationship === 'incoming' ? 'social.decline' : 'social.cancel')}</Button>}
<FriendMenu person={p} act={act} />
</div>
</article>;
}
@@ -0,0 +1,40 @@
.dialog { width: 600px; max-height: min(680px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.dialog > :first-child { flex-shrink: 0; padding: 20px 24px 16px; }
.dialog > :nth-child(2) { display: flex; flex-direction: column; gap: 0; min-height: 0; padding: 0; overflow: hidden; }
.filters { display: flex; gap: 6px; flex-shrink: 0; padding: 0 24px 16px; border-bottom: 1px solid var(--line); }
.filters button { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 0 12px; border-radius: 9px; color: var(--text2); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer; }
.filters button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
.filters button:hover { background: var(--hover); color: var(--text); }
.count { font-size: 11px; font-variant-numeric: tabular-nums; color: var(--accent2); }
.list { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--line2) transparent; padding: 8px 12px 12px; }
.row { position: relative; display: flex; align-items: center; border-radius: 12px; }
.row + .row { margin-top: 4px; }
.row.unread { background: var(--accent-soft); }
.row:hover { background: var(--hover); }
.open { display: flex; align-items: center; gap: 14px; flex: 1; min-width: 0; padding: 14px 12px; border-radius: 12px; text-align: left; color: var(--text); font: inherit; cursor: pointer; }
.avatar { display: grid; place-items: center; flex-shrink: 0; width: 40px; height: 40px; border: 1px solid var(--line2); border-radius: 50%; background: var(--surface2); color: var(--accent2); font-size: 14px; font-weight: 600; }
.content { display: flex; flex-direction: column; flex: 1; min-width: 0; gap: 4px; overflow-wrap: anywhere; }
.content strong { font-size: 13px; font-weight: 600; }
.content > span { font-size: 13px; color: var(--text2); line-height: 1.45; }
.content time { color: var(--text3); font-size: 11px; line-height: 1.4; }
.arrow { flex-shrink: 0; color: var(--text3); }
.read { position: relative; display: grid; place-items: center; width: 36px; height: 36px; flex-shrink: 0; margin-right: 10px; border-radius: 9px; color: var(--accent2); cursor: pointer; }
.read > svg, .read > span:last-child { opacity: 0; }
.dot { position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--accent2); }
.read:hover, .read:focus-visible { background: var(--accent-soft); }
.read:hover .dot, .read:focus-visible .dot { opacity: 0; }
.read:hover > :last-child, .read:focus-visible > :last-child { opacity: 1; }
.read:disabled { opacity: .5; cursor: wait; }
.open:focus-visible, .read:focus-visible, .filters button:focus-visible, .list:focus-visible { outline: 2px solid var(--accent2); outline-offset: -2px; }
.loading { display: grid; gap: 8px; padding: 12px; }
.empty { padding: 36px 20px; }
@media(max-width: 767px) {
.dialog { max-height: min(680px, calc(100dvh - 32px - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px))); }
.dialog > :first-child { padding: 16px; }
.filters { padding: 0 16px 12px; }
.list { padding: 6px; }
.open { gap: 10px; padding: 12px 10px; }
.avatar { width: 34px; height: 34px; }
.read { width: 36px; min-height: 44px; margin-right: 4px; }
}
@media(hover: none) { .read > :last-child { opacity: 1; } .read .dot { display: none; } }
+20
View File
@@ -0,0 +1,20 @@
import { socialApi } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useSession, useUser } from '../../app/session';
import { useDesktop } from '../../desktop';
const scopes = new WeakMap<object, number>();
let nextScope = 0;
export function useSocial() {
const session = useSession();
const user = useUser();
const server = useDesktop()?.server ?? '';
const api = useMemo(() => socialApi(session), [session]);
if (!scopes.has(session)) scopes.set(session, ++nextScope);
const key = ['social', server, scopes.get(session), user.id] as const;
const inbox = useQuery({ queryKey: key, queryFn: api.inbox, staleTime: 5000, refetchInterval: 15_000, refetchOnWindowFocus: true });
return { api, key, inbox, user };
}
@@ -0,0 +1,206 @@
.page { width: 100%; max-width: 1480px; margin: 0 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 14px; }
.pageHeader { display: flex; justify-content: space-between; align-items: center; gap: 20px; flex-shrink: 0; }
.page h1 { font-size: 24px; letter-spacing: -.035em; margin: 0; }
.muted { color: var(--text2); font-size: 13px; line-height: 1.6; }
.hub { display: flex; flex-direction: column; min-height: 0; flex: 1; background: var(--surface); border: 1px solid var(--line2); border-radius: 20px; overflow: hidden; box-shadow: 0 12px 48px var(--hover); }
.tabs { display: flex; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 10px 16px; flex-shrink: 0; }
.tabs > button:not(.addFriend) { position: relative; display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 0 14px; border-radius: 10px; color: var(--text2); font-weight: 600; transition: color .18s, background .18s; }
.tabs > button[aria-pressed='true'] { background: var(--accent-soft); color: var(--accent2); }
.tabs > button[aria-pressed='true']::after { content: ''; position: absolute; bottom: -11px; height: 2px; left: 16px; right: 16px; background: var(--accent2); border-radius: 2px; animation: tabIn .2s ease-out; }
.tabs > button:hover { color: var(--text); background: var(--hover); }
.addFriend { margin-left: auto; }
.badge { display: inline-flex; align-items: center; justify-content: center; min-width: 20px; height: 20px; padding: 0 6px; border-radius: 10px; background: var(--accent-fill); color: var(--on-accent, #fff); font-size: 11px; font-weight: 600; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.workspace { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(230px, 29%) minmax(0, 1fr); }
.peopleList { padding: 12px 10px; min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.workspace > .peopleList { border-right: 1px solid var(--line); background: var(--bg2); }
.listSearch { display: flex; align-items: center; gap: 10px; background: var(--surface2); border: 1px solid var(--line); border-radius: 10px; padding: 0 12px; min-height: 44px; margin: 2px 4px 14px; color: var(--text3); }
.listSearch:focus-within { border-color: var(--accent); }
.listSearch input { color: var(--text); font: inherit; font-size: 13px; width: 100%; min-width: 0; background: transparent; border: 0; height: 44px; }
.listSearch input::placeholder { color: var(--text3); }
.listSearch input:focus-visible { outline: none; }
.dialogRow { display: flex; align-items: center; gap: 12px; width: 100%; padding: 14px 12px; text-align: left; border-radius: 12px; margin-bottom: 4px; transition: background .18s, transform .18s; }
.dialogRow:hover { background: var(--hover); }
.dialogRow:active { transform: scale(.985); }
.dialogRow.selected { background: var(--accent-soft); }
.dialogBody { display: flex; flex-direction: column; gap: 4px; min-width: 0; flex: 1; }
.dialogTitle { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; min-width: 0; }
.dialogTitle strong { font-size: 14px; min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.dialogTitle time { color: var(--text3); font-size: 10px; white-space: nowrap; flex-shrink: 0; }
.preview { color: var(--text2); font-size: 12px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.listeningPreview { display: flex; align-items: center; gap: 4px; color: var(--ok); font-size: 11px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.avatar { position: relative; width: 42px; height: 42px; flex-shrink: 0; border-radius: 50%; display: grid; place-items: center; color: var(--accent2); background: var(--accent-soft); font-size: 16px; font-weight: 600; }
.avatarListening::after { content: ''; position: absolute; right: 0; bottom: 0; width: 10px; height: 10px; background: var(--ok); border-radius: 50%; border: 2px solid var(--surface); }
.presence { display: inline-flex; align-items: center; gap: 5px; color: var(--text2); font-size: 11px; white-space: nowrap; }
.presenceDot { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: var(--text3); flex-shrink: 0; transition: background .2s; }
.presenceOnline .presenceDot { background: var(--ok); }
.directory { flex: 1; min-height: 0; overflow-y: auto; padding: 16px 24px; animation: panelIn .22s ease-out; }
.directory .peopleList { padding: 0; overflow: visible; }
.directory .listSearch { max-width: 440px; margin-left: 0; }
.person { display: flex; flex-wrap: wrap; align-items: center; gap: 20px; padding: 20px 4px; border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; }
.person > .actions { margin-left: auto; }
.person.selected { background: var(--accent-soft); }
.identity { display: flex; gap: 12px; align-items: center; min-width: 0; }
.identity > div { min-width: 0; }
.identity strong { display: block; overflow-wrap: anywhere; }
.activity { display: grid; gap: 4px; max-width: 300px; font-size: 13px; }
.activity a, .nowListening a { color: var(--text); text-decoration: none; }
.activity a:hover, .nowListening a:hover { color: var(--accent2); text-decoration: underline; }
.listening { display: flex; align-items: center; gap: 5px; color: var(--ok); font-size: 11px; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.stack { display: grid; gap: 12px; }
.stack:empty { display: none; }
.welcome { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; padding: 32px; }
.welcomeIcon { color: var(--accent2); background: var(--accent-soft); display: grid; place-items: center; width: 80px; height: 80px; border-radius: 24px; margin-bottom: 8px; }
.welcome h2 { font-size: 22px; letter-spacing: -.025em; margin: 0; }
.welcome p { color: var(--text2); max-width: 280px; margin: 0 0 8px; line-height: 1.7; }
.privacy { border: 1px solid var(--line); border-radius: 12px; padding: 16px; margin: 0 0 16px; min-width: 0; }
.privacy legend { color: var(--text2); padding: 0 8px; font-size: 13px; }
.privacy summary { cursor: pointer; font-weight: 600; }
.privacy p { margin-bottom: 0; }
.privacy .privacy { margin: 12px 0; }
.privacy input { accent-color: var(--accent); }
.preferencesDialog { width: 560px; max-height: min(720px, calc(100dvh - 48px)); display: flex; flex-direction: column; overflow: hidden; background: var(--surface); }
.preferencesDialog > :first-child { padding: 20px 24px 16px; flex-shrink: 0; border-bottom: 1px solid var(--line); }
.preferencesDialog > :nth-child(2) { min-height: 0; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
.preferencesDialog fieldset.privacy { border: 0; padding: 0; margin: 0; }
.preferencesDialog .privacy legend { padding: 0; margin-bottom: 12px; color: var(--text); font-weight: 600; }
.preferencesDialog .privacy .actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; padding: 4px; border: 1px solid var(--line); border-radius: 12px; background: var(--surface2); }
.preferencesDialog .privacy .actions button { min-width: 0; padding: 0 8px; border-radius: 8px; min-height: 36px; }
.preferencesDialog details.privacy { padding: 16px; margin: 8px 0 0; border: 1px solid var(--line); background: var(--surface2); }
.preferencesDialog details.privacy > summary { line-height: 1.5; }
.preferencesDialog details.privacy fieldset { margin: 20px 0; }
.preferencesDialog details.privacy > label { display: flex; align-items: flex-start; gap: 10px; font-size: 13px; line-height: 1.5; }
.preferencesDialog details.privacy > label input { margin-top: 3px; }
.choice { display: flex; gap: 12px; padding: 14px; align-items: center; border: 1px solid var(--line); border-radius: 12px; cursor: pointer; background: var(--surface2); font-size: 13px; line-height: 1.5; }
.choice + .choice { margin-top: 8px; }
.choice:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
.choice:has(input:disabled) { opacity: .5; cursor: not-allowed; }
.choice input { flex-shrink: 0; accent-color: var(--accent); }
.stream { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; justify-content: space-between; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--ok); border-radius: 14px; background: var(--surface); margin-bottom: 20px; }
.chat { position: relative; display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; background: var(--surface); animation: conversationIn .24s cubic-bezier(.2,.8,.2,1); }
.chatHeader { display: flex; align-items: center; gap: 12px; padding: 16px 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
.chatIdentity { flex: 1; min-width: 0; }
.chatIdentity h2 { font-size: 16px; line-height: 1.4; margin: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chatIdentity > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.chatIdentity > .presence { display: inline-flex; }
.chatTools { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.iconButton { width: 40px; min-width: 40px; height: 40px; padding: 0; border-radius: 12px; flex-shrink: 0; }
.backButton { display: none; }
.nowListening { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); color: var(--ok); flex-shrink: 0; }
.nowListening > div { min-width: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; font-size: 12px; }
.nowListening span { font-size: 11px; }
.nowListening a { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
.chatTabs { display: flex; gap: 16px; padding: 0 22px; border-bottom: 1px solid var(--line); flex-shrink: 0; }
.chatTabs button { position: relative; display: flex; align-items: center; gap: 6px; padding: 12px 0; font-size: 12px; color: var(--text3); transition: color .16s; }
.chatTabs button[aria-pressed='true'] { color: var(--text); }
.chatTabs button[aria-pressed='true']::after { content: ''; height: 2px; position: absolute; left: 0; right: 0; bottom: -1px; border-radius: 2px; background: var(--accent2); animation: tabIn .18s ease-out; }
.chatSearch { display: flex; gap: 12px; align-items: end; padding: 12px 20px; border-bottom: 1px solid var(--line); animation: panelIn .18s ease-out; flex-shrink: 0; }
.chatSearch > div { flex: 1; }
.messages { display: flex; flex-direction: column; gap: 12px; flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; overflow-anchor: none; scrollbar-width: thin; padding: 22px 28px; }
.chatSkeleton { display: flex; flex-direction: column; gap: 20px; }
.older { align-self: center; flex-shrink: 0; }
.date { align-self: center; color: var(--text3); font-size: 11px; margin: 6px 0 10px; flex-shrink: 0; }
.newMessages { text-align: center; color: var(--accent2); font-size: 11px; border-bottom: 1px solid var(--line2); padding: 6px; }
.bubble { display: flex; flex-direction: column; gap: 7px; padding: 10px 14px 5px; border-radius: 16px 16px 16px 4px; background: var(--surface2); max-width: min(82%, 560px); min-width: 100px; flex-shrink: 0; align-self: start; overflow-wrap: anywhere; animation: messageIn .22s cubic-bezier(.2,.8,.2,1); }
.mine { align-self: end; background: var(--accent-soft); border-radius: 16px 16px 4px 16px; }
.messageText { white-space: pre-wrap; user-select: text; line-height: 1.65; font-size: 14px; min-width: 0; }
.messageText a, .attachmentCaption a { color: var(--accent2); }
.messageFoot { display: flex; align-items: center; justify-content: end; gap: 10px; min-height: 30px; }
.messageFoot small { font-size: 11px; color: var(--text2); }
.messageFoot button { width: 28px; height: 28px; min-width: 28px; padding: 0; border-radius: 8px; color: var(--text3); }
.messageFoot button:hover { color: var(--text); background: var(--hover); }
.quote { margin: 0 0 2px; padding: 6px 10px; border-left: 2px solid var(--accent2); border-radius: 0 6px 6px 0; background: var(--hover); color: var(--text2); font-size: 12px; max-height: 72px; overflow: hidden; }
.deleted { font-style: italic; color: var(--text3); }
.sending { opacity: .6; }
.reactions { display: flex; flex-wrap: wrap; gap: 5px; }
.reactions button { border: 1px solid var(--line); border-radius: 14px; background: var(--surface); color: var(--text); padding: 4px 8px; font-size: 12px; cursor: pointer; transition: transform .15s, background .15s; }
.reactions button:hover { transform: translateY(-2px); }
.reactions button[aria-pressed='true'] { background: var(--accent-soft); border-color: var(--accent2); }
.typing { display: flex; align-items: center; gap: 8px; min-height: 24px; padding: 0 24px 4px; color: var(--text3); font-size: 11px; flex-shrink: 0; }
.typingDots { display: flex; gap: 3px; }
.typingDots i { width: 3px; height: 3px; border-radius: 50%; background: currentColor; animation: typingPulse 1s ease-in-out infinite alternate; }
.typingDots i:nth-child(2) { animation-delay: .15s; }
.typingDots i:nth-child(3) { animation-delay: .3s; }
.replyBar { display: flex; gap: 10px; align-items: center; padding: 10px 22px; background: var(--hover); border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .16s ease-out; }
.replyBar > div { flex: 1; min-width: 0; }
.replyBar small { color: var(--accent2); }
.replyBar p { margin: 3px 0; font-size: 12px; max-height: 35px; overflow: hidden; overflow-wrap: anywhere; }
.composer { display: flex; gap: 8px; padding: 14px 18px; border-top: 1px solid var(--line); align-items: end; flex-shrink: 0; background: var(--surface); }
.composer textarea { resize: none; min-width: 0; flex: 1; border: 1px solid transparent; border-radius: 12px; background: var(--surface2); color: var(--text); padding: 10px 14px; min-height: 42px; max-height: 144px; font: inherit; font-size: 14px; line-height: 22px; scrollbar-width: thin; transition: border-color .18s, background .18s; }
.composer textarea:focus { border-color: var(--accent); }
.composer textarea::placeholder { color: var(--text3); }
.emojiPicker { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 20px; border-top: 1px solid var(--line); flex-shrink: 0; animation: panelIn .18s ease-out; }
.emojiPicker button { width: 40px; height: 40px; border-radius: 10px; font-size: 20px; transition: background .15s, transform .15s; }
.emojiPicker button:hover { background: var(--hover); transform: translateY(-2px); }
.jumpToBottom { position: absolute; bottom: 94px; align-self: center; border: 1px solid var(--line2); background: var(--elev); box-shadow: var(--shadow); z-index: 2; animation: panelIn .18s ease-out; }
.musicCard { display: flex; align-items: center; gap: 12px; min-width: 0; width: 320px; max-width: 100%; padding: 6px 0; }
.musicInfo { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1; }
.musicInfo a { color: var(--text); font-size: 14px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-decoration: none; }
.musicInfo a:hover { color: var(--accent2); text-decoration: underline; }
.musicInfo > span { font-size: 11px; color: var(--text2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.musicCover { width: 48px; height: 48px; display: grid; place-items: center; background: var(--surface3); border-radius: 8px; color: var(--text3); flex-shrink: 0; }
.musicUnavailable { opacity: .7; }
.musicMessages .mediaBubble { align-self: stretch; max-width: 100%; background: transparent; border: 1px solid var(--line); border-radius: 12px; }
.musicMessages .musicCard { width: 100%; }
.miniDock { grid-area: content; align-self: end; justify-self: end; z-index: 25 !important; display: flex; flex-direction: column; align-items: end; gap: 12px; width: min(600px, calc(100% - 32px)); min-height: 0; margin: 16px; pointer-events: none; }
.miniPanel { pointer-events: auto; width: 100%; height: min(640px, calc(100dvh - 250px)); min-height: 0; display: flex; flex-direction: column; border: 1px solid var(--line2); border-radius: 18px; background: var(--surface); box-shadow: var(--shadow); overflow: hidden; animation: panelIn .2s ease-out; }
.miniDockIsland { margin-bottom: 132px; }
.miniDockIsland .miniPanel { height: min(640px, calc(100dvh - 290px)); }
.miniPanel[hidden] { display: none; }
.miniHeader { display: flex; align-items: center; gap: 6px; padding: 8px 10px 8px 16px; border-bottom: 1px solid var(--line); }
.miniHeader > strong { flex: 1; font-size: 14px; }
.miniHeader > button { min-height: 36px; padding: 0 8px; font-size: 12px; }
.miniWorkspace { flex: 1; min-height: 0; display: grid; grid-template-columns: 170px minmax(0, 1fr); }
.miniFriends { overflow-y: auto; min-width: 0; padding: 8px; border-right: 1px solid var(--line); }
.miniFriends .listSearch { padding: 0 8px; gap: 5px; margin: 0 0 8px; min-height: 36px; }
.miniFriends .listSearch input { height: 36px; font-size: 11px; }
.miniFriends .dialogRow { gap: 8px; padding: 10px 6px; }
.miniFriends .avatar { width: 28px; height: 28px; font-size: 12px; }
.miniFriends .dialogBody strong { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; }
.miniFriends .badge { min-width: 17px; height: 17px; padding: 0 4px; font-size: 10px; }
.miniWorkspace .chat { min-width: 0; }
.miniWorkspace .chatHeader { gap: 8px; padding: 10px; }
.miniWorkspace .chatHeader .avatar { width: 30px; height: 30px; font-size: 12px; }
.miniWorkspace .chatIdentity h2 { font-size: 14px; }
.miniWorkspace .chatTools .togetherLabel { display: none; }
.miniWorkspace .chatTools > button, .miniWorkspace .chatTools > div > button { width: 34px; min-width: 34px; padding: 0; }
.miniWorkspace .chatTabs { gap: 14px; padding: 0 12px; }
.miniWorkspace .chatTabs button { font-size: 11px; }
.miniWorkspace .messages { padding: 12px; gap: 10px; }
.miniWorkspace .bubble { max-width: 94%; font-size: 13px; padding: 8px 10px 4px; }
.miniWorkspace .composer { padding: 8px; gap: 4px; }
.miniWorkspace .composer .iconButton { width: 34px; min-width: 34px; }
.miniWorkspace .composer textarea { padding: 8px; font-size: 13px; }
.miniWorkspace .welcome { padding: 20px; }
.miniTrigger { position: relative; pointer-events: auto; display: grid; place-items: center; width: 48px; height: 48px; flex-shrink: 0; border-radius: 50%; color: var(--text); border: 1px solid var(--line2); background: var(--surface); box-shadow: 0 6px 24px #0005; cursor: pointer; }
.miniTrigger:hover, .miniTrigger[aria-expanded='true'] { background: var(--accent-fill); color: var(--on-accent, #fff); }
.miniTrigger:focus-visible { outline: 2px solid var(--accent2); outline-offset: 3px; }
.miniTrigger > .badge { position: absolute; top: -5px; right: -5px; }
@media(max-width: 1023px) { .miniDock { display: none; } }
@media(prefers-reduced-motion: reduce) { .miniPanel { animation: none; } }
.attachmentCaption { font-size: 12px; color: var(--text2); white-space: pre-wrap; }
.expandText { display: block; color: var(--accent2); font-size: 12px; padding: 6px 0; }
@keyframes conversationIn { from { opacity: .4; transform: translateX(8px); } to { opacity: 1; transform: none; } }
@keyframes panelIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } }
@keyframes messageIn { from { opacity: 0; transform: translateY(8px) scale(.98); } to { opacity: 1; transform: none; } }
@keyframes tabIn { from { opacity: 0; transform: scaleX(.5); } to { opacity: 1; transform: none; } }
@keyframes typingPulse { from { opacity: .35; transform: translateY(0); } to { opacity: 1; transform: translateY(-3px); } }
@media(max-width: 1150px) { .pageHeader { align-items: start; } .chatTools .togetherLabel { display: none; } .chatTools > button, .chatTools > div > button { min-width: 40px; width: 40px; padding: 0; } .workspace { grid-template-columns: 240px minmax(0, 1fr); } .chatHeader { padding: 14px 16px; } .messages { padding: 18px; } .bubble { max-width: 90%; } }
@media(max-width: 900px) { .pageHeader { flex-wrap: wrap; gap: 10px; } .page { gap: 14px; } .workspace { grid-template-columns: 220px minmax(0, 1fr); } .tabs { gap: 2px; padding: 8px; } .tabs > button:not(.addFriend) { padding: 0 10px; font-size: 13px; } .addFriend { font-size: 12px; padding: 0 10px; } .chatHeader { gap: 8px; } .chatTools { gap: 0; } }
@media(max-width: 767px) {
.page { gap: 12px; } .pageHeader { padding: 0 2px; } .page h1 { font-size: 23px; }
.hub { border-radius: 14px; } .tabs { flex-wrap: wrap; } .tabs > button:not(.addFriend) { flex: 1; font-size: 12px; } .tabs .addFriend { width: 100%; height: 36px; font-size: 12px; }
.workspace { display: flex; flex-direction: column; } .workspace > .peopleList { flex: 1; border: 0; } .workspace > .chat, .workspace > .welcome { display: none; }
.conversationOpen > .pageHeader, .conversationOpen > .stack, .conversationOpen .hub > .tabs, .conversationOpen .workspace > .peopleList { display: none; }
.conversationOpen .workspace > .chat { display: flex; flex: 1; } .backButton { display: inline-flex; } .chatHeader { padding: 10px 8px; gap: 6px; }
.chatHeader .avatar { width: 32px; height: 32px; font-size: 13px; } .chatIdentity h2 { font-size: 14px; } .chatIdentity > span { font-size: 11px; }
.chatTools > button, .chatTools > div > button { min-width: 36px; width: 36px; } .iconButton { min-width: 36px; width: 36px; } .chatTools > div:last-child > button { width: 32px; min-width: 32px; }
.chatTabs { padding: 0 14px; gap: 18px; } .chatTabs button { min-height: 44px; font-size: 11px; }
.nowListening { padding: 8px 14px; } .messages { padding: 12px; gap: 10px; } .bubble { max-width: 92%; font-size: 14px; padding: 9px 12px 4px; }
.composer { gap: 4px; padding: 10px 8px; } .composer textarea { font-size: 16px; padding: 10px; } .composer .iconButton { min-width: 40px; width: 40px; height: 44px; }
.directory { padding: 12px; } .person { gap: 12px; padding: 16px 0; } .person > .actions { margin-left: 0; width: 100%; } .person .activity { width: 100%; padding-left: 54px; }
.musicCard { gap: 8px; } .musicCard > :first-child { width: 40px !important; height: 40px !important; } .musicCard .iconButton { min-width: 32px; width: 32px; } .musicInfo a { font-size: 13px; }
.chatSearch { padding: 10px 12px; gap: 6px; } .replyBar { padding: 8px 12px; } .privacy { padding: 12px; } .privacy .actions { gap: 4px; } .privacy .actions button { padding: 0 12px; }
}
@media(pointer: coarse) { .chatTools button, .messageFoot button, .reactions button { min-height: 44px; } }
@media(prefers-reduced-motion: reduce) { .chat, .bubble, .directory, .chatSearch, .replyBar, .emojiPicker, .jumpToBottom, .typingDots i, .tabs > button::after, .chatTabs button::after { animation: none; } .dialogRow, .tabs > button, .chatTabs button, .composer textarea, .reactions button, .emojiPicker button { transition: none; } .dialogRow:active, .reactions button:hover, .emojiPicker button:hover { transform: none; } }
@@ -0,0 +1,68 @@
import { unwrap } from '@pipu/core';
import { useMutation, 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, FormError } from '../../shared/ui/ui';
import s from '../../pages/page.module.css';
import { downloadUserArchive, MAX_ARCHIVE_BYTES, parseUserArchive, type UserArchive } from './transfer';
export function UserTransfer() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [open, setOpen] = useState(false);
const [archive, setArchive] = useState<UserArchive | null>(null);
const [fileError, setFileError] = useState(false);
const [reading, setReading] = useState(false);
const exporting = useMutation({
mutationFn: async () => downloadUserArchive(unwrap(await session.client.GET('/api/v1/admin/users/export'))),
});
const importing = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/users/import', { body: archive! })),
onSuccess: () => { setArchive(null); void queryClient.invalidateQueries({ queryKey: ['admin-users'] }); },
});
const busy = importing.isPending || reading;
const close = () => { if (!busy) { setOpen(false); setArchive(null); } };
const read = async (file: File | undefined) => {
setArchive(null);
setFileError(false);
importing.reset();
if (!file) return;
setReading(true);
try {
if (file.size > MAX_ARCHIVE_BYTES) throw new Error('size');
setArchive(parseUserArchive(await file.text()));
} catch { setFileError(true); }
finally { setReading(false); }
};
return <>
<Button busy={exporting.isPending} onClick={() => exporting.mutate()}>{t('transfer.export')}</Button>
<Button onClick={() => { importing.reset(); setFileError(false); setOpen(true); }}>{t('transfer.import')}</Button>
<FormError>{exporting.error ? errorText(exporting.error) : null}</FormError>
{open && <Dialog title={t('transfer.import')} onClose={close} footer={<>
<Button variant="ghost" disabled={busy} onClick={close}>{t('common:form.close')}</Button>
<Button variant="primary" busy={importing.isPending} disabled={!archive || busy} onClick={() => importing.mutate()}>
{t('transfer.apply')}
</Button>
</>}>
<p className={s.note}>{t('transfer.description')}</p>
<p className={s.note}>{t('transfer.sensitive')}</p>
<label className={s.sessions}>
<span>{t('transfer.file')}</span>
<input type="file" accept=".json,application/json" disabled={busy} onChange={(e) => void read(e.target.files?.[0])} />
</label>
{archive && <p className={s.note}>{t('transfer.preview', { count: archive.users.length })}</p>}
<FormError>{fileError ? t('transfer.invalid') : importing.error ? errorText(importing.error) : null}</FormError>
{importing.data && <div role="status">
<p>{t('transfer.result', { count: importing.data.created, skipped: importing.data.skipped.length })}</p>
{importing.data.skipped.length > 0 && <p className={s.note}>{importing.data.skipped.join(', ')}</p>}
</div>}
</Dialog>}
</>;
}
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { parseUserArchive } from './transfer';
describe('user archive preview', () => {
const entry = { username: 'alice', role: 'user', password_hash: '$argon2id$hash' };
it('accepts a versioned export and a UTF-8 BOM', () => {
const archive = { version: 1, users: [entry] };
expect(parseUserArchive(`\uFEFF${JSON.stringify(archive)}`)).toEqual(archive);
});
it.each([
'{}', '{"version":2,"users":[]}', '{"version":1,"users":{}}',
JSON.stringify({ version: 1, users: [{ ...entry, role: 'owner' }] }),
JSON.stringify({ version: 1, users: [{ username: 'alice', role: 'user' }] }),
JSON.stringify({ version: 1, users: Array(10001).fill(entry) }),
])('rejects unsupported or malformed input', (text) => {
expect(() => parseUserArchive(text)).toThrow();
});
});
+29
View File
@@ -0,0 +1,29 @@
import type { components } from '@pipu/api-client';
export type UserArchive = components['schemas']['UserArchive'];
export const MAX_ARCHIVE_BYTES = 16 * 1024 * 1024;
/** Only previews metadata; the server validates every account before writing. */
export function parseUserArchive(text: string): UserArchive {
const data: unknown = JSON.parse(text.replace(/^\uFEFF/, ''));
if (!data || typeof data !== 'object' || !('version' in data) || data.version !== 1 ||
!('users' in data) || !Array.isArray(data.users) || data.users.length > 10000) throw new Error('archive');
for (const entry of data.users) {
if (!entry || typeof entry !== 'object' || typeof entry.username !== 'string' ||
!['admin', 'moderator', 'user'].includes(entry.role) || typeof entry.password_hash !== 'string') {
throw new Error('archive');
}
}
return data as UserArchive;
}
export function downloadUserArchive(archive: UserArchive) {
const url = URL.createObjectURL(new Blob([JSON.stringify(archive, null, 2)], { type: 'application/json' }));
const link = document.createElement('a');
link.href = url;
link.download = 'pipu-users.json';
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
+22 -10
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { useState, type MouseEvent } from 'react';
import { useTranslation } from 'react-i18next';
@@ -9,7 +9,9 @@ import { PlaylistEditor, playlistInput, usePlaylist, usePlaylists, type Playlist
import type { components } from '@pipu/api-client';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { sharePlaylist } from '../shared/lib/share';
import { playlistShareUrl } from '@pipu/core';
import { desktopHandle } from '../desktop';
import { useMusicShare } from '../features/social/Social';
import { formatDuration } from '../shared/lib/format';
import { Button, Dialog, EmptyState, Field, FormError, Icon, PageSkeleton } from '../shared/ui/ui';
import { playIcon, playlistAddIcon, searchIcon, shuffleIcon, shareIcon } from '../shared/ui/icons';
@@ -55,7 +57,21 @@ export function PlaylistPage({ id, shared = false }: { id: string; shared?: bool
</div>;
}
function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Playlist; readOnly?: boolean; shareId?: string }) {
export function ActivityPlaylistPage({ userId }: { userId: string }) {
const session = useSession();
const errorText = useErrorText();
const { t } = useTranslation();
const query = useQuery({ queryKey: ['activity-playlist', session.state.status === 'signed_in' ? session.state.user.id : '', userId], queryFn: async () => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })), staleTime: 0, refetchInterval: 15_000 });
return <div className={s.page}>
<Link to="/friends">← {t('social.title')}</Link>
<FormError>{query.error ? errorText(query.error) : null}</FormError>
{query.isPending && <PageSkeleton />}
{query.data && !query.isError && <PlaylistDetail key={query.data.id} playlist={query.data} readOnly activity />}
</div>;
}
function PlaylistDetail({ playlist, readOnly = false, shareId, activity = false }: { playlist: Playlist; readOnly?: boolean; shareId?: string; activity?: boolean }) {
const openShare = useMusicShare();
const { t } = useTranslation();
const session = useSession();
const cache = useQueryClient();
@@ -65,20 +81,17 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
const [edit, setEdit] = useState(false);
const [remove, setRemove] = useState(false);
const [manage, setManage] = useState(false);
const [copied, setCopied] = useState(false);
const sharing = useMutation({ mutationFn: async () => {
setCopied(false);
let token = shareId ?? playlist.share_id;
if (!readOnly) {
token = unwrap(await session.client.POST('/api/v1/me/playlists/{id}/share', { params: { path: { id: playlist.id } } })).share_id;
cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: token } : current);
}
if (!token) throw new Error('Share link unavailable');
const result = await sharePlaylist(token, playlist.name);
if (result === 'copied') setCopied(true);
openShare({ attachment: { kind: 'playlist', id: token, title: playlist.name }, url: playlistShareUrl(desktopHandle()?.server ?? window.location.origin, token) });
} });
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] }); } });
onSuccess: () => { cache.setQueryData<Playlist>(['playlists', playlist.id], current => current ? { ...current, share_id: null } : current); cache.removeQueries({ queryKey: ['shared-playlists', playlist.share_id] }); } });
const context = { type: 'collection' as const, id: playlist.id, label: playlist.name };
const save = useMutation({ mutationFn: async (ids: string[]) => unwrap(await session.client.PUT('/api/v1/me/playlists/{id}', {
@@ -108,7 +121,7 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
<div className={s.toolbar}>
<Button variant="primary" icon={playIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(false)}>{t('collection.play_all')}</Button>
<Button icon={shuffleIcon} disabled={!playlist.tracks.some(x => x.available)} onClick={() => play(true)}>{t('collection.shuffle')}</Button>
<Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>
{!activity && <Button icon={shareIcon} busy={sharing.isPending} disabled={revoke.isPending} onClick={() => sharing.mutate()}>{t('playlists.share')}</Button>}
{!readOnly && <>
<Button disabled={save.isPending} onClick={() => setEdit(true)}>{t('playlists.edit')}</Button>
<Button aria-pressed={manage} onClick={() => setManage(!manage)}>{t('playlists.manage')}</Button>
@@ -117,7 +130,6 @@ function PlaylistDetail({ playlist, readOnly = false, shareId }: { playlist: Pla
</>}
</div>
{!readOnly && <p>{t('playlists.share_hint')}</p>}
{copied && <p role="status">{t('playlists.link_copied')}</p>}
<FormError>{sharing.error ? t('player.share_error') : revoke.error ? errorText(revoke.error) : null}</FormError>
<FormError>{save.error ? errorText(save.error) : null}</FormError>
{!playlist.tracks.length && (
+2
View File
@@ -13,6 +13,7 @@ import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
import { Onboarding } from './Onboarding';
import { FriendRecommendations } from '../features/social/Social';
import { DesktopOfflineSection, DesktopUpdateButton } from '../features/desktop/DesktopFeatures';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
@@ -371,6 +372,7 @@ function RecommendationSettings() {
const errorText = useErrorText();
const options = useQuery({ queryKey: ['onboarding'], queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')), staleTime: 0 });
return <section className={s.section}>
<FriendRecommendations />
{editing && options.data ? <Onboarding key={JSON.stringify(options.data.choice)} options={options.data} editing onDone={() => { setEditing(false); void queryClient.invalidateQueries({ queryKey: ['onboarding'] }); }} />
: <Button busy={options.isFetching} onClick={() => { void options.refetch().then((result) => { if (result.data && !result.error) setEditing(true); }); }}>{t('onboarding.edit')}</Button>}
<FormError>{options.error ? errorText(options.error) : null}</FormError>
+10
View File
@@ -75,6 +75,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
const [test, setTest] = useState<TestResult | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const id = source.id;
const covers = useQuery({
queryKey: ['source-cover-stats', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}/cover-stats', { params: { path: { id } } })),
refetchInterval: 10000,
});
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] });
const scan = useMutation({
@@ -144,6 +149,11 @@ function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void })
</div>
<div className={l.cardFoot}>
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
{covers.data && <span className={s.note} role="status">
{t('sources.cover_files', { count: covers.data.files_with_cover, total: covers.data.files_total })}
{covers.data.albums_pending > 0 && ` · ${t('sources.cover_pending', { count: covers.data.albums_pending })}`}
</span>}
<FormError>{covers.error ? errorText(covers.error) : null}</FormError>
{test && (
<span className={`${l.test} ${test.ok ? l.testOk : l.testFail}`} role="status">
{test.ok ? t('sources.test_ok', { count: test.audio_files }) : t('sources.test_fail', { message: test.message })}
+8
View File
@@ -5,6 +5,7 @@ import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { UserTransfer } from '../../features/users/UserTransfer';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
@@ -140,6 +141,11 @@ export function UsersPage() {
label: t(u.reviews_uploads ? 'users.revoke_review' : 'users.grant_review'),
onSelect: () => patch.mutate({ id: u.id, body: { reviews_uploads: !u.reviews_uploads } }),
});
if (me.role === 'admin' && u.role !== 'admin')
items.push({
label: t(u.manages_updates ? 'users.revoke_updates' : 'users.grant_updates'),
onSelect: () => patch.mutate({ id: u.id, body: { manages_updates: !u.manages_updates } }),
});
if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items;
@@ -158,6 +164,7 @@ export function UsersPage() {
</div>
<div className={s.toolbar}>
{me.role === 'admin' && <UserTransfer />}
<label className={s.search}>
<Icon src={searchIcon} size={18} className={s.searchIcon} />
<input
@@ -214,6 +221,7 @@ export function UsersPage() {
</td>
<td data-label={t('users.col_role')}>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
{u.manages_updates && <span className={cx(s.badge, s.role_moderator)}>{t('users.manage_updates')}</span>}
{u.reviews_uploads && <span className={cx(s.badge, s.role_moderator)}>{t('users.review_uploads')}</span>}
</td>
<td data-label={t('users.col_status')}>
+102
View File
@@ -0,0 +1,102 @@
import type { AuthSession, ListeningState, Track } from '@pipu/core';
import { refreshListening } from '@pipu/core';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Player } from './player';
const track: Track = {
id: 'local', title: 'Local', duration_ms: 200_000, artists: [],
album: { id: 'album', title: 'Album', year: null, cover_url: null, cover_colors: [] },
genres: [], disc_no: null, track_no: null, available: true, stream_url: '/s/local', reaction: null, features: null,
};
const remote = { ...track, id: 'remote', stream_url: '/s/remote' };
const ok = (data: unknown) => ({ data, response: new Response() });
const players: Player[] = [];
function fixture() {
let snapshot: ListeningState = { room: null, requests: [], preferences: { requests: 'friends', auto_accept: 'friends', autoplay: true }, server_time: new Date().toISOString() };
const session = {
state: { status: 'signed_in', user: { id: 'guest', ui_prefs: { crossfade_s: 6, normalize: false } } },
client: {
GET: vi.fn(async (path: string) => ok(path === '/api/v1/me/social/listening' ? snapshot : remote)),
PUT: vi.fn(async () => ok({})), POST: vi.fn(async () => ok({})),
},
} as unknown as AuthSession;
const audio = document.createElement('audio');
const overlap = document.createElement('audio');
vi.spyOn(globalThis, 'Audio').mockImplementation(function () { return overlap; });
const frames: FrameRequestCallback[] = [];
vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => { frames.push(callback); return frames.length; });
const clock = vi.spyOn(performance, 'now').mockReturnValue(0);
const player = new Player(session, audio);
players.push(player);
const startFade = () => {
player.playList([track, { ...track, id: 'next', stream_url: '/s/next' }], 0, { type: 'queue' });
Object.defineProperty(audio, 'duration', { configurable: true, value: 200 });
audio.currentTime = 196;
audio.dispatchEvent(new Event('timeupdate'));
clock.mockReturnValue(3000);
frames.shift()!(3000);
expect(overlap.paused).toBe(false);
expect(overlap.volume).toBeGreaterThan(0);
};
const follow = async () => {
snapshot = { ...snapshot, room: { id: 'room', owner_id: 'host', owner_name: 'Host', device_id: 'host-device', track_id: remote.id, position_ms: 1000, playing: true, updated_at: snapshot.server_time, participants: [{ id: 'host', name: 'Host' }, { id: 'guest', name: 'Guest' }] } };
player.start();
await vi.waitFor(() => expect(player.getState()).toMatchObject({ track: { id: 'remote' }, playing: true, listening: { locked: true } }));
};
return { player, audio, overlap, frames, clock, startFade, follow, session, setRoom: (room: ListeningState['room']) => { snapshot = { ...snapshot, room }; } };
}
afterEach(() => { players.splice(0).forEach(player => player.dispose()); vi.restoreAllMocks(); localStorage.removeItem('pipu.player'); });
describe('shared listening audio ownership', () => {
it('stops a local crossfade before following and ignores its queued animation', async () => {
const e = fixture();
e.startFade();
const staleFrame = e.frames.shift()!;
await e.follow();
expect(e.audio.paused).toBe(false);
expect(e.overlap.paused).toBe(true);
expect(e.overlap.getAttribute('src')).toBeNull();
staleFrame(6000);
expect(e.player.getState().track?.id).toBe('remote');
expect(e.overlap.paused).toBe(true);
e.setRoom(null);
refreshListening(e.session);
await vi.waitFor(() => expect(e.player.getState().listening?.room).toBeNull());
expect(e.audio.paused).toBe(true);
expect(e.overlap.paused).toBe(true);
});
it('silences the overlapping element on pause', () => {
const e = fixture();
e.startFade();
e.player.pause();
expect(e.audio.paused).toBe(true);
expect(e.overlap.paused).toBe(true);
});
it('cancels an old transition when another track is selected', () => {
const e = fixture();
e.startFade();
const staleFrame = e.frames.shift()!;
e.player.playList([remote], 0, { type: 'queue' });
e.clock.mockReturnValue(6000);
staleFrame(6000);
expect(e.audio.getAttribute('src')).toBe('/s/remote');
expect(e.player.getState().track?.id).toBe('remote');
expect(e.overlap.paused).toBe(true);
});
it('still completes an uninterrupted local crossfade with one active source', async () => {
const e = fixture();
e.startFade();
e.clock.mockReturnValue(6000);
e.frames.shift()!(6000);
await Promise.resolve();
expect(e.player.getState().track?.id).toBe('next');
expect(e.audio.getAttribute('src')).toBe('/s/next');
expect(e.audio.paused).toBe(false);
expect(e.overlap.paused).toBe(true);
});
});
+94 -7
View File
@@ -1,5 +1,9 @@
import {
EventBuffer,
createActivityPublisher,
type ActivityPublisher,
ListeningCoordinator,
type ListeningRoom,
WaveCoordinator,
EARLY_SKIP_MS,
type WaveOutcome,
@@ -35,12 +39,17 @@ const PRELOAD_BEFORE_MS = 20_000;
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private activity: ActivityPublisher | null = null;
private listening: ListeningCoordinator | null = null;
private applyingSync = false;
private remoteRoom: string | null = null;
readonly offline: DesktopOffline | null;
private retryEvents: (() => Promise<void>) | null = null;
private readonly engine: AudioEngine;
private get audio() { return this.engine.audio; }
private get overlap() { return this.engine.overlap; }
private fading = false;
private fadeVersion = 0;
private ignoreEnded = false;
private readonly queue = new PlayQueue();
private readonly events: EventBuffer;
@@ -93,6 +102,17 @@ export class Player {
/** Запуск фоновой отправки событий (парный вызов — dispose). */
start() {
this.activity ??= createActivityPublisher(this.session);
this.activity.update(this.state);
if (!this.listening) {
this.listening = new ListeningCoordinator(this.session, this.activity.deviceId, {
read: this.getState,
pause: () => { this.restoreVersion++; this.leaveWave(); this.cancelCrossfade(); this.audio.pause(); },
apply: (room, position, current) => this.syncListening(room, position, current),
changed: listening => this.update({ listening }),
});
this.listening.start();
}
this.offline?.activate();
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
if (this.retryEvents) {
@@ -116,6 +136,8 @@ export class Player {
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
this.activity?.update(this.state);
this.listening?.update();
for (const fn of this.listeners) fn();
this.media.syncDesktopMedia();
}
@@ -135,6 +157,7 @@ export class Player {
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
playList(tracks: Track[], index: number, context: PlayContext) {
if (this.listening?.locked && !this.applyingSync) return;
const playable = tracks.filter((t) => t.available && t.stream_url);
const target = tracks[index];
if (!target || !playable.length) return;
@@ -151,6 +174,7 @@ export class Player {
/** Запустить волну (или перезапустить с новым контекстом). */
async startWave(activity: string | null, mood: string | null, seed?: { type: 'artist' | 'album' | 'track'; id: string; label: string }) {
if (this.listening?.locked) return;
this.restoreVersion++;
this.finishAttempt('skip');
this.leaveWave();
@@ -190,6 +214,7 @@ export class Player {
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
*/
async setWaveContext(activity: string | null, mood: string | null) {
if (this.listening?.locked) return;
const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood);
this.update({ wave: { ...wave, activity, mood } });
@@ -215,6 +240,7 @@ export class Player {
}
stopWave() {
if (this.listening?.locked) return;
this.leaveWave();
this.pause();
}
@@ -247,11 +273,13 @@ export class Player {
}
toggle() {
if (this.listening?.locked && !this.applyingSync) return;
if (this.state.playing) this.pause();
else this.play();
}
play() {
if (this.listening?.locked && !this.applyingSync) return;
if (!this.queue.current) return;
if (!this.audio.src || !this.attempt) {
void this.load(true, this.state.positionMs);
@@ -263,10 +291,13 @@ export class Player {
}
pause() {
if (this.listening?.locked && !this.applyingSync) return;
this.cancelCrossfade();
this.audio.pause();
}
next() {
if (this.listening?.locked) return;
this.finishAttempt('skip');
if (this.queue.next()) {
this.syncQueue();
@@ -278,6 +309,7 @@ export class Player {
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
prev() {
if (this.listening?.locked) return;
if (this.audio.currentTime > 3) {
this.seek(0);
return;
@@ -290,6 +322,7 @@ export class Player {
}
jump(index: number) {
if (this.listening?.locked) return;
this.finishAttempt('skip');
this.queue.jump(index);
this.syncQueue();
@@ -297,9 +330,11 @@ export class Player {
}
seek(ms: number) {
if (this.listening?.locked && !this.applyingSync) return;
const track = this.queue.current;
if (!track) return;
const to = Math.min(Math.max(ms, 0), track.duration_ms);
this.cancelCrossfade();
const from = Math.round(this.audio.currentTime * 1000);
this.attempt?.seek(to);
this.audio.currentTime = to / 1000;
@@ -323,12 +358,14 @@ export class Player {
}
toggleShuffle() {
if (this.listening?.locked) return;
this.queue.setShuffle(!this.queue.shuffle);
this.syncQueue();
this.saveLocal();
}
cycleRepeat() {
if (this.listening?.locked) return;
const order: RepeatMode[] = ['off', 'all', 'one'];
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
this.syncQueue();
@@ -363,13 +400,14 @@ export class Player {
// --- загрузка трека ---
private async load(autoplay: boolean, startMs = 0) {
private async load(autoplay: boolean, startMs = 0, current = () => true) {
this.cancelCrossfade();
const track = this.queue.current;
if (!track?.stream_url) return;
const version = this.restoreVersion;
if (this.offline) {
await this.offline.ready.catch(() => undefined);
if (this.queue.current !== track || version !== this.restoreVersion) return;
if (!current() || this.queue.current !== track || version !== this.restoreVersion) return;
}
this.offline?.keep([track.id]);
const source = this.offline?.local(track).stream_url ?? track.stream_url;
@@ -396,6 +434,7 @@ export class Player {
try {
const resumed = this.engine.resume();
if (resumed) await resumed;
if (!current() || version !== this.restoreVersion) return;
await this.audio.play();
} catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
@@ -449,10 +488,25 @@ export class Player {
this.engine.applyLoudness(user * normalizeGain(this.state.track?.features?.loudness_lufs, this.normalizeOn()) * mix);
}
/** A cancelled transition must not keep a second source audible or change the new queue. */
private cancelCrossfade() {
this.fadeVersion++;
const wasFading = this.fading;
this.fading = false;
this.ignoreEnded = false;
this.overlap.pause();
this.overlap.volume = 0;
if (this.overlap.hasAttribute('src')) {
this.overlap.removeAttribute('src');
this.overlap.load();
}
if (wasFading) this.applyLoudness();
}
/** В конце трека накладывает следующий на crossfade_s секунд. */
private maybeCrossfade(positionMs: number) {
const cf = this.crossfadeSeconds();
if (this.fading || cf <= 0 || this.state.repeat === 'one') return;
if (this.listening?.locked || this.fading || cf <= 0 || this.state.repeat === 'one') return;
const dur = this.audio.duration;
if (!Number.isFinite(dur)) return;
const left = dur * 1000 - positionMs;
@@ -460,15 +514,16 @@ export class Player {
const next = this.state.queue[this.state.index + 1];
if (!next?.stream_url || !next.available) return;
this.fading = true;
const version = ++this.fadeVersion;
this.offline?.keep([this.queue.current!.id, next.id]);
this.overlap.src = this.offline?.local(next).stream_url ?? next.stream_url;
this.overlap.volume = 0;
void this.overlap.play().catch(() => {
this.fading = false;
if (version === this.fadeVersion) this.cancelCrossfade();
});
const started = performance.now();
const step = () => {
if (!this.fading) return;
if (!this.fading || version !== this.fadeVersion) return;
const p = Math.min(1, (performance.now() - started) / (cf * 1000));
this.applyLoudness(1 - p);
this.overlap.volume = Math.min(1, (this.state.muted ? 0 : this.state.volume) * normalizeGain(next.features?.loudness_lufs, this.normalizeOn()) * p);
@@ -476,12 +531,12 @@ export class Player {
requestAnimationFrame(step);
return;
}
this.finishCrossfade(next);
this.finishCrossfade(next, version);
};
requestAnimationFrame(step);
}
private finishCrossfade(next: Track) {
private finishCrossfade(next: Track, version: number) {
const at = this.overlap.currentTime;
this.ignoreEnded = true;
this.audio.pause();
@@ -510,6 +565,7 @@ export class Player {
this.media.updateMediaMetadata(next);
void this.offline?.save(next).catch(() => undefined);
void this.audio.play().finally(() => {
if (version !== this.fadeVersion) return;
this.fading = false;
this.ignoreEnded = false;
});
@@ -649,8 +705,13 @@ export class Player {
}
dispose() {
this.listening?.dispose();
this.listening = null;
this.activity?.dispose();
this.activity = null;
this.restoreVersion++;
this.wavePolicy.activate(null);
this.fadeVersion++;
this.fading = false;
this.finishAttempt('skip');
this.engine.dispose();
@@ -661,4 +722,30 @@ export class Player {
this.media.dispose();
void this.events.flush();
}
beginListeningAudio() {
if (this.state.listening?.room?.playing) {
this.applyingSync = true; try { this.play(); } finally { this.applyingSync = false; }
}
this.listening?.beginAudio();
}
private async syncListening(room: ListeningRoom, position: number, current: () => boolean) {
if (!room.track_id) { this.cancelCrossfade(); this.audio.pause(); return; }
if (this.remoteRoom !== room.id || this.state.track?.id !== room.track_id || this.state.queue.length !== 1 || this.state.wave) {
const track = unwrap(await this.session.client.GET('/api/v1/tracks/{id}', { params: { path: { id: room.track_id } } }));
if (!current()) return;
this.remoteRoom = room.id;
this.restoreVersion++; this.finishAttempt('skip'); this.leaveWave(); this.context = { type: 'queue' };
this.queue.set([track], 0); this.queue.repeat = 'off'; this.queue.shuffle = false; this.syncQueue();
await this.load(room.playing, Math.min(position, Math.max(0, track.duration_ms - 100)), current);
return;
}
if (!current()) return;
this.applyingSync = true;
try {
if (Math.abs(this.state.positionMs - position) > 1500) this.seek(Math.min(position, Math.max(0, this.state.durationMs - 100)));
if (room.playing && !this.state.playing) this.play();
if (!room.playing && this.state.playing) this.pause();
} finally { this.applyingSync = false; }
}
}
+1
View File
@@ -1,6 +1,7 @@
import type { WaveInfo, Track, PlayContext, RepeatMode } from '@pipu/core';
export interface PlayerState {
listening?: import('@pipu/core').ListeningClientState;
wave: WaveInfo | null;
track: Track | null;
queue: Track[];
+3
View File
@@ -23,6 +23,9 @@ export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/
export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.svg';
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
export { default as arrowBackIcon } from '@material-symbols/svg-400/rounded/arrow_back.svg';
export { default as chatIcon } from '@material-symbols/svg-400/rounded/chat_bubble.svg';
export { default as smileIcon } from '@material-symbols/svg-400/rounded/sentiment_satisfied.svg';
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
+15
View File
@@ -420,6 +420,21 @@
background: var(--hover);
}
.menuBadge {
margin-left: auto;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
background: var(--accent-fill);
color: var(--on-accent, #fff);
font-size: 11px;
font-weight: 600;
line-height: 20px;
text-align: center;
font-variant-numeric: tabular-nums;
}
.menuDanger {
color: var(--danger);
}
+22 -7
View File
@@ -149,21 +149,25 @@ export function Dialog({
onClose,
children,
footer,
className,
}: {
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
className?: string;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const titleId = useId();
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
const prev = document.activeElement as HTMLElement | null;
const first = ref.current?.querySelector<HTMLElement>('input, select, button:not([data-close])');
(first ?? ref.current)?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
if (e.key === 'Escape') closeRef.current();
if (e.key === 'Tab') {
const controls = [...(ref.current?.querySelectorAll<HTMLElement>(
'a[href], button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), [tabindex="0"]',
@@ -182,10 +186,10 @@ export function Dialog({
document.removeEventListener('keydown', onKey);
prev?.focus();
};
}, [onClose]);
}, []);
return createPortal(
<div className={s.overlay} onMouseDown={(e) => e.target === e.currentTarget && onClose()}>
<div ref={ref} className={s.dialog} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div ref={ref} className={cx(s.dialog, className)} role="dialog" aria-modal="true" aria-labelledby={titleId} tabIndex={-1}>
<div className={s.dialogHead}>
<h2 id={titleId}>{title}</h2>
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
@@ -200,7 +204,7 @@ export function Dialog({
);
}
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
export type MenuItem = { label: string; icon?: string; danger?: boolean; badge?: number; onSelect: () => void };
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
export function Menu({
@@ -210,6 +214,7 @@ export function Menu({
header,
align = 'end',
triggerClassName,
portal = false,
}: {
label: string;
trigger: ReactNode;
@@ -217,8 +222,10 @@ export function Menu({
header?: ReactNode;
align?: 'start' | 'end';
triggerClassName?: string;
portal?: boolean;
}) {
const [open, setOpen] = useState(false);
const [position, setPosition] = useState<CSSProperties>();
const ref = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -246,7 +253,7 @@ export function Menu({
els[next]?.focus();
};
const menu = (
<div ref={menuRef} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
<div ref={menuRef} style={portal && !window.matchMedia?.('(max-width: 767px)').matches ? position : undefined} className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
@@ -261,6 +268,7 @@ export function Menu({
>
{it.icon && <Icon src={it.icon} size={18} />}
{it.label}
{it.badge ? <span className={s.menuBadge}>{it.badge > 99 ? '99+' : it.badge}</span> : null}
</button>
))}
</div>
@@ -273,11 +281,18 @@ export function Menu({
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
onClick={e => {
if (portal) {
const rect = e.currentTarget.getBoundingClientRect();
const bottom = Math.max(12, window.innerHeight - rect.top + 6);
setPosition({ position: 'fixed', zIndex: 60, top: 'auto', bottom, right: Math.max(12, window.innerWidth - rect.right), maxHeight: Math.max(120, window.innerHeight - bottom - 12), overflowY: 'auto' });
}
setOpen(v => !v);
}}
>
{trigger}
</button>
{open && (window.matchMedia?.('(max-width: 767px)').matches
{open && (portal || window.matchMedia?.('(max-width: 767px)').matches
? createPortal(menu, document.body)
: menu)}
</div>
+29 -1
View File
@@ -1,4 +1,4 @@
import { fakeApi, makeUser } from './fakeApi';
import { albumTracks, fakeApi, makeUser } from './fakeApi';
import { mobileFixtures } from './mobileFixtures';
/** Подменяет fetch на фейковый API. Включается только при сборке с VITE_E2E=1. */
@@ -6,9 +6,37 @@ export function installE2EFetch() {
const api = fakeApi([
makeUser('admin', 'admin', { must_change_credentials: true }),
makeUser('alice', 'user'),
makeUser('update-manager', 'user', { manages_updates: true }),
makeUser('mobile-admin', 'admin', { reviews_uploads: true }),
makeUser('moderator', 'moderator', { reviews_uploads: true }),
]);
// A ready conversation lets browser tests exercise messages and music sharing.
const alice = [...api.users.values()].find(u => u.username === 'alice')!;
const friend = [...api.users.values()].find(u => u.username === 'moderator')!;
api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true });
const listeningScenario = localStorage.getItem('pipu.e2e.listening');
if (listeningScenario) {
const device = crypto.randomUUID();
api.presence.set(friend.id, Date.now());
api.activityVisibility.set(friend.id, 'friends');
api.activity.set(friend.id, { device_id: device, track_id: albumTracks[0]!.id, track_title: albumTracks[0]!.title, artist: 'Кино', context: 'other', position_ms: 0 });
api.listeningPreferences.set(friend.id, { requests: listeningScenario === 'restricted' ? 'nobody' : 'friends', auto_accept: listeningScenario === 'automatic' ? 'friends' : 'nobody', autoplay: true });
if (listeningScenario === 'incoming') {
const roomId = crypto.randomUUID();
const requestId = crypto.randomUUID();
api.listeningRooms.set(roomId, { id: roomId, owner_id: friend.id, owner_name: friend.username, device_id: device, track_id: albumTracks[0]!.id, position_ms: 0, playing: false, updated_at: new Date().toISOString(), participants: [] });
api.listeningMembers.set(friend.id, roomId);
api.listeningRequests.set(requestId, { id: requestId, owner_id: friend.id, guest_id: alice.id, sender_id: friend.id, sender_name: friend.username, recipient_id: alice.id, kind: 'invite', expires_at: new Date(Date.now() + 120_000).toISOString() });
}
}
if (localStorage.getItem('pipu.e2e.notifications') === '1') {
const kinds = ['message', 'friend_request', 'friend_accepted', 'music'] as const;
for (let index = 0; index < 28; index++) api.notifications.push({
id: `event-${index}`, recipient: alice.id, actor_id: friend.id,
actor_name: index % 3 === 0 ? 'JoyRacoon' : index % 3 === 1 ? 'Александра' : 'Константин',
kind: kinds[index % kinds.length]!, created_at: new Date(Date.now() - index * 36e5).toISOString(), read: index >= 4,
});
}
const orig = window.fetch.bind(window);
window.fetch = async (input, init) => {
const req = new Request(input, init);
+171 -1
View File
@@ -78,13 +78,58 @@ export function fakeApi(initial: FakeUser[]) {
const reactions = new Map<string, 'like' | 'dislike'>();
const playlistOwners = new Map<string, string>();
const playlists = new Map<string, components['schemas']['Playlist']>();
const friendships = new Map<string, { sender: string; accepted: boolean }>();
const blocks = new Set<string>();
const messages: (components['schemas']['DirectMessage'] & { recipient: string })[] = [];
const notifications: (components['schemas']['SocialNotification'] & { recipient: string })[] = [];
const activityVisibility = new Map<string, components['schemas']['ActivityVisibility']>();
const friendRecommendations = new Map<string, boolean>();
const activity = new Map<string, components['schemas']['ListeningActivity'] & { device_id: string; position_ms: number }>();
const listeningPreferences = new Map<string, components['schemas']['ListeningPreferences']>();
const listeningRooms = new Map<string, components['schemas']['ListeningRoom']>();
const listeningMembers = new Map<string, string>();
const listeningRequests = new Map<string, components['schemas']['ListeningRequest'] & { guest_id: string }>();
const typing = new Map<string, number>();
const presence = new Map<string, number>();
const defaultListening = { requests: 'nobody', auto_accept: 'nobody', autoplay: true } as const;
const pair = (a: string, b: string) => [a, b].sort().join(':');
const isBlocked = (a: string, b: string) => blocks.has(`${a}:${b}`) || blocks.has(`${b}:${a}`);
const socialPerson = (me: string, p: FakeUser): components['schemas']['SocialPerson'] => {
const f = friendships.get(pair(me, p.id));
const last = f?.accepted && !isBlocked(me, p.id) ? messages.filter(m => pair(m.sender_id, m.recipient) === pair(me, p.id)).at(-1) : undefined;
const visible = activityVisibility.get(p.id) === 'everyone' || activityVisibility.get(p.id) === 'friends' && f?.accepted;
return { id: p.id, username: p.username, display_name: p.display_name ?? p.username, ...(visible && activity.has(p.id) && !isBlocked(me,p.id) ? { activity: activity.get(p.id) } : {}),
...(last ? { last_message: { id: last.id, sender_id: last.sender_id, text: last.deleted ? '' : last.text.slice(0, 160), attachment_kind: last.attachment_kind, created_at: last.created_at, deleted: last.deleted ?? false } } : {}),
relationship: blocks.has(`${me}:${p.id}`) ? 'blocked' : f?.accepted ? 'friend' : f ? f.sender === me ? 'outgoing' : 'incoming' : 'none',
online: !!f?.accepted && !isBlocked(me, p.id) && (presence.get(p.id) ?? 0) > Date.now() - 45_000,
unread: messages.filter(m => m.recipient === me && m.sender_id === p.id && !m.read).length };
};
const notify = (recipient: string, actor: FakeUser, kind: components['schemas']['SocialNotification']['kind']) => notifications.unshift({
id: crypto.randomUUID(), recipient, actor_id: actor.id, actor_name: actor.display_name ?? actor.username, kind, read: false, created_at: new Date().toISOString(),
});
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 rawLocal = configValues[entry.key] ?? entry.local;
const local = entry.secret && rawLocal ? '••••••••' : rawLocal;
return { ...entry, local, effective: entry.env ?? local ?? entry.default_value, source: entry.env !== null ? 'env' : local !== null ? 'local' : 'default' };
}) });
const me = (u: FakeUser): Me => ({
id: u.id,
username: u.username,
display_name: u.display_name,
role: u.role,
reviews_uploads: u.reviews_uploads,
manages_updates: u.role === 'admin' || !!u.manages_updates,
locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password,
@@ -107,6 +152,33 @@ export function fakeApi(initial: FakeUser[]) {
calls.push({ method: req.method, path, body });
const route = `${req.method} ${path}`;
const user = current ? users.get(current) : undefined;
if (path.startsWith('/api/v1/admin/server/')) {
if (!user) return problem(401, 'auth.unauthorized');
const configs = path.includes('/config');
if (configs ? user.role !== 'admin' : user.role !== 'admin' && !user.manages_updates) return problem(403, 'auth.forbidden');
if (route === 'GET /api/v1/admin/server/updates') {
const channel = url.searchParams.get('channel') ?? 'latest';
const selected = url.searchParams.get('version') ?? '';
const target = channel === 'version' ? selected : channel === 'main' ? 'main' : 'v1.10.0';
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,
current_commit: 'a'.repeat(40), target_version: target, target_commit: target === 'v1.9.0' || target === 'main' ? 'a'.repeat(40) : 'b'.repeat(40),
update_state: !target ? 'select_version' : target === 'v1.9.0' || target === 'main' ? 'current' : 'available' });
}
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' };
if (configs) configPending = false;
return json(managementJob, 202);
}
}
switch (route) {
case 'GET /api/v1/server-info':
@@ -158,6 +230,100 @@ export function fakeApi(initial: FakeUser[]) {
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
if (path === '/api/v1/me/social/activity/visibility' && body) { activityVisibility.set(user.id, body.visibility as components['schemas']['ActivityVisibility']); if (body.visibility === 'nobody') activity.delete(user.id); return new Response(null,{status:204}); }
if (path === '/api/v1/me/social/recommendations' && body) { friendRecommendations.set(user.id,!!body.enabled);return new Response(null,{status:204}); }
if (path === '/api/v1/me/social/activity' && body) {
if (activityVisibility.get(user.id) !== undefined && activityVisibility.get(user.id) !== 'nobody' && body.playing) {
const track = albumTracks.find(t => t.id === body.track_id); const playlist = playlists.get(String(body.playlist_id));
if (track) activity.set(user.id,{track_id:track.id,track_title:track.title,artist:'Кино',context: body.context as 'wave'|'playlist'|'other',device_id:String(body.device_id),position_ms:Number(body.position_ms??0),...(playlist ? {playlist_id:playlist.id,playlist_name:playlist.name} : {})});
} else activity.delete(user.id);
return new Response(null,{status:204});
}
const activityPlaylistPath=/^\/api\/v1\/social\/activity\/([^/]+)\/playlist$/.exec(path);
if (activityPlaylistPath) {
const owner=users.get(activityPlaylistPath[1]!); const a=owner ? socialPerson(user.id,owner).activity : undefined; const p=a?.playlist_id ? playlists.get(a.playlist_id) : undefined;
return p ? json({...p,share_id:null}) : problem(404,'social.activity_unavailable');
}
if (path === '/api/v1/me/social/listening/preferences' && body) { listeningPreferences.set(user.id,body as components['schemas']['ListeningPreferences']);return new Response(null,{status:204}); }
if (path === '/api/v1/me/social/listening') {
presence.set(user.id, Date.now());
const room=listeningRooms.get(listeningMembers.get(user.id) ?? '');
if(room)room.participants=[...listeningMembers].filter(([,id])=>id===room.id).map(([id])=>({id,name:users.get(id)?.display_name??users.get(id)?.username??id}));
return json({preferences:listeningPreferences.get(user.id)??defaultListening,room:room??null,requests:[...listeningRequests.values()].filter(r=>r.recipient_id===user.id||r.sender_id===user.id),server_time:new Date().toISOString()});
}
if (path === '/api/v1/me/social/listening/state' && body) {
const room=listeningRooms.get(String(body.room_id));if(!room||room.owner_id!==user.id||room.device_id!==body.device_id)return problem(403,'social.unavailable');
Object.assign(room,{track_id:body.track_id??null,position_ms:body.position_ms,playing:body.playing,updated_at:new Date().toISOString()});return new Response(null,{status:204});
}
if (path === '/api/v1/me/social/listening/actions' && body) {
const target=String(body.target_id);const op=String(body.action);
if(op==='join'||op==='invite'){
const host=op==='join'?target:user.id; const guest=op==='join'?user.id:target; const prefs=listeningPreferences.get(target)??defaultListening;
const friend=!!friendships.get(pair(user.id,target))?.accepted;
if(isBlocked(user.id,target)||!(prefs.requests==='everyone'||prefs.requests==='friends'&&friend))return problem(403,'social.listening_unavailable');
if ((presence.get(target) ?? 0) <= Date.now() - 45_000) return problem(409, 'social.person_offline');
const guestRoom = listeningRooms.get(listeningMembers.get(guest) ?? '');
const hostRoom = listeningRooms.get(listeningMembers.get(host) ?? '');
if (guestRoom && guestRoom.owner_id !== host || hostRoom && hostRoom.owner_id !== host) return problem(409, 'social.listening_busy');
if (guestRoom?.owner_id === host || listeningRequests.has(String(body.id))) return new Response(null, { status: 204 });
if ([...listeningRequests.values()].some(r => r.owner_id === host && r.guest_id === guest)) return problem(409, 'social.listening_pending');
let room=[...listeningRooms.values()].find(r=>r.owner_id===host);
const source=activity.get(host);if(!room){room={id:crypto.randomUUID(),owner_id:host,owner_name:users.get(host)?.username??host,device_id:String(op==='join'?source?.device_id:body.device_id),track_id:String(op==='join'?source?.track_id:body.track_id),position_ms:Number(op==='join'?source?.position_ms:body.position_ms??0),playing:true,updated_at:new Date().toISOString(),participants:[]};listeningRooms.set(room.id,room);listeningMembers.set(host,room.id);}
if(prefs.auto_accept==='everyone'||prefs.auto_accept==='friends'&&friend)listeningMembers.set(guest,room.id);
else listeningRequests.set(String(body.id),{id:String(body.id),owner_id:host,guest_id:guest,sender_id:user.id,sender_name:user.username,recipient_id:target,kind:op,expires_at:new Date(Date.now()+120000).toISOString()});
}else if(op==='accept'||op==='decline'||op==='cancel'){
const r=listeningRequests.get(target);if(r){if(op==='accept'&&r.recipient_id===user.id){const room=[...listeningRooms.values()].find(room=>room.owner_id===r.owner_id);if(room)listeningMembers.set(r.guest_id,room.id);}listeningRequests.delete(target);}
}else if(op==='leave'||op==='end'){
const room=listeningRooms.get(target);if(room?.owner_id===user.id){listeningRooms.delete(target);for(const [u,r]of listeningMembers)if(r===target)listeningMembers.delete(u);}else listeningMembers.delete(user.id);
}
return new Response(null,{status:204});
}
const chatMeta=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/meta$/.exec(path);
if(chatMeta){const peer=chatMeta[1]!;if(!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');if(req.method==='PUT'){typing.set(`${user.id}:${peer}`,body?.typing?Date.now()+6000:0);return new Response(null,{status:204});}return json({typing:(typing.get(`${peer}:${user.id}`)??0)>Date.now()});}
const updateMessage=/^\/api\/v1\/me\/social\/chats\/([^/]+)\/messages\/([^/]+)$/.exec(path);
if(updateMessage&&body){
const peer=updateMessage[1]!;const m=messages.find(m=>m.id===updateMessage[2]&&pair(m.sender_id,m.recipient)===pair(user.id,peer));
if(!m||!friendships.get(pair(user.id,peer))?.accepted||isBlocked(user.id,peer))return problem(403,'social.unavailable');
if(body.action!=='react'&&m.sender_id!==user.id)return problem(403,'social.unavailable');
if(body.action==='edit'){m.text=String(body.text);m.edited_at=new Date().toISOString();}
if(body.action==='delete'){m.deleted=true;m.text='';m.attachment_id=undefined;m.attachment_kind='none';m.reactions=[];}
if(body.action==='react'){const reactions=m.reactions??[];const old=reactions.find(r=>r.emoji===body.emoji);m.reactions=old?reactions.filter(r=>r!==old):[...reactions,{emoji:String(body.emoji),count:1,mine:true}];}
return new Response(null,{status:204});
}
if (path === '/api/v1/me/social') return json({
activity_visibility: activityVisibility.get(user.id) ?? 'nobody',
friends_recommendations: friendRecommendations.get(user.id) ?? true,
people: [...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !blocks.has(`${p.id}:${user.id}`)).map(p => socialPerson(user.id, p)).filter(p => p.relationship !== 'none'),
notifications: notifications.filter(n => n.recipient === user.id && !isBlocked(user.id, n.actor_id)),
unread: notifications.filter(n => n.recipient === user.id && !n.read && !isBlocked(user.id, n.actor_id)).length,
});
if (path === '/api/v1/me/social/people') {
const q = url.searchParams.get('q')?.toLowerCase() ?? '';
return json([...users.values()].filter(p => p.id !== user.id && !p.is_blocked && !isBlocked(user.id,p.id) && `${p.username} ${p.display_name ?? ''}`.toLowerCase().includes(q)).map(p => socialPerson(user.id,p)));
}
if (path === '/api/v1/me/social/actions' && body) {
const peer = String(body.target_id); const action = body.action; const p = users.get(peer); const f = friendships.get(pair(user.id,peer));
if (action === 'request' && p && !isBlocked(user.id,peer) && !f) { friendships.set(pair(user.id,peer),{ sender:user.id,accepted:false }); notify(peer,user,'friend_request'); }
if (action === 'accept' && f && f.sender !== user.id && !f.accepted) { f.accepted=true; notify(peer,user,'friend_accepted'); }
if (action === 'remove' || action === 'block') friendships.delete(pair(user.id,peer));
if (action === 'block') blocks.add(`${user.id}:${peer}`);
if (action === 'unblock') blocks.delete(`${user.id}:${peer}`);
if (action === 'read_notification') notifications.filter(n => n.recipient === user.id && n.id === peer).forEach(n => { n.read=true; });
if (action === 'read_chat') { messages.filter(m => m.recipient === user.id && m.sender_id === peer && m.seq <= Number(body.up_to_seq)).forEach(m => { m.read=true; }); notifications.filter(n => n.recipient === user.id && n.actor_id === peer).forEach(n => { n.read=true; }); }
return new Response(null,{status:204});
}
const chatPath = /^\/api\/v1\/me\/social\/chats\/([^/]+)$/.exec(path);
if (chatPath) {
const peer=chatPath[1]!;if (!friendships.get(pair(user.id,peer))?.accepted || isBlocked(user.id,peer)) return problem(403,'social.unavailable');
if (req.method === 'GET') {const q=url.searchParams.get('q')?.toLowerCase()??'';return json(messages.filter(m => pair(m.sender_id,m.recipient)===pair(user.id,peer) && (!url.searchParams.has('before') || m.seq<Number(url.searchParams.get('before'))) && (!q||!m.deleted&&m.text.toLowerCase().includes(q)) && (url.searchParams.get('music') !== 'true' || !m.deleted && m.attachment_kind !== 'none')).slice(-50).map(m=>({...m,reply_preview:m.reply_to?messages.find(r=>r.id===m.reply_to)?.text:undefined})));}
if (req.method === 'POST' && body) {
const old=messages.find(m => m.id===body.id);if(old)return json(old);
const message={...body,recipient:peer,sender_id:user.id,seq:messages.length+1,created_at:new Date().toISOString(),read:false} as typeof messages[number];
messages.push(message);notify(peer,user,body.attachment_kind==='none'?'message':'music');return json(message);
}
}
const sharedPath = /^\/api\/v1\/shared\/playlists\/([^/]+)$/.exec(path);
if (sharedPath && req.method === 'GET') {
@@ -323,6 +489,10 @@ export function fakeApi(initial: FakeUser[]) {
return problem(422, 'auth.username_taken', { errors: [{ field: 'username', code: 'auth.username_taken' }] });
target.username = username;
}
if (typeof body?.manages_updates === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.manages_updates = body.manages_updates;
}
if (typeof body?.reviews_uploads === 'boolean') {
if (user.role !== 'admin') return problem(403, 'auth.forbidden');
target.reviews_uploads = body.reviews_uploads;
@@ -349,5 +519,5 @@ export function fakeApi(initial: FakeUser[]) {
return problem(404, 'common.not_found');
};
return { fetch: fetchImpl as typeof fetch, calls, users, playlists, signOut: () => (current = null) };
return { presence, fetch: fetchImpl as typeof fetch, calls, users, playlists, friendships, notifications, messages, activityVisibility, activity, friendRecommendations, listeningPreferences, listeningRooms, listeningMembers, listeningRequests, signOut: () => (current = null) };
}
+1
View File
@@ -24,6 +24,7 @@ const file: components['schemas']['UploadFile'] = {
export const mobileFixtures: Record<string, unknown> = {
'/api/v1/admin/sources': { items: [source] },
[`/api/v1/admin/sources/${source.id}`]: source,
[`/api/v1/admin/sources/${source.id}/cover-stats`]: { files_total: 120, files_with_cover: 87, albums_pending: 3 },
[`/api/v1/admin/sources/${source.id}/patterns`]: { patterns: [] },
'/api/v1/admin/uploads': { items: [file], next_cursor: null },
'/api/v1/uploads/quota': { limit_bytes: 10000000000, used_bytes: 42000000, per_user_limit_bytes: null,
+27 -1
View File
@@ -170,6 +170,22 @@
display: none;
}
.navSubsection {
min-height: 40px;
margin: 2px 0;
font-size: 13px;
font-weight: 600;
letter-spacing: normal;
text-transform: none;
color: var(--text2);
}
.navSubgroup > .navItem {
margin-left: 12px;
padding-left: 12px;
border-left: 1px solid var(--line);
}
.navItem {
display: flex;
align-items: center;
@@ -257,6 +273,11 @@
animation: pipuIn 0.2s ease-out;
}
/* Communication owns its scroll areas so its composer stays above either player. */
.socialContent { display: flex; flex-direction: column; overflow: hidden; }
.socialContent > :not(.routeIn) { flex-shrink: 0; }
.socialContent .routeIn { display: flex; flex: 1; min-height: 0; }
@media (max-width: 1023px) {
.shell {
--side-w: 72px;
@@ -290,6 +311,9 @@
.navGroupFolded {
display: contents;
}
.navSubsection { min-height: 0; }
.navSubgroup > .navItem { margin-left: 0; padding-left: 0; border-left: 0; }
}
.mobileBottom { display: none; }
@@ -298,7 +322,7 @@
.shell, .shell.island {
--side-w: 0px;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr) 72px calc(64px + env(safe-area-inset-bottom, 0px));
grid-template-rows: auto minmax(0, 1fr) auto calc(64px + env(safe-area-inset-bottom, 0px));
grid-template-areas: 'top' 'content' 'player' 'mobile';
}
@@ -353,6 +377,8 @@
border: 0; pointer-events: none;
}
.mobileMenu .navSectionTitle { display: block; }
.mobileMenu .navSubsection { color: var(--text2); font-size: 13px; }
.mobileMenu .navSubgroup > .navItem { margin-left: 12px; border-left: 1px solid var(--line); }
.mobileMenu .navChevron { display: none; }
.content, .island .content { padding: 20px 16px 24px; }
}
+64 -28
View File
@@ -2,7 +2,7 @@ import { queueIcon } from '../shared/ui/icons';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link, Outlet, useNavigate, useRouterState } from '@tanstack/react-router';
import { Suspense, useCallback, useState, type ReactNode } from 'react';
import { Suspense, useCallback, useEffect, useId, useState, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
@@ -38,8 +38,12 @@ import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
import { DesktopUpdateDialog, DesktopOfflineBanner } from '../features/desktop/DesktopFeatures';
import { useSocialUnread, useSocialProfileMenu, SocialProvider } from '../features/social/Social';
import { MiniSocial } from '../features/social/MiniSocial';
import { ListeningBanner } from '../features/social/Listening';
type NavPath =
| '/friends'
| '/'
| '/admin/users'
| '/admin/audit'
@@ -48,6 +52,8 @@ type NavPath =
| '/admin/uploads'
| '/uploads/new'
| '/uploads/mine'
| '/admin/updates'
| '/admin/config'
| '/admin/settings'
| '/collection/playlists'
| '/collection/downloads'
@@ -104,6 +110,8 @@ function NavSection({
children,
defaultFolded = false,
mobile = false,
nested = false,
paths,
}: {
id: string;
title: string;
@@ -111,12 +119,21 @@ function NavSection({
children: ReactNode;
defaultFolded?: boolean;
mobile?: boolean;
nested?: boolean;
paths?: readonly string[];
}) {
const groupId = useId();
const pathname = useRouterState({ select: (state) => state.location.pathname });
const active = paths?.some((path) => pathname === path || pathname.startsWith(`${path}/`)) ?? false;
const [folded, setFolded] = useState(() => {
if (active) return false;
if (readIds(FOLDED_KEY).includes(id)) return true;
if (readIds(TOUCHED_KEY).includes(id)) return false;
return defaultFolded;
});
useEffect(() => {
if (active) setFolded(false);
}, [active, pathname]);
const toggle = () => {
const next = !folded;
setFolded(next);
@@ -132,15 +149,15 @@ function NavSection({
return (
<>
{mobile ? (
<h3 className={s.navSection}><span className={s.navSectionTitle}>{title}</span></h3>
<h3 className={cx(s.navSection, nested && s.navSubsection)}><span className={s.navSectionTitle}>{title}</span></h3>
) : (
<button type="button" className={s.navSection} aria-expanded={!folded} onClick={toggle}>
<button type="button" className={cx(s.navSection, nested && s.navSubsection)} aria-expanded={!folded} aria-controls={groupId} onClick={toggle}>
<span className={s.navSectionTitle}>{title}</span>
{folded && badge ? <span className={s.navBadge}>{badge > 99 ? '99+' : badge}</span> : null}
<Icon src={chevronRightIcon} size={16} className={cx(s.navChevron, !folded && s.navChevronOpen)} />
</button>
)}
<div className={cx(s.navGroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
<div id={groupId} role="group" aria-label={title} className={cx(s.navGroup, nested && s.navSubgroup, !mobile && folded && s.navGroupFolded)}>{children}</div>
</>
);
}
@@ -148,16 +165,18 @@ function NavSection({
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() {
return useAuthState().status === 'signed_in' ? (
<PlayerProvider>
<SocialProvider><PlayerProvider>
<DesktopUpdateDialog />
<Shell />
</PlayerProvider>
</PlayerProvider></SocialProvider>
) : null;
}
function Shell() {
const { t } = useTranslation();
const user = useUser();
const socialUnread = useSocialUnread();
const socialProfile = useSocialProfileMenu();
const session = useSession();
const desktop = useDesktop();
const navigate = useNavigate();
@@ -170,11 +189,13 @@ function Shell() {
const reviews = user.reviews_uploads;
const pending = usePendingUploads(reviews);
const unseen = useUnseenUploads(staff);
const uploadBadge = (reviews ? pending ?? 0 : 0) + (unseen ?? 0);
const [mobileOpen, setMobileOpen] = useState(false);
const closeMobile = useCallback(() => setMobileOpen(false), []);
const navigation = (mobile = false) => (
<>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<NavLink to="/friends" label={t('social.title')} icon={groupIcon} activeIcon={groupFillIcon} badge={socialUnread} />
<NavSection mobile={mobile} id="collection" title={t('nav.collection')}>
<NavLink to="/collection/playlists" label={t('playlists.title')} icon={queueIcon} activeIcon={queueIcon} />
{desktop && <NavLink to="/collection/downloads" label={t('desktop.offline')} icon={downloadIcon} activeIcon={downloadIcon} />}
@@ -197,29 +218,40 @@ function Shell() {
badge={pending}
/>
)}
{!staff && user.manages_updates && <NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />}
{staff && (
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={reviews ? pending : undefined} defaultFolded>
<NavSection mobile={mobile} id="admin" title={t('nav.admin')} badge={uploadBadge} paths={['/admin', '/uploads']} defaultFolded>
<NavLink to="/admin/users" label={t('nav.users')} icon={groupIcon} activeIcon={groupFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
{reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
{user.role === 'admin' && (
<>
<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} />
</>
<NavSection mobile={mobile} nested id="admin-media" title={t('nav.admin_media')} paths={['/admin/library', '/admin/sources']} defaultFolded>
<NavLink to="/admin/library" label={t('nav.library')} icon={libraryIcon} activeIcon={libraryFillIcon} />
{user.role === 'admin' && (
<NavLink to="/admin/sources" label={t('nav.sources')} icon={storageIcon} activeIcon={storageFillIcon} />
)}
</NavSection>
<NavSection mobile={mobile} nested id="admin-uploads" title={t('nav.admin_uploads')} badge={uploadBadge} paths={['/uploads', '/admin/uploads']} defaultFolded>
<NavLink to="/uploads/new" label={t('nav.suggest')} icon={uploadIcon} activeIcon={uploadFillIcon} />
<NavLink to="/uploads/mine" label={t('nav.my_uploads')} icon={myUploadsIcon} activeIcon={myUploadsFillIcon} badge={unseen} />
{reviews && (
<NavLink
to="/admin/uploads"
label={t('nav.upload_queue')}
icon={inboxIcon}
activeIcon={inboxFillIcon}
badge={pending}
/>
)}
</NavSection>
{(user.role === 'admin' || user.manages_updates) && (
<NavSection mobile={mobile} nested id="admin-system" title={t('nav.admin_system')} paths={['/admin/updates', '/admin/config', '/admin/settings', '/admin/audit']} defaultFolded>
<NavLink to="/admin/updates" label={t('nav.server_updates')} icon={downloadIcon} activeIcon={downloadIcon} />
{user.role === 'admin' && (
<>
<NavLink to="/admin/config" label={t('nav.server_config')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/settings" label={t('nav.admin_settings')} icon={tuneIcon} activeIcon={tuneFillIcon} />
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
</>
)}
</NavSection>
)}
</NavSection>
)}
@@ -260,6 +292,7 @@ function Shell() {
</div>
}
items={[
...socialProfile.items,
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
...(!desktop
? [{ label: t('profile.download_apps'), icon: downloadIcon, onSelect: () => void navigate({ to: '/download' }) }]
@@ -291,7 +324,9 @@ function Shell() {
</Dialog>
)}
{socialProfile.dialogs}
<ShellMain />
<MiniSocial />
<PlayerBar variant={island ? 'island' : 'bar'} />
</div>
@@ -301,8 +336,9 @@ function Shell() {
function ShellMain() {
const key = useRouterState({ select: (st) => st.location.pathname });
return (
<main id="content" className={s.content} tabIndex={-1}>
<main id="content" className={`${s.content} ${key === '/friends' ? s.socialContent : ''}`} tabIndex={-1}>
<DesktopOfflineBanner />
<ListeningBanner />
<div key={key} className={s.routeIn}>
<Suspense fallback={<PageSkeleton />}>
<Outlet />
+35 -13
View File
@@ -259,7 +259,28 @@ a.source:hover {
}
.play[aria-busy='true'] {
opacity: 0.7;
opacity: 0.85;
}
.playSpinner {
width: 22px;
height: 22px;
border: 3px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: playSpin 0.7s linear infinite;
}
@keyframes playSpin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.playSpinner {
animation-duration: 2s;
}
}
.progress {
@@ -413,28 +434,29 @@ a.source:hover {
}
@media (max-width: 767px) {
.bar { grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px; padding: 0 8px; }
.bar {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas: 'now now' 'controls actions';
gap: 4px 8px;
min-height: 72px;
padding: 8px 12px;
}
.bar.island { position: relative; left: auto; bottom: auto; width: auto; height: auto;
transform: none; margin: 0 8px; border-radius: 16px; }
.empty { grid-column: 1 / -1; }
.empty { grid-column: 1 / -1; grid-row: 1 / -1; }
.placeholder { flex: none; }
.now { gap: 8px; }
.now { grid-area: now; gap: 10px; }
.center { grid-area: controls; justify-self: start; }
.now > :first-child { width: 44px !important; height: 44px !important; }
.source { display: none; }
.controls { gap: 2px; }
.play, .icon { width: 44px; height: 44px; }
.right { display: flex; gap: 0; }
.right { grid-area: actions; display: flex; gap: 0; }
.right > :not(.queueButton):not(.shareButton):not(.playlistButton) { display: none; }
.error { position: absolute; bottom: calc(100% + 8px); left: 8px; right: 8px;
padding: 8px 12px; background: var(--surface3); border-radius: 10px; text-align: center; }
.drawer, .drawerIsland { bottom: calc(144px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 180px - env(safe-area-inset-bottom, 0px)); }
}
@media (max-width: 359px) {
.now > :first-child { display: none; }
/* share + очередь + три кнопки управления не оставляют месту название трека (e2e 320px). */
.shareButton { display: none; }
.drawer, .drawerIsland { bottom: calc(184px + env(safe-area-inset-bottom, 0px));
max-height: calc(100dvh - 220px - env(safe-area-inset-bottom, 0px)); }
}
.shareStatus {
+31 -48
View File
@@ -7,7 +7,9 @@ import { ArtistLinks, Cover, Equalizer } from '../entities/catalog';
import { AddToPlaylist } from '../entities/playlists';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import { shareTrack } from '../shared/lib/share';
import { trackShareUrl } from '@pipu/core';
import { desktopHandle } from '../desktop';
import { useMusicShare } from '../features/social/Social';
import {
albumIcon,
closeIcon,
@@ -36,20 +38,12 @@ import s from './PlayerBar.module.css';
export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const { t } = useTranslation();
const player = usePlayer();
const openShare = useMusicShare();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const narrow = useNarrow();
const [sheetOpen, setSheetOpen] = useState(false);
const [queueOpen, setQueueOpen] = useState(false);
const [shareStatus, setShareStatus] = useState<'copied' | 'error' | null>(null);
const [sharing, setSharing] = useState(false);
useEffect(() => {
setShareStatus(null);
}, [st.track?.id]);
useEffect(() => {
if (!shareStatus) return;
const timer = setTimeout(() => setShareStatus(null), 4000);
return () => clearTimeout(timer);
}, [shareStatus]);
const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId();
const closeQueue = useCallback(() => {
@@ -72,17 +66,8 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
}
const react = (r: 'like' | 'dislike') => void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const share = async () => {
setSharing(true);
setShareStatus(null);
try {
const result = await shareTrack(track.id, track.title);
if (result === 'copied') setShareStatus('copied');
} catch {
setShareStatus('error');
} finally {
setSharing(false);
}
const share = () => {
openShare({ attachment: { kind: 'track', id: track.id, title: track.title }, url: trackShareUrl(desktopHandle()?.server ?? window.location.origin, track.id) });
};
const repeatLabel = t(`player.repeat_${st.repeat}`);
const ctx = st.context;
@@ -111,16 +96,16 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
>
{narrow ? (
<button type="button" className={s.nowArt} aria-label={t('player.expand')} onClick={() => setSheetOpen(true)}>
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
<Cover album={track.album} size={44} className={st.playing ? s.coverLive : undefined} />
{st.playing && <span className={s.nowEq}><Equalizer playing /></span>}
</button>
) : (
<Cover album={track.album} size={52} className={st.playing ? s.coverLive : undefined} />
)}
<div className={s.meta}>
<span className={s.title}>{track.title}</span>
<span className={s.title} title={track.title}>{track.title}</span>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
<ArtistLinks artists={track.artists} hideUrlPreview />
</span>
{source &&
(ctx?.type === 'album' && ctx.id ? (
@@ -148,7 +133,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={26} />
</button>
<button
@@ -156,11 +141,11 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
className={s.play}
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
aria-busy={st.loading || undefined}
onClick={() => player.toggle()}
disabled={locked} onClick={() => player.toggle()}
>
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
{st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={28} />}
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={26} />
</button>
<button
@@ -184,7 +169,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
aria-valuetext={formatDuration(st.positionMs)}
onChange={(e) => player.seek(Number(e.target.value))}
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
@@ -198,7 +183,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<div className={s.right}>
<AddToPlaylist trackIds={[track.id]} compact className={cx(s.icon, s.playlistButton)} />
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} disabled={sharing} onClick={() => void share()}>
<button type="button" className={cx(s.icon, s.shareButton)} aria-label={t('player.share')} title={t('player.share')} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button
@@ -206,7 +191,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
disabled={locked} onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
@@ -215,7 +200,7 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
disabled={locked} onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
@@ -255,11 +240,9 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
variant={variant}
onClose={() => setSheetOpen(false)}
share={share}
sharing={sharing}
/>
)}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
{shareStatus && <span className={s.shareStatus} role="status">{t(shareStatus === 'copied' ? 'player.link_copied' : 'player.share_error')}</span>}
</footer>
);
}
@@ -281,16 +264,15 @@ function NowPlayingSheet({
variant,
onClose,
share,
sharing,
}: {
variant: 'bar' | 'island';
onClose: () => void;
share: () => Promise<void>;
sharing: boolean;
share: () => void;
}) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const track = st.track;
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -328,7 +310,7 @@ function NowPlayingSheet({
{track.title}
</Link>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
<ArtistLinks artists={track.artists} hideUrlPreview />
</span>
</div>
<div className={s.progress}>
@@ -341,7 +323,7 @@ function NowPlayingSheet({
step={1000}
value={Math.min(st.positionMs, st.durationMs || track.duration_ms)}
aria-label={t('player.progress')}
onChange={(e) => player.seek(Number(e.target.value))}
disabled={locked} onChange={(e) => player.seek(Number(e.target.value))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
@@ -350,13 +332,13 @@ function NowPlayingSheet({
<button type="button" className={cx(s.icon, track.reaction === 'dislike' && s.danger)} aria-label={t(track.reaction === 'dislike' ? 'player.undislike' : 'player.dislike')} aria-pressed={track.reaction === 'dislike'} onClick={() => react('dislike')}>
<Icon src={track.reaction === 'dislike' ? dislikeFillIcon : dislikeIcon} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<button type="button" className={s.icon} aria-label={t('player.prev')} disabled={locked} onClick={() => player.prev()}>
<Icon src={prevIcon} size={28} />
</button>
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} onClick={() => player.toggle()}>
<Icon src={st.playing ? pauseIcon : playIcon} size={32} />
<button type="button" className={s.play} aria-label={t(st.playing ? 'player.pause' : 'player.play')} aria-busy={st.loading || undefined} disabled={locked} onClick={() => player.toggle()}>
{st.loading ? <span className={s.playSpinner} aria-hidden /> : <Icon src={st.playing ? pauseIcon : playIcon} size={32} />}
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<button type="button" className={s.icon} aria-label={t('player.next')} disabled={locked} onClick={() => player.next()}>
<Icon src={nextIcon} size={28} />
</button>
<button type="button" className={cx(s.icon, track.reaction === 'like' && s.accent)} aria-label={t(track.reaction === 'like' ? 'player.unlike' : 'player.like')} aria-pressed={track.reaction === 'like'} onClick={() => react('like')}>
@@ -364,13 +346,13 @@ function NowPlayingSheet({
</button>
</div>
<div className={s.sheetExtra}>
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} onClick={() => player.toggleShuffle()}>
<button type="button" className={cx(s.icon, st.shuffle && s.accent)} aria-label={t('player.shuffle')} aria-pressed={st.shuffle} disabled={locked} onClick={() => player.toggleShuffle()}>
<Icon src={shuffleIcon} size={22} />
</button>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} onClick={() => player.cycleRepeat()}>
<button type="button" className={cx(s.icon, st.repeat !== 'off' && s.accent)} aria-label={repeatLabel} disabled={locked} onClick={() => player.cycleRepeat()}>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={22} />
</button>
<button type="button" className={s.icon} aria-label={t('player.share')} disabled={sharing} onClick={() => void share()}>
<button type="button" className={s.icon} aria-label={t('player.share')} onClick={() => void share()}>
<Icon src={shareIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t(st.muted ? 'player.unmute' : 'player.mute')} onClick={() => player.toggleMute()}>
@@ -399,6 +381,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const locked = st.listening?.locked === true;
const upcoming = st.queue.slice(st.index, st.index + 40);
const close = useRef<HTMLButtonElement>(null);
// Клавиатура: при открытии фокус в панель, Escape закрывает.
@@ -428,7 +411,7 @@ function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'i
type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
aria-current={k === 0 || undefined}
onClick={() => player.jump(st.index + k)}
disabled={st.listening?.locked} onClick={() => player.jump(st.index + k)}
>
<Cover album={tr.album} size={40} />
<span className={s.meta}>
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
# Share a lock with manual deployments and config application.
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
exec 9<.management.lock
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
touch config.local
compose() { docker compose --env-file config.local --env-file .env "$@"; }
# Validate before stopping any container.
compose config --quiet
compose up -d --force-recreate --remove-orphans
echo '→ ждём готовности api'
for _ in $(seq 1 90); do
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
echo 'Конфигурация применена; API готов'
exit 0
fi
sleep 2
done
echo 'API не стал готов: проверьте docker compose logs api' >&2
exit 1
+15 -2
View File
@@ -54,8 +54,10 @@ services:
context: ..
dockerfile: services/api/Dockerfile
target: api
args: *version
env_file: .env
args:
<<: *version
REVISION: ${PIPU_COMMIT:-}
env_file: [config.local, .env]
environment: &db-env
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
volumes:
@@ -63,6 +65,7 @@ services:
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
- ${PIPU_DATA:-/srv/pipu}/management:/management
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
depends_on:
postgres: { condition: service_healthy }
@@ -112,6 +115,11 @@ services:
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
INTERNAL_TOKEN: ${INTERNAL_TOKEN:?заполните INTERNAL_TOKEN в .env (sudo ./install.sh)}
LOG_LEVEL: ${LOG_LEVEL:-info}
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
TRAIN_HOUR: ${TRAIN_HOUR:-3}
LIBRARY_ROOT: /library
LIBRARY_HOST_ROOT: /library-host
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
depends_on:
postgres: { condition: service_healthy }
@@ -124,6 +132,11 @@ services:
<<: *db-env
REDIS_URL: ${REDIS_URL:-redis://redis:6379/0}
LOG_LEVEL: ${LOG_LEVEL:-info}
CONTEXTS_PATH: ${CONTEXTS_PATH:-}
TRAIN_HOUR: ${TRAIN_HOUR:-3}
LIBRARY_ROOT: /library
LIBRARY_HOST_ROOT: /library-host
FFMPEG_PATH: ${FFMPEG_PATH:-ffmpeg}
volumes:
- *library-ro
- *library-host-ro
+426
View File
@@ -0,0 +1,426 @@
[
{
"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": false,
"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
},
{
"key": "PIPU_COMMIT",
"group": "deployment",
"type": "string",
"default_value": "",
"secret": false,
"readonly": true
}
]
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Host setup shared by first install and updates.
set -euo pipefail
cd "$(dirname "$0")"
DEPLOY="$(pwd -P)"
ENSURE=0
case "${1:-}" in
--ensure) ENSURE=1 ;;
'') ;;
*) echo "Usage: bash install-management.sh [--ensure]" >&2; exit 1 ;;
esac
# The active worker owns this process tree (systemd KillMode=control-group).
# Restarting it here would kill the update. It refreshes itself after success.
if [ "$ENSURE" = 1 ] && [ -n "${PIPU_UPDATE_SOURCE:-}" ]; then
systemctl is-active --quiet pipu-management.service || {
echo 'The management worker must stay active during its own update' >&2
exit 1
}
echo '→ агент управления продолжает обновление; новая копия применится после завершения'
exit 0
fi
DATA="${PIPU_DATA:-$(sed -n 's/^PIPU_DATA=//p' .env)}"
DATA="${DATA:-/srv/pipu}"
if [ "$(id -u)" -ne 0 ]; then
command -v sudo >/dev/null || { echo 'Для настройки агента запустите обновление с правами root (sudo)' >&2; exit 1; }
echo '→ для настройки системного сервиса агента нужны права sudo'
exec sudo -- env "PIPU_DATA=$DATA" bash "$DEPLOY/install-management.sh" "$@"
fi
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; }
RESTART=0
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.
if ! cmp -s server-agent.py /usr/local/lib/pipu/server-agent.py; then
install -m 700 server-agent.py /usr/local/lib/pipu/server-agent.py.new
mv -f /usr/local/lib/pipu/server-agent.py.new /usr/local/lib/pipu/server-agent.py
RESTART=1
fi
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")"
UNIT_TMP="$(mktemp)"
trap 'rm -f "$UNIT_TMP"' EXIT
cat > "$UNIT_TMP" <<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
if ! cmp -s "$UNIT_TMP" /etc/systemd/system/pipu-management.service; then
install -m 644 "$UNIT_TMP" /etc/systemd/system/pipu-management.service
systemctl daemon-reload
RESTART=1
fi
systemctl enable pipu-management.service
if [ "$ENSURE" = 0 ] || [ "$RESTART" = 1 ] || ! systemctl is-active --quiet pipu-management.service; then
systemctl restart pipu-management.service
fi
systemctl is-active --quiet pipu-management.service
echo '→ управление сервером: pipu-management.service'
+2
View File
@@ -118,4 +118,6 @@ if [ -n "$LIBRARY_HOST" ]; then
fi
fi
bash ./install-management.sh
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
+2 -1
View File
@@ -3,4 +3,5 @@
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
# hash-wasm считает SHA-256 загрузок в Web Worker; разрешаем WASM без JavaScript eval.
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data:; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
+498
View File
@@ -0,0 +1,498 @@
#!/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()
def sensitive_url(key, value):
"""URL credentials also occur in userinfo, query strings and fragments."""
if not key.endswith("_URL") or not value:
return False
try:
parsed = urlsplit(value)
return bool(parsed.username or parsed.password or parsed.query or parsed.fragment)
except ValueError:
# A malformed URL must not bypass masking.
return True
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 effective_revision(self, ignore_local=False):
env = read_env(self.deploy / ".env")
local = {} if ignore_local else read_env(self.deploy / "config.local")
effective = {item["key"]: env.get(item["key"], local.get(item["key"], item["default_value"]))
for item in self.schema() if not item["readonly"]}
return hashlib.sha256(json.dumps(effective, sort_keys=True).encode()).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"]))
secret = item["secret"] or any(sensitive_url(key, value) for value in
(env.get(key), local.get(key), item["default_value"]))
def display(value):
return "••••••••" if secret and value else value
entries.append({**item, "secret": secret, "default_value": display(item["default_value"]),
"env": display(env.get(key)), "local": display(local.get(key)),
"effective": display(effective), "source": source})
effective = self.effective_revision()
applied = self.load("applied-effective.json", None)
if applied is None and (not local or self.load("applied.json", None) == self.revision()):
self.save("applied-effective.json", effective)
applied = effective
# Changes shadowed by .env do not change the running configuration.
empty = self.effective_revision(ignore_local=True)
return {"entries": entries, "revision": self.revision(),
"restart_required": effective != (applied or empty)}
def installed_commit(self, body):
"""Trust the running API's build identity, never the checkout or .env.
The checkout can already point at a failed update. Legacy builds contain
git-describe/main hashes or a release tag, which we resolve locally.
"""
revision = body.get("_running_commit", "")
if re.fullmatch(r"[a-f0-9]{40,64}", revision):
return revision
version = body.get("_running_version", "")
match = re.fullmatch(r"(?:.*-g|main-)([a-f0-9]{7,64})", version)
candidates = [match[1]] if match else []
if version_key(version):
candidates = ["refs/tags/v" + version.removeprefix("v"), "refs/tags/" + version.removeprefix("v")]
for candidate in candidates:
try:
commit = self.command(["git", "rev-parse", "--verify", candidate + "^{commit}"]).strip()
if re.fullmatch(r"[a-f0-9]{40,64}", commit):
return commit
except (subprocess.SubprocessError, OSError):
pass
return ""
def update_target(self, refs, body):
channel = body.get("channel") or "latest"
if channel not in ("latest", "version", "main"):
raise ManagementError("management.target_invalid")
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:
return "", ""
target = max(tags, key=version_key)
else:
target = "main" if channel == "main" else body.get("version") or ""
if not target:
return "", ""
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 re.fullmatch(r"[a-f0-9]{40,64}", commit):
return target, ""
return target, commit
def update_state(self, target, commit, current, body):
if body.get("channel") == "version" and not body.get("version"):
return "select_version"
if not commit:
return "unavailable"
if not current:
return "unknown"
if current == commit:
return "current"
try:
self.command(["git", "merge-base", "--is-ancestor", commit, current])
return "ahead"
except (subprocess.SubprocessError, OSError):
# A selected source's commit may not have been fetched locally yet.
pass
# A stable tag behind a git-describe build is not a newer release.
running = body.get("_running_version", "")
base = re.match(r"v?(\d+\.\d+\.\d+)(?:-rc\.\d+)?", running)
target_key = version_key(target)
if target_key and base:
running_key = version_key(base[0])
if target_key[:3] < running_key[:3] or (target_key[:3] == running_key[:3] and re.search(r"-\d+-g[0-9a-f]+$", running)):
return "ahead"
return "available"
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):
effective_revision = self.effective_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)
self.save("applied-effective.json", effective_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, refs = [], "", {}
try:
refs = self.refs(selected["url"], fresh=bool(body.get("fresh")))
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"
current = self.installed_commit(body)
target, commit = self.update_target(refs, body)
return {"sources": sources, "versions": versions, "source_error": error,
"current_commit": current, "target_version": target, "target_commit": commit,
"update_state": self.update_state(target, commit, current, body),
"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)
refs = self.refs(selected["url"], fresh=True)
target, commit = self.update_target(refs, body)
ref = "refs/heads/main" if target == "main" else "refs/tags/" + target
if not commit:
raise ManagementError("management.target_invalid")
state = self.update_state(target, commit, self.installed_commit(body), body)
if state != "available":
code = {"current": "management.already_installed", "ahead": "management.downgrade", "unknown": "management.version_unknown"}.get(state, "management.target_invalid")
raise ManagementError(code, 409)
if body.get("expected_commit") and body["expected_commit"] != commit:
raise ManagementError("management.target_changed", 409)
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()
+141
View File
@@ -0,0 +1,141 @@
"""Exercise deployment shell scripts with isolated host paths and command stubs."""
import os
import shutil
import subprocess
import tempfile
import unittest
from pathlib import Path
BASH = ("C:/Program Files/Git/bin/bash.exe" if os.name == "nt" else shutil.which("bash"))
SCRIPTS = Path(__file__).resolve().parent
@unittest.skipUnless(BASH and Path(BASH).exists(), "Bash unavailable")
class DeploymentTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root = Path(self.temp.name)
self.deploy = self.root / "deploy"
self.bin = self.root / "bin"
self.systemd = self.root / "systemd"
for directory in (self.deploy, self.bin, self.systemd):
directory.mkdir()
self.trace = self.root / "trace"
self.active = self.root / "active"
# Git Bash on Windows prepends its binaries when importing Windows PATH.
# Source our isolated PATH after shell initialization, including re-exec.
shell_bin = self.bin.as_posix()
if os.name == "nt":
shell_bin = "/" + shell_bin[0].lower() + shell_bin[2:]
shell_env = self.root / "bash-env"
shell_env.write_text('export PATH="' + shell_bin + ':$PATH"\n', encoding="utf-8")
self.env = {**os.environ, "PATH": str(self.bin) + os.pathsep + os.environ["PATH"],
"BASH_ENV": shell_env.as_posix(),
"TRACE": self.trace.as_posix(), "ACTIVE": self.active.as_posix()}
for key in ("PIPU_UPDATE_REEXEC", "PIPU_UPDATE_SOURCE", "PIPU_UPDATE_COMMIT", "PIPU_UPDATE_REF", "PIPU_DATA", "SUDO_USER"):
self.env.pop(key, None)
(self.deploy / ".env").write_text("PIPU_DATA=" + (self.root / "data").as_posix() + "\nPIPU_VERSION=1.9.0\nJWT_PRIVATE_KEY=fixture\nSTREAM_SIGNING_KEY=fixture\nGITEA_URL=https://git.example.org\nGITEA_REPO=pipu/music\n", encoding="utf-8")
self.stub("systemctl", 'echo "systemctl $*" >> "$TRACE"\ncase "$1" in is-active) test -f "$ACTIVE";; restart) touch "$ACTIVE";; esac\n')
self.stub("id", 'echo "${TEST_UID:-0}"\n')
self.stub("chown", 'exit 0\n')
self.stub("python3", 'exit 0\n')
self.stub("flock", 'exit 0\n')
def stub(self, name, body):
path = self.bin / name
path.write_text("#!/usr/bin/env bash\n" + body, encoding="utf-8", newline="\n")
path.chmod(0o755)
def run_script(self, name, *args, extra=None):
return subprocess.run([BASH, (self.deploy / name).as_posix(), *args], cwd=self.deploy,
env={**self.env, **(extra or {})}, text=True, encoding="utf-8",
errors="replace", capture_output=True, timeout=20)
def install_fixture(self):
source = (SCRIPTS / "install-management.sh").read_text(encoding="utf-8")
# Relocate host constants only; run the real installer logic unchanged.
for original, replacement in (("/etc/systemd/system", self.systemd),
("/var/lib/pipu-management", self.root / "state"),
("/usr/local/lib/pipu", self.root / "installed")):
source = source.replace(original, replacement.as_posix())
(self.deploy / "install-management.sh").write_text(source, encoding="utf-8", newline="\n")
(self.deploy / "server-agent.py").write_text("# worker fixture\n", encoding="utf-8")
def test_ensure_installs_starts_and_preserves_unchanged_running_worker(self):
self.install_fixture()
first = self.run_script("install-management.sh", "--ensure")
self.assertEqual(0, first.returncode, first.stderr)
self.assertTrue((self.root / "installed/server-agent.py").is_file())
self.assertTrue((self.root / "data/management").is_dir())
unit = (self.systemd / "pipu-management.service").read_text(encoding="utf-8")
canonical = subprocess.run([BASH, "-c", 'cd "$1" && pwd -P', "test", self.deploy.as_posix()],
capture_output=True, text=True, check=True).stdout.strip()
self.assertIn(canonical, unit)
self.assertTrue(self.active.exists())
self.trace.write_text("", encoding="utf-8")
again = self.run_script("install-management.sh", "--ensure")
self.assertEqual(0, again.returncode, again.stderr)
self.assertNotIn("restart", self.trace.read_text())
self.assertNotIn("daemon-reload", self.trace.read_text())
(self.deploy / "server-agent.py").write_text("# upgraded worker\n", encoding="utf-8")
updated = self.run_script("install-management.sh", "--ensure")
self.assertEqual(0, updated.returncode, updated.stderr)
self.assertIn("restart", self.trace.read_text())
def test_ensure_restarts_stopped_worker(self):
self.install_fixture()
self.assertEqual(0, self.run_script("install-management.sh", "--ensure").returncode)
self.active.unlink()
result = self.run_script("install-management.sh", "--ensure")
self.assertEqual(0, result.returncode, result.stderr)
self.assertTrue(self.active.exists())
def test_agent_owned_update_never_replaces_or_restarts_itself(self):
self.install_fixture()
self.active.touch()
result = self.run_script("install-management.sh", "--ensure", extra={"PIPU_UPDATE_SOURCE": "https://git.example.org/pipu/music"})
self.assertEqual(0, result.returncode, result.stderr)
self.assertEqual("systemctl is-active --quiet pipu-management.service\n", self.trace.read_text())
self.assertFalse((self.root / "installed").exists())
def test_nonroot_setup_delegates_privileged_step_to_sudo(self):
self.install_fixture()
self.stub("sudo", 'printf "%s\\n" "$@" > "$TRACE"\n')
result = self.run_script("install-management.sh", "--ensure", extra={"TEST_UID": "1000"})
self.assertEqual(0, result.returncode, result.stderr)
arguments = self.trace.read_text().splitlines()
self.assertEqual(["--", "env"], arguments[:2])
self.assertEqual("PIPU_DATA=" + (self.root / "data").as_posix(), arguments[2])
self.assertEqual("--ensure", arguments[-1])
def update_fixture(self, install_exit=0):
shutil.copyfile(SCRIPTS / "update.sh", self.deploy / "update.sh")
target = self.root / "target-installer"
target.write_text('echo "setup $*" >> "$TRACE"\nexit ' + str(install_exit) + '\n', encoding="utf-8", newline="\n")
self.env["TARGET_INSTALLER"] = target.as_posix()
(self.deploy / "install-management.sh").write_text("echo 'old setup must not run' >&2; exit 99\n", encoding="utf-8")
self.stub("git", 'echo "git $*" >> "$TRACE"\ncase "$1" in checkout) cp "$TARGET_INSTALLER" install-management.sh;; describe) echo v1.9.1;; rev-parse) echo aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa;; esac\n')
self.stub("docker", 'echo "docker $*" >> "$TRACE"\n')
def test_update_installs_target_revision_worker_before_container_operations(self):
self.update_fixture()
result = self.run_script("update.sh", "main")
self.assertEqual(0, result.returncode, result.stderr)
trace = self.trace.read_text()
self.assertLess(trace.index("git checkout"), trace.index("setup --ensure"))
self.assertLess(trace.index("setup --ensure"), trace.index("docker compose"))
self.assertIn("up -d --remove-orphans", trace)
self.assertIn("PIPU_COMMIT=" + "a" * 40, (self.deploy / ".env").read_text())
def test_failed_host_setup_stops_update_before_container_operations(self):
self.update_fixture(install_exit=1)
result = self.run_script("update.sh", "main")
self.assertNotEqual(0, result.returncode)
self.assertNotIn("docker ", self.trace.read_text())
self.assertIn("PIPU_VERSION=1.9.0", (self.deploy / ".env").read_text())
if __name__ == "__main__":
unittest.main()
+250
View File
@@ -0,0 +1,250 @@
"""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.running_commit = "0" * 40
self.agent.installed_commit = lambda body: body.get("_running_commit", self.running_commit)
self.agent.save("sources.json", [{"id": "origin", "name": "origin", "url": "https://example.org/o/pipu.git"}])
def test_unchanged_configuration_needs_no_restart_even_without_saved_baseline(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"LOG_LEVEL": "debug"}})
self.assertFalse(result["restart_required"], "LOG_LEVEL is overridden by .env")
def test_returning_to_applied_values_removes_pending_restart(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
changed = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "6"}})
self.assertTrue(changed["restart_required"])
restored = self.agent.save_config({"revision": changed["revision"], "values": {"TRAIN_HOUR": None}})
self.assertFalse(restored["restart_required"])
def test_explicit_default_value_does_not_require_a_restart(self):
self.assertFalse(self.agent.config()["restart_required"])
with patch.object(self.agent, "command", return_value=""):
result = self.agent.save_config({"revision": self.agent.revision(), "values": {"TRAIN_HOUR": "3"}})
self.assertFalse(result["restart_required"])
def test_update_check_and_start_both_reject_installed_main_commit(self):
refs = {"refs/heads/main": self.running_commit}
body = {"source_id": "origin", "channel": "main"}
with patch.object(self.agent, "refs", return_value=refs), patch.object(self.agent, "start_job") as start:
self.assertEqual("current", self.agent.handle("updates.get", body)["update_state"])
with self.assertRaises(ManagementError) as error:
self.agent.handle("updates.start", body)
self.assertEqual("management.already_installed", error.exception.code)
start.assert_not_called()
def test_check_distinguishes_missing_selection_and_changed_target(self):
with patch.object(self.agent, "refs", return_value={"refs/heads/main": "a" * 40}), patch.object(self.agent, "start_job") as start:
self.assertEqual("select_version", self.agent.handle("updates.get", {"channel": "version"})["update_state"])
with self.assertRaises(ManagementError) as error:
self.agent.handle("updates.start", {"channel": "main", "expected_commit": "b" * 40})
self.assertEqual("management.target_changed", error.exception.code)
start.assert_not_called()
def test_unknown_installed_commit_and_older_release_cannot_be_installed(self):
with patch.object(self.agent, "refs", return_value={"refs/tags/v1.8.0": "a" * 40}), patch.object(self.agent, "start_job") as start:
for body, state in [({"_running_commit": ""}, "unknown"), ({"_running_version": "1.9.0-1-gbc55528"}, "ahead")]:
self.assertEqual(state, self.agent.handle("updates.get", body)["update_state"])
with self.assertRaises(ManagementError):
self.agent.handle("updates.start", body)
start.assert_not_called()
def test_installed_identity_uses_running_build_and_resolves_legacy_describe(self):
with patch.object(self.agent, "command", return_value="a" * 40) as command:
self.assertEqual("b" * 40, Agent.installed_commit(self.agent, {"_running_commit": "b" * 40}))
command.assert_not_called()
self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "1.9.0-1-gbc55528"}))
self.assertEqual("bc55528^{commit}", command.call_args.args[0][-1])
self.assertEqual("a" * 40, Agent.installed_commit(self.agent, {"_running_version": "main-bc55528"}))
self.assertEqual("", Agent.installed_commit(self.agent, {"_running_version": "0.0.0-dev"}))
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_secret_defaults_and_shadowed_values_never_leave_worker(self):
self.schema[1]["default_value"] = "default-canary"
(self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8")
(self.deploy / "config.local").write_text("GITEA_TOKEN=local-canary\n", encoding="utf-8")
config = self.agent.config()
encoded = json.dumps(config)
for value in ("top-secret", "local-canary", "default-canary"):
self.assertNotIn(value, encoded)
token = next(entry for entry in config["entries"] if entry["key"] == "GITEA_TOKEN")
for field in ("env", "local", "effective", "default_value"):
self.assertEqual("••••••••", token[field])
def test_real_catalog_masks_every_secret_and_credential_bearing_url(self):
schema = json.loads(Path(__file__).with_name("config.schema.json").read_text(encoding="utf-8"))
(self.deploy / "config.schema.json").write_text(json.dumps(schema), encoding="utf-8")
secrets = {item["key"] for item in schema if item["secret"]}
self.assertTrue({"POSTGRES_PASSWORD", "DATABASE_URL", "REDIS_URL", "JWT_PRIVATE_KEY",
"JWT_PREVIOUS_KEYS", "BOOTSTRAP_ADMIN_PASSWORD", "INTERNAL_TOKEN",
"STREAM_SIGNING_KEY", "GENIUS_ACCESS_TOKEN", "MEILI_MASTER_KEY", "GITEA_TOKEN"} <= secrets)
self.assertNotIn("ACCESS_TOKEN_TTL", secrets)
env = {key: f"env-canary-{key}" for key in secrets}
local = {key: f"local-canary-{key}" for key in secrets}
env.update(GITEA_URL="https://user:password@example.org", MEILI_URL="https://example.org?api_key=query-canary")
local.update(GITEA_URL="https://example.org#fragment-canary", MEILI_URL="https://token@example.org")
for name, values in ((".env", env), ("config.local", local)):
(self.deploy / name).write_text("\n".join(f"{key}={value}" for key, value in values.items()), encoding="utf-8")
config = self.agent.config()
for value in (*env.values(), *local.values()):
self.assertNotIn(value, json.dumps(config))
entries = {entry["key"]: entry for entry in config["entries"]}
for key in secrets | {"GITEA_URL", "MEILI_URL"}:
self.assertTrue(entries[key]["secret"])
self.assertEqual("••••••••", entries[key]["effective"])
self.assertEqual("15m", entries["ACCESS_TOKEN_TTL"]["effective"])
def test_plain_urls_remain_readable_and_local_credentials_are_masked_after_save(self):
self.schema.append({**self.schema[0], "key": "MEILI_URL", "default_value": "https://example.org/search"})
(self.deploy / "config.schema.json").write_text(json.dumps(self.schema), encoding="utf-8")
entry = self.agent.config()["entries"][-1]
self.assertFalse(entry["secret"])
self.assertEqual("https://example.org/search", entry["effective"])
with patch.object(self.agent, "command", return_value=""):
config = self.agent.save_config({"revision": self.agent.revision(), "values": {"MEILI_URL": "https://user:save-canary@example.org"}})
self.assertNotIn("save-canary", json.dumps(config))
self.assertTrue(config["entries"][-1]["secret"])
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()
+51 -6
View File
@@ -14,6 +14,15 @@ fi
trap 'rm -f "$0"' EXIT
cd "$PIPU_DEPLOY_DIR"
# Share a lock with manual deployments and config application.
if [ ! -f .management.lock ]; then (umask 022; : > .management.lock); fi
exec 9<.management.lock
flock -n 9 || { echo 'Another deployment is running' >&2; exit 1; }
touch config.local
chmod 600 config.local
compose() { docker compose --env-file config.local --env-file .env "$@"; }
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
@@ -69,7 +78,15 @@ if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [
fi
REF="${1:-}"
git fetch --tags --prune --quiet origin
if [ -n "${PIPU_UPDATE_SOURCE:-}" ]; then
[[ "${PIPU_UPDATE_COMMIT:-}" =~ ^[a-f0-9]{40,64}$ ]] || { echo 'Invalid commit' >&2; exit 1; }
[[ "${PIPU_UPDATE_REF:-}" =~ ^refs/(heads/main|tags/v?[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?)$ ]] || { echo 'Invalid ref' >&2; exit 1; }
git -c protocol.file.allow=never -c protocol.ext.allow=never fetch --quiet -- "$PIPU_UPDATE_SOURCE" "$PIPU_UPDATE_REF"
FETCHED="$(git rev-parse 'FETCH_HEAD^{commit}')"
[ "$FETCHED" = "$PIPU_UPDATE_COMMIT" ] || { echo 'Source changed; select update again' >&2; exit 1; }
else
git fetch --tags --prune --quiet origin
fi
if [ -z "$REF" ]; then
REF="$(git tag -l 'v*' --sort=-v:refname | grep -v -- '-rc' | head -1)"
@@ -80,6 +97,7 @@ fi
# Откат на более старую версию: миграции БД назад не применяются, старый api может не понять новую схему.
CURRENT="$(sed -n 's/^PIPU_VERSION=//p' .env)"
CURRENT_COMMIT="$(sed -n 's/^PIPU_COMMIT=//p' .env)"
TARGET="${REF#v}"
core() { [[ "$1" =~ ^([0-9]+\.[0-9]+\.[0-9]+) ]] && echo "${BASH_REMATCH[1]}"; }
cur_core="$(core "$CURRENT" || true)"
@@ -96,26 +114,53 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
fi
echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
# Refuse to overwrite local source changes.
git diff --quiet && git diff --cached --quiet || { echo 'Repository has local changes' >&2; exit 1; }
if [ -n "${PIPU_UPDATE_COMMIT:-}" ]; then
git checkout --quiet --detach "$PIPU_UPDATE_COMMIT"
elif git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF"
git merge --ff-only --quiet "origin/$REF"
else
git checkout --quiet --detach "$REF"
fi
VERSION="$(git describe --tags --always --dirty)"
# Use the target revision's setup, even when this updater was copied before
# checkout. Existing deployments must gain new host services during upgrades.
if [ -f ./install-management.sh ]; then
bash ./install-management.sh --ensure
fi
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}"
COMMIT="$(git rev-parse HEAD)"
if grep -q '^PIPU_COMMIT=' .env; then
sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$COMMIT|" .env
else
echo "PIPU_COMMIT=$COMMIT" >> .env
fi
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$VERSION|" .env
echo "→ версия $VERSION, сборка образов (первая сборка — 5–15 минут)"
docker compose build --pull
docker compose up -d --remove-orphans
compose config --quiet
if ! compose build --pull; then
# A build failure leaves running containers untouched; keep their version in .env.
sed -i "s|^PIPU_VERSION=.*|PIPU_VERSION=$CURRENT|" .env
sed -i "s|^PIPU_COMMIT=.*|PIPU_COMMIT=$CURRENT_COMMIT|" .env
echo 'Build failed; running containers were not replaced' >&2
exit 1
fi
compose up -d --remove-orphans
docker image prune -f >/dev/null
echo "→ ждём готовности api"
for _ in $(seq 1 60); do
# 127.0.0.1, а не localhost: в alpine localhost — сначала ::1, а nginx слушает только IPv4
if docker compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
if compose exec -T nginx wget -qO- http://127.0.0.1/readyz >/dev/null 2>&1; then
echo "pipu $VERSION запущен"
exit 0
fi
+2
View File
@@ -17,6 +17,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [playback.md](playback.md) | Стриминг, транскодинг, состояние плеера, офлайн |
| [genius.md](genius.md) | Genius: токен, поиск метаданных, парсинг текстов и проверочная команда |
| [playlists.md](playlists.md) | Личные плейлисты: API, веб и Android |
| [social.md](social.md) | Друзья, заявки, личные сообщения, уведомления и отправка музыки |
| [recommendations.md](recommendations.md) | Рекомендательная система и «Моя волна» |
| [web.md](web.md) | Веб-клиент |
| [desktop.md](desktop.md) | Десктоп Windows / Linux / macOS (Tauri) |
@@ -24,6 +25,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [i18n.md](i18n.md) | Локализация (ru / en, расширяемость) |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, диагностика |
| [server-management.md](server-management.md) | Управление обновлениями и config.local из админки, установка агента |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные |
## Стек в одном абзаце
+14
View File
@@ -14,6 +14,15 @@
## 2. Пользователи и роли
В меню «Админка» пользователи доступны отдельным пунктом, остальные страницы собраны в подразделы:
- **Медиатека:** библиотека и хранилища.
- **Загрузки:** предложить треки, мои предложения и очередь на рассмотрение.
- **Система:** обновления, конфигурация, настройки и журнал.
На компьютере подразделы можно сворачивать; браузер запоминает выбор. Подраздел текущей страницы
раскрывается при переходе. На телефоне группы показаны заголовками в меню «Ещё». Видимость страниц зависит от прав.
«Администрирование → Пользователи».
| Роль | Что может |
@@ -133,3 +142,8 @@ APK, Linux (`.deb`) и Windows (NSIS) прикладываются к релиз
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
## Управление обновлениями и конфигурацией
Разделы **Обновления** и **Конфигурация** используют агент на хосте. Установка для существующего сервера, права, приоритет `.env` над `config.local` и диагностика описаны в [server-management.md](server-management.md).
+1
View File
@@ -96,6 +96,7 @@ pipu-music/
│ │ ├── internal/
│ │ │ ├── auth/ library/ search/ genius/
│ │ │ ├── playback/ wave/ uploads/ appupdate/ system/
│ │ │ ├── social/ # друзья, личные сообщения, центр уведомлений
│ │ │ └── platform/ # db, redis, config, logging, http, jobs
│ │ └── migrations/ # goose SQL-миграции; SQL-запросы — в адаптерах internal/
│ └── recommender/ # Python (uv)
+19
View File
@@ -56,6 +56,22 @@
Аналогично для `must_change_password` (после сброса пароля админом / модератором): разрешён только
`POST /me/password` (без `current_password`, если вход был по временному паролю).
## Перенос пользователей через файл
Администратору на странице «Пользователи» доступны «Экспорт пользователей» и «Импорт пользователей».
JSON-файл версии 1 содержит логины, отображаемые имена, роли, дополнительные права аудита загрузок
и управления обновлениями, флаги блокировки и обязательной смены пароля, а также хэши Argon2id.
Действующие пароли сохраняются; открытые пароли в файле отсутствуют. Файл содержит секретные данные
и должен храниться в защищённом месте. Экспорт охватывает всех пользователей независимо от фильтров страницы.
Импорт добавляет отсутствующие логины с новыми ID. Существующие логины пропускаются без изменений
пароля, роли и данных пользователя; список пропущенных логинов показывается в результате.
Весь файл проверяется до записи, добавление выполняется атомарно. Ограничения: 10000 пользователей,
16 MiB; параметры импортированных хэшей ограничены для безопасной проверки пароля при следующем входе.
Рекомендации, история, лайки, плейлисты, сессии и настройки интерфейса не экспортируются и не импортируются.
Язык новых записей берётся из настроек сервера. Импорт и экспорт пишутся в журнал без хэшей паролей.
API: `GET /admin/users/export`, `POST /admin/users/import` (оба только для admin).
## Пароли
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
@@ -97,3 +113,6 @@
делает **единственный** параллельный refresh (single-flight) и повторяет запросы.
- Роутинг-гарды: `must_change_credentials` → `/setup/admin`; `must_change_password` → `/setup/password`;
админские разделы — по `role`.
Управление обновлениями сервера — право `manages_updates`: у admin всегда, другим его выдаёт и отзывает только admin. Оно даёт доступ к `/admin/updates`, управлению источниками и запуску обновления, но не к конфигурации сервера. `/admin/config` доступен только admin. Подробности: [server-management.md](server-management.md).
+17
View File
@@ -172,6 +172,23 @@ lower-case, `ё → е`, удаление пунктуации, схлопыва
сгенерированный из цвета. Конвертируются в WebP 64/300/1200 px в `/srv/pipu/cache/covers/`, вычисляется
палитра из 3 цветов (k-means по пикселям; `cover_colors`) — для ambient-фона, цветов кнопки волны и плейсхолдеров.
Поиск файлов в папке не зависит от регистра: `cover.jpg`, `cover.jpeg`, `Cover.png`,
а также `folder`, `front`, `album`, `albumart` с расширениями JPG/JPEG/PNG/WebP.
Картинка должна лежать рядом с аудиофайлом; глубина папки относительно корня источника не важна.
Папки, исключённые из сканирования, не используются для поиска обложек.
После успешного скана (в том числе обычного, без перечитывания тегов) альбомы без обложек
проверяются повторно. Обработчик запускается также при старте API, готовит до четырёх альбомов
одновременно и сохраняет готовые обложки по мере обработки. Одинаковые изображения используют
общие файлы кэша; ошибки чтения и конвертации остаются для повтора при следующем запуске обработчика.
Клиент получает ссылку на готовый WebP через API каталога; nginx раздаёт файл из кэша.
В карточке источника показано «Локальные обложки: X из Y файлов» и число альбомов, ожидающих обработки.
Статистика обновляется каждые 10 секунд через `GET /admin/sources/{id}/cover-stats`.
Считаются аудиофайлы каталога без ошибок чтения, пропавших файлов и исключённых папок, включая
альтернативные форматы одного трека. Числитель — файлы, у альбома которых есть готовая обложка
из тегов или папки; внешние URL Genius не учитываются. Это покрытие файлов обложками альбомов,
а не количество отдельных `cover.jpg` на диске.
## Поиск
Meilisearch, индексы:
+106
View File
@@ -0,0 +1,106 @@
# Project Review — 2026-10-24 (1)
## Вывод и область проверки
Модульный монолит, contract-first API и общий core соответствуют масштабу проекта. После предыдущего рефакторинга у library/uploads/player появились полезные границы. Перестраивать проект целиком ради SOLID оснований нет. Приоритет — изоляция асинхронных операций при смене аккаунта, авторизация записей и согласованность состояния рекомендаций.
Проверены карта документации и архитектура, локальные AGENTS, выбранные потоки auth, playback/events, wave, social/listening, uploads/placement, library/schedule, web persistence/dialogs, Android offline/events/downloads/onboarding, desktop proxy, Python wave/API и текущий diff. Это статическое ревью перечисленных потоков с целевыми тестами, а не построчная проверка каждого файла или полный security-аудит. Физический Android, реальные аудиопотоки, NFS и производительность под нагрузкой не проверялись. Замечания ниже подтверждены чтением кода; отдельные новые воспроизводящие тесты для них в этой работе не добавлялись.
## Prior Reviews Summary
В каталоге были два отчёта: `2026-10-22-review-1.md` и `2026-10-21-review-1.md`. Все 17 задач отмечены `[x]`; закрытые задачи повторно не открывались.
### Still Open (carried forward)
Нет. Перенесено: 0.
### Resolved Since Last Review
Нет ранее открытых задач для перепроверки. Подтверждено новых закрытий: 0.
## Phase 1: Code Quality
### SOLID
**F7, P3 — WaveService собирает инфраструктуру и выполняет сценарий.** Конструктор сам создаёт IndexHolder, SessionStore и RankerHolder, `batch` открывает PostgreSQL-соединения, загружает профиль, меняет Redis-сессию, вызывает алгоритм и пишет обучающие признаки. Изолированная проверка сценария требует подмены конкретных инфраструктурных деталей. Передавать узкие порты каталога/профиля, сессии и impression sink из `_serve`, оставив SQL/Redis в адаптерах. Не нужно оборачивать каждую математическую функцию интерфейсом. Источник: `services/recommender/pipu_rec/wave/service.py:58`, `:79`, `:112`; composition root: `pipu_rec/cli.py:_serve`.
Доказанных нарушений LSP в просмотренных реализациях не найдено. Остаточная длина Player и наличие конкретных адаптеров в composition root сами по себе не считаются дефектами. Закрытые замечания предыдущего SOLID-ревью не дублируются.
### Performance
**F5, P2 — размер исключений растёт без ограничения и нарушает внутренний контракт.** Go `servedIDs` загружает все показы сессии и передаёт весь массив как `exclude_ids`, а Python BatchRequest ограничивает его 1000 элементами. После превышения 1000 показов запросы этой сессии получают 422, и Go каждый раз переходит на fallback. Это подтверждённый конфликт границ, а не результат нагрузочного замера. Согласовать bounded history/хранение исключений на стороне рекомендателя, сохранив политику неповторов. Источники: `services/api/internal/wave/wave.go:291`, `:349`; `services/recommender/pipu_rec/wave/service.py:40`.
Иных измеренных проблем производительности не установлено.
### Correctness & Bugs
**F1, P1 — незавершённая загрузка Android продолжает работу в новом аккаунте.** `attach` меняет `this.owner/server` и очищает active; `run` захватывает старую session, но после сетевого await строит путь через изменяемый `this.file` и URL через `this.abs`, затем без проверки поколения снова вставляет DownloadResumable в active. Сценарий: медленный `/tracks/batch` → logout/смена сервера → старый ответ. Старая задача может писать в каталог нового владельца и занимать его очередь; её finally может удалить active новой задачи с тем же track ID. Проверка owner внутри `status` не защищает создание загрузки, progress callback и файловые операции. Захватывать контекст задачи (owner/server/пути), проверять поколение после await и удалять active только по идентичности задачи. Источник: `apps/mobile/src/offline/downloads.ts:84`, `:228`, `:235`, `:261`.
**F3, P2 — поздний refresh восстанавливает устаревшую авторизацию.** `doRefresh` без проверки версии вызывает `apply`; logout/signOut и новый login не инвалидируют уже отправленный refresh. Если успешный refresh был обработан сервером до logout, но его ответ задержался, он может после signOut вернуть локальный signed_in и записать старые токены в RefreshStore. Аналогично ответ старого refresh может перезаписать новый login. Нужны поколение auth-операций и отбрасывание устаревшего результата до изменения access/store/state. Источник: `packages/core/src/auth.ts:143`, `:159`, `:167`, `:197`.
### Code Quality
Отдельных задач по косметике, размеру файлов и magic numbers не создано: они уступают проверенным нарушениям поведения.
## Phase 2: Logical Consistency
### Domain & Application Layer
**F2, P1 — событие пользователя может изменить исход чужой wave-сессии.** Клиент передаёт произвольный UUID в context_id; Accept передаёт его в MarkImpression, где UPDATE ограничен только sessionID/trackID. Проверки `wave_sessions.user_id = p.UserID` нет. Зная UUID чужой сессии и трека, авторизованный клиент может записать like/dislike/skip в чужой показ, влияя на метрики и обучающие данные. UUID усложняет угадывание, но не заменяет авторизацию. Передавать userID и ограничивать запись владельцем сессии в той же SQL-операции; добавить тест изоляции двух пользователей. Источники: `services/api/internal/playback/events.go:75`, `services/api/internal/playback/store.go:354`; контракт `api/openapi.yaml:PlayEvent` разрешает context_id как строку.
### Data Flow
Сохранены положительные границы: core не импортирует платформенные реализации, uploads use cases отделены от pgx, read-проекции могут делать межмодульные JOIN. Целевая проверка `pnpm check:boundaries` проходит; это не доказательство отсутствия всех циклов импортов.
### State Management
**F4, P2 — drain реакций может перезаписать более свежий выбор.** `drain` читает snapshot pending_reactions и отправляет его без координации с `react`. Возможен порядок: drain прочитал старый like → react успешно записал новый dislike и удалил pending → drain позже отправил старый like. Условие `reaction IS ?` защищает удаление SQLite-строки, но не порядок запросов к серверу. Сериализовать операции по track ID либо добавить версию и согласовать запись на сервере. Источник: `apps/mobile/src/offline/events.ts:50`, `:65`, `:77`, `:82`.
**F6, P2 — параллельные запросы теряют обновления Redis-сессии волны.** API запускает batch через `asyncio.to_thread`; каждый batch отдельно делает get → меняет session → put. Два запроса одной сессии могут прочитать одинаковый snapshot, выбрать пересекающиеся треки и последней записью затереть feedback/served/recent_artists первого. PostgreSQL-обновление tracks_served не защищает Redis и выбор кандидатов. Нужна сериализация/версионирование всей операции по session_id с учётом нескольких процессов, а не только локальный thread lock. Источники: `services/recommender/pipu_rec/api/app.py:wave_batch`, `pipu_rec/wave/service.py:84`, `:106`, `pipu_rec/wave/store.py:SessionStore`.
### Consistency
Конфликт внутреннего контракта Go/Python описан как F5; отдельная дублирующая задача не создаётся.
## Phase 3: UI/UX
### Usability
**F8, P2 — Android молча пропускает onboarding при ошибке его загрузки.** После завершения запроса с ошибкой `query.isPending` становится false, data отсутствует, и `!query.data?.required` возвращает основной интерфейс без ошибки и retry. Для нового пользователя временный сбой выглядит как отсутствие настройки рекомендаций. Разделить unknown/error и подтверждённое required=false; предусмотреть retry и осознанный вход в offline-режим без блокирования скачанной музыки. Источник: `apps/mobile/src/ui/Onboarding.tsx:29`.
### Visual & Consistency
Новых подтверждённых визуальных замечаний нет. Старые закрытые mobile-web задачи не переоткрывались. В этой работе браузер использовался для функциональных E2E; полного визуального аудита всех экранов не было.
### Interaction & Feedback
В текущих висящих изменениях завершён lifecycle listening: закрытие диалога после отправки, отсутствие стрима до admission, отдельная ошибка listening, явный запуск на втором устройстве ведущего, защита от позднего GET после выхода и сохранение refresh во время запроса. Дополнительно исправлено освобождение пустой скрытой комнаты после отмены/истечения последнего запроса; музыка источника продолжает играть. Эти исправленные в рамках сдачи дефекты не входят в открытые Tasks.
### Accessibility
У просмотренного web Dialog есть именование, focus trap, Escape и возврат фокуса; у Android Field/Button есть доступные имена/роли. Новых доказанных accessibility-дефектов не заявлено. Проверка screen reader и всех контрастов не проводилась.
## Tasks
Новых открытых задач: 8. Перенесено: 0. Ранее открытых, исправленных после прошлых ревью: 0. Исправление всех найденных замечаний не входило в просьбу завершить уже висящие изменения listening.
- [ ] 1. [Bug] [P1, F1] Изолировать Download jobs по поколению owner/server, включая URL, пути, progress и active cleanup — `apps/mobile/src/offline/downloads.ts` line 261.
- [ ] 2. [Logic] [P1, F2] Проверять владельца wave-сессии при MarkImpression и покрыть изоляцию пользователей — `services/api/internal/playback/store.go` line 354.
- [ ] 3. [Bug] [P2, F3] Инвалидировать поздние refresh/apply после logout и нового login — `packages/core/src/auth.ts` line 197.
- [ ] 4. [Bug] [P2, F4] Согласовать порядок drain и новых реакций по track ID — `apps/mobile/src/offline/events.ts` line 82.
- [ ] 5. [Performance] [P2, F5] Согласовать bounded exclude history Go/Python, исключить постоянный fallback после 1000 показов — `services/api/internal/wave/wave.go` line 349.
- [ ] 6. [Logic] [P2, F6] Защитить read-modify-write wave-сессии от параллельных batch — `services/recommender/pipu_rec/wave/service.py` line 84.
- [ ] 7. [UX] [P2, F8] Отделить ошибку onboarding от required=false и дать retry/offline-путь — `apps/mobile/src/ui/Onboarding.tsx` line 29.
- [ ] 8. [SOLID] [P3, F7] Передавать узкие IO-порты в WaveService из composition root — `services/recommender/pipu_rec/wave/service.py` line 58.
## Проверки и Git
- До изменений этого ревью: HEAD `cb22f15`; после успешного `git fetch origin` — HEAD и origin/main совпадали, ahead=0, behind=0.
- В рабочем дереве изначально: 13 изменённых tracked-файлов listening/тестов/переводов/docs, новый `apps/web/e2e/listening.spec.ts`, а также отдельные `scripts/share-stats.py`, `artifacts/wave-palette/`, `services/api/api.exe`. Последние три не менялись и не включаются в коммит listening.
- Core: 69 тестов прошли, в том числе новый тест освобождения пустой комнаты.
- Web: App + player/listening — 51 тест прошёл в итоговом прогоне. В первом запуске при параллельных проверках упал lossless-hint test; изолированно и в повторном прогоне проходит. Стабильность этого теста не считается доказанной одним повтором.
- Mobile: 6 тестов прошли. Web/mobile/core typecheck и i18n check прошли (1111 ключей × 2 языка).
- `pnpm check:boundaries` прошёл. `git diff --check` прошёл.
- Go: unit-тесты social/playback/wave и vet social прошли. Integration `TestActivityRoomsAndChatPrivacy` собирается, но **SKIP**: TEST_DATABASE_URL не задан. Docker daemon недоступен. Транзакционные сценарии на реальной БД этой проверкой не подтверждены.
- Playwright listening: 4 сценария прошли. Используется fake API; ошибки аудиопрокси ECONNREFUSED в сценариях не являются проверкой воспроизведения настоящего аудио.
- Публичный OpenAPI не менялся, генерация не требуется. Реальный Android/background и PostgreSQL остаются ограничениями проверки.
+163
View File
@@ -0,0 +1,163 @@
# Управление сервером из веб-интерфейса
В меню админки доступны **Обновления** (`/admin/updates`) и **Конфигурация**
(`/admin/config`). Клиентские APK и desktop-сборки по-прежнему раздаются через
существующий механизм `appupdate`; источники обновления сервера от него независимы.
## Установка на существующий сервер
Обычное обновление на Linux-хосте автоматически настраивает агент после
переключения на выбранную версию и до пересоздания контейнеров:
```bash
cd /opt/pipu-music/deploy
./update.sh main # либо тег версии, содержащей эту возможность
```
Если прав не хватает, установщик запросит sudo только для настройки системного
сервиса. Повторный запуск проверяет установку и запускает остановленный агент;
исправный агент с тем же кодом и unit-файлом не перезапускается. Ошибка установки
останавливает обновление до пересоздания контейнеров. При обновлении из админки
собственный агент остаётся запущенным и заменяет свою копию после успеха.
В версии **v1.9.0** вызов установщика из `update.sh` был пропущен. Если сервер уже
обновлён до неё, агент можно включить сразу без новой сборки:
```bash
cd /opt/pipu-music/deploy && sudo bash ./install-management.sh
```
Чтобы получить исправленный updater на checkout ветки main, один раз выполните
`git pull --ff-only` перед `./update.sh main`: старая временная копия updater,
запущенная до checkout, ещё не содержит нового шага настройки.
Новая установка через `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.
Кнопка установки появляется только если выбранный commit отличается от работающего
и переход не является откатом. Для `main` сравнивается SHA, для релиза — SHA тега.
Одинаковая версия, более новая установленная сборка, ошибка проверки и неопределённый
commit не предлагают установку. «Проверить обновления» обходит кэш refs.
Работающий API передаёт свою встроенную revision; для прежних сборок агент разрешает
hash из git-describe или локальный релизный тег. HEAD checkout и PIPU_VERSION в .env
не считаются доказательством установленного commit, поскольку сборка могла не удаться.
Обновления устанавливаются только по нажатию кнопки и после подтверждения.
Перед запуском агент повторно читает 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.
Маскирование охватывает `.env`, config.local, итоговое значение и значение по
умолчанию. URL со встроенными учётными данными, query-параметрами или фрагментом
целиком считается секретом. Поля замены изначально пусты; переключатель видимости
показывает только новый ввод. После сохранения поле снова очищается.
Сохранение атомарно, с правами 0600 и проверкой ревизии: устаревшая форма не затрёт
правки другого администратора. Перед сохранением проверяются типы и Compose;
при ошибке прежний файл восстанавливается. Произвольные ключи, переводы строк
и изменение служебных параметров отклоняются. Сохраняемые значения трактуются
буквально: подстановки shell не выполняются.
Панель сохранения появляется только при отличиях от исходных локальных значений.
После возврата прежних значений она исчезает. Сохранённые изменения, влияющие на
итоговую конфигурацию, показывают отдельное действие применения. Пустой config.local,
комментарии, явное значение по умолчанию или значение, перекрытое .env, сами по себе
не требуют перезапуска.
Кнопка **Применить и перезапустить** отдельно подтверждает пересоздание контейнеров.
Агент запускает `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`.
+149
View File
@@ -0,0 +1,149 @@
# Друзья, сообщения и уведомления
Социальные функции работают между пользователями одного сервера. В web и desktop
открывается раздел «Друзья и общение», на Android — вкладка «Друзья».
На ПК справа над плеером доступна мини-панель друзей и чата шириной до 600 px.
Она позволяет переписываться без ухода с текущей страницы, сворачивается с сохранением
черновика и открывает выбранную переписку в полном разделе. На экранах уже 1024 px
используется полный раздел общения.
## Поведение
- Поиск по логину и отображаемому имени начинается с двух символов, возвращает до 30 человек.
Пароли, роли, настройки и другие приватные поля в результат не попадают.
- Заявка требует явного принятия получателем. Встречная заявка не принимает дружбу автоматически.
Можно отменить свою заявку, отклонить входящую или удалить друга.
- Личные сообщения и отправка музыки доступны только текущим друзьям. Блокировка прекращает
дружбу и закрывает общение в обоих направлениях. Разблокировка дружбу не восстанавливает.
- История сохраняется после удаления друга, но доступна снова только после новой взаимной дружбы.
В первой странице чата — последние 50 сообщений; ранние подгружаются отдельно.
- В сообщении до 4000 символов. Музыкальное вложение — ID трека либо действующая ссылка
общего доступа к плейлисту. Отзыв ссылки делает вложенный плейлист недоступным.
- «Поделиться» открывает выбор: скопировать ссылку, вызвать системную отправку (когда она
поддерживается клиентом) или отправить конкретному другу одним нажатием. Отправка другу
создаёт сообщение с музыкальным вложением. Повтор после сетевого сбоя использует тот же UUID.
## Спокойные уведомления
Четыре типа: входящая заявка, принятие заявки, личное сообщение, музыка.
Центр событий и счётчики сохраняются на сервере, отметки прочтения общие для устройств.
Нет звуков, автоматических всплывающих окон и запроса разрешений ОС.
Список уведомлений возвращает до 100 событий, сначала непрочитанные. По мере подтверждения
новых становятся доступны более старые непрочитанные события. Открытый видимый чат подтверждает
сообщения только до последнего полученного номера; более поздние сообщения не теряются.
В скрытой вкладке и в фоне Android автоматического подтверждения нет.
Сейчас это уведомления **внутри работающего приложения**, с обновлением центра каждые 15 секунд
и открытого чата каждые 3 секунды. Web использует стандартную приостановку фонового polling
React Query; Android останавливает polling при уходе приложения в фон. Уведомления ОС при
закрытом клиенте (Web Push/FCM/нативный desktop) в эту реализацию не входят.
## Реализация и проверки
Контракт: `api/openapi.yaml`, операции `getSocial`, `searchPeople`, `socialAction`,
`getDirectMessages`, `sendDirectMessage`. Модуль API: `internal/social`.
Миграция `00019_social.sql` создаёт дружбу, блокировки, сообщения и уведомления.
Миграции применяются штатным запуском API.
Мутации пары сериализованы транзакционной advisory-блокировкой PostgreSQL. Отправка сообщения
и создание уведомления атомарны. UUID сообщения глобально уникален; повтор с другим содержимым
возвращает конфликт. Чужое уведомление нельзя отметить прочитанным. Поиск скрывает заблокированные
аккаунты и личные блокировки, включая обратное направление.
Ограничения: до 50 исходящих ожидающих заявок, до 10 новых заявок за минуту,
до 30 сообщений одному человеку за минуту; тело социального POST ограничено 32 KiB.
Тексты выводятся обычным текстом, вложения формируют только внутренние маршруты каталога.
Проверки: `go test ./internal/social`,
`TEST_DATABASE_URL=… go test -tags integration ./internal/social`,
`pnpm --filter @pipu/web test src/App.test.tsx -t 'social|playlist sharing'`,
`pnpm --filter @pipu/web test:e2e e2e/social.spec.ts e2e/playlists.spec.ts`.
Интеграционный сценарий включён в `ci-db`.
## Активность и приватность
В разделе друзей настройка «Показывать мой статус» принимает значения «Никому»
(по умолчанию), «Друзьям», «Всем». Сервер проверяет аудиторию, дружбу и блокировки.
При «Никому» сведения о текущем прослушивании не сохраняются. Переключение на это
значение удаляет активность всех устройств. Публикация имеет последовательный номер
и срок жизни 90 секунд; пауза снимает статус, потерянное соединение не оставляет вечный статус.
Карточка показывает трек и контекст: «Моя волна», плейлист или обычное прослушивание.
Трек открывается в каталоге. Плейлист доступен для чтения, пока активность видима:
этот переход не создаёт постоянной ссылки общего доступа и не раскрывает существующую.
## Совместное прослушивание
Статус «В сети» виден только текущим друзьям и независим от видимости музыки.
Клиент поддерживает его штатным опросом состояния прослушивания, даже на паузе;
просмотр социального раздела также обновляет собственный heartbeat. Сервер объединяет
частые обновления одного пользователя до одного за 10 секунд. Если ни одно устройство
не отвечает 45 секунд, статус истекает; UI обновляется при следующем опросе друзей.
«Слушать вместе» отключено для друга не в сети. Сервер проверяет присутствие обоих
участников при создании и принятии запроса, поэтому устаревшая кнопка не обходит правило.
Блокировки и ограничения аудитории проверяются до выдачи ошибки об офлайн-статусе.
«Подключиться» отправляет запрос хозяину и оставляет управление у него.
«Пригласить на мой стрим» создаёт собственную комнату: приглашённый становится слушателем.
Один пользователь состоит в одной комнате; несколько слушателей могут подключиться к хозяину.
Комната резервируется на время ожидания подтверждения. До принятия запроса
слушатель не становится участником, а баннер совместного прослушивания не показывается.
Исходное устройство ведущего поддерживает резервирование, не прерывая свою музыку.
После успешной отправки окно закрывается; ожидающий запрос можно отменить в разделе общения.
После отмены или истечения последнего запроса исходное устройство освобождает
пустую комнату при следующем опросе, не прерывая свою музыку.
Повторный запрос уже подключённого слушателя не создаёт нового ожидания.
Второе устройство ведущего не включает звук автоматически и при закрытии не завершает стрим.
Настройки приёма запросов/приглашений и автоматического принятия независимы,
обе по умолчанию «Никто». Автопринятие применяется только внутри разрешённой аудитории.
Дополнительная настройка управляет запуском звука при подключении.
Только исходное устройство хозяина публикует состояние комнаты. Слушатели получают
трек, паузу и позицию с поправкой на серверное время каждые 2 секунды. Обычные кнопки
управления, очередь и перемотка заблокированы; громкость, реакции и выход доступны.
Это синхронизация собственных потоков из каталога с допуском дрейфа 1,5 секунды,
а не ретрансляция аудиобайта хозяина. Браузер может потребовать нажатия «Включить звук».
После 10 секунд потери связи слушатель ставится на паузу, комната истекает через 30 секунд
без хозяина, отсутствующий слушатель удаляется через 60 секунд, запрос действует 2 минуты.
## Чат
Web и desktop используют встроенный экран: список диалогов слева, переписка справа.
На узком экране список и переписка открываются последовательно; кнопка назад возвращает
к списку, а поле ввода остаётся над плеером. Android использует общую нижнюю панель.
Поддерживаются текст и музыка, ответы с цитатой, шесть реакций, редактирование своих
сообщений, удаление для всех, поиск по тексту, история, даты, прочтение и индикатор набора.
Удаление очищает исходный текст и музыкальное вложение. Черновики разделены по серверу,
пользователю и собеседнику. Скрытая переписка не отмечает сообщения прочитанными.
Просмотр истории выше последних сообщений не вызывает принудительную прокрутку вниз.
Поиск открывается по кнопке. Длинные сообщения можно развернуть, HTTP(S)-ссылки
отображаются компактно без декодирования произвольного текста.
Список диалогов отсортирован по последнему сообщению; сервер отдаёт короткое превью
только для текущих друзей, с повторной проверкой блокировок. Удалённый текст очищен.
Вкладки «Друзья» и «Заявки» отделены от сообщений; приватность и настройки совместного
прослушивания открываются из компактного индикатора статуса. Удаление друга и блокировка
доступны через меню и требуют подтверждения.
Треки и плейлисты отображаются карточками с названием, обложкой и воспроизведением.
«Музыка из переписки» использует `getDirectMessages?music=true` с обычным `before`:
доступна вся история вложений, удалённые сообщения исключены, прочтение не подтверждается.
Недоступные треки и отозванные ссылки не запускаются; метаданные берутся из каталога
и публичной проекции плейлиста, а не из текста сообщения.
Анимации учитывают системное уменьшение движения. Фото, файлы и голосовые не входят в версию.
Миграции `00020`–`00022` добавляют активность, комнаты и возможности чата.
Уведомления дополнены запросом, приглашением и принятием совместного прослушивания.
## Сигнал друзей в рекомендациях
В настройках рекомендаций можно отключить влияние друзей. Используется только их
текущая видимая активность с учётом аудитории и блокировок, без скрытой истории.
Go передаёт до 30 допустимых треков; Python добавляет до 10 кандидатов, небольшой бонус
с учётом выбранного контекста и не более одного кандидата с источником `friends` на выдачу.
Дизлайки, недоступные треки и недавние повторы исключаются прежними фильтрами.
При недоступном рекомендателе резервный подбор добавляет не более одного такого трека
в каждую вторую выдачу; тематические волны с seed сохраняют свой сценарий.
+5
View File
@@ -113,3 +113,8 @@ apps/web/src/
элементы); прогресс-бары подписаны через `aria-labelledby`;
- выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку;
- `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена.
На экранах уже 768 px веб-плеер использует два ряда: обложка, название и исполнители
занимают верхний ряд целиком, управление и дополнительные действия — нижний.
Высота строки плеера в каркасе адаптируется к содержимому для вариантов bar и island.
File diff suppressed because it is too large. Load diff
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { ActivityPublisher } from './activity';
const track = { id: '00000000-0000-4000-8000-000000000001' };
const device = '00000000-0000-4000-8000-000000000002';
describe('listening activity leases', () => {
it('publishes actual playback, throttles progress, and clears on pause', async () => {
let now = 1000; const sent: unknown[] = [];
const publisher = new ActivityPublisher(async value => { sent.push(value); }, device, () => now);
publisher.update({ track, playing: false, context: null }); await publisher.idle(); expect(sent).toEqual([]);
publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 123 }); await publisher.idle();
expect(sent[0]).toMatchObject({ context: 'wave', track_id: track.id, sequence: 1, position_ms: 123 });
publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 900 }); await publisher.idle(); expect(sent).toHaveLength(1);
now += 20_001; publisher.update({ track, playing: true, context: { type: 'wave' }, positionMs: 20123 }); await publisher.idle(); expect(sent).toHaveLength(2);
publisher.update({ track, playing: false, context: null }); await publisher.idle(); expect(sent.at(-1)).toMatchObject({ playing: false, sequence: 3 });
publisher.dispose(); await publisher.idle();
});
it('serializes an in-flight play and a final stop without replaying intermediate tracks', async () => {
let release!: () => void; const waiting = new Promise<void>(r => { release = r; }); const sent: { playing: boolean; sequence: number }[] = [];
const publisher = new ActivityPublisher(async value => { sent.push(value); if (sent.length === 1) await waiting; }, device);
publisher.update({ track, playing: true, context: null });
publisher.update({ track: { id: device }, playing: true, context: null });
publisher.dispose(); release(); await publisher.idle();
expect(sent.map(v => [v.playing, v.sequence])).toEqual([[true, 1], [false, 2]]);
});
it('only treats UUID collection contexts as playlists', async () => {
const sent: { context: string }[] = []; const publisher = new ActivityPublisher(async v => { sent.push(v); }, device);
publisher.update({ track, playing: true, context: { type: 'collection', id: 'likes' } }); await publisher.idle(); expect(sent[0]?.context).toBe('other');
publisher.update({ track, playing: true, context: { type: 'collection', id: device } }); await publisher.idle(); expect(sent[1]?.context).toBe('playlist');
publisher.dispose(); await publisher.idle();
});
});
+73
View File
@@ -0,0 +1,73 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import { uuidv7, type PlayContext } from './player';
export type ActivityVisibility = components['schemas']['ActivityVisibility'];
export type ListeningActivity = components['schemas']['ListeningActivity'];
type Input = components['schemas']['ListeningActivityInput'];
export interface ActivityPlaybackState {
positionMs?: number;
playing: boolean;
track: { id: string } | null;
context: PlayContext | null;
wave?: unknown;
}
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const publishers = new WeakMap<AuthSession, Set<ActivityPublisher>>();
/** Ephemeral, serialized heartbeats. Never persists or replays offline activity. */
export class ActivityPublisher {
private current: ActivityPlaybackState = { playing: false, track: null, context: null };
private pending: Omit<Input, 'device_id' | 'sequence'> | null = null;
private sequence = 0;
private lastKey = '';
private lastAttempt = 0;
private active = false;
private closed = false;
private flight: Promise<void> | null = null;
constructor(private readonly send: (body: Input) => Promise<void>, readonly deviceId: string, private readonly now = Date.now, private readonly onDispose = () => {}) {}
update(state: ActivityPlaybackState, force = false) {
if (this.closed) return;
this.current = state;
const playing = state.playing && !!state.track;
if (!playing && !this.active) return;
const playlist = playing && !state.wave && state.context?.type === 'collection' && UUID.test(state.context.id ?? '') ? state.context.id : undefined;
const body: Omit<Input, 'device_id' | 'sequence'> = playing
? { position_ms: 0, playing: true, track_id: state.track!.id, context: state.wave || state.context?.type === 'wave' ? 'wave' : playlist ? 'playlist' : 'other', ...(playlist ? { playlist_id: playlist } : {}) }
: { position_ms: 0, playing: false, context: 'other' };
const key = JSON.stringify(body);
if (!force && key === this.lastKey && (!playing || this.now() - this.lastAttempt < 20_000)) return;
this.active ||= playing;
this.lastKey = key;
this.lastAttempt = this.now();
this.pending = { ...body, ...(playing ? { position_ms: Math.max(0, Math.round(state.positionMs ?? 0)) } : {}) };
if (!this.flight) this.flight = this.drain();
}
refresh() { this.update(this.current, true); }
idle() { return this.flight ?? Promise.resolve(); }
private async drain() {
while (this.pending) {
const body = this.pending;
this.pending = null;
try { await this.send({ ...body, device_id: this.deviceId, sequence: ++this.sequence }); }
catch { /* A failed heartbeat expires; it must never interrupt playback. */ }
}
this.flight = null;
}
dispose() {
if (this.closed) return;
this.update({ playing: false, track: null, context: null }, true);
this.closed = true;
this.onDispose();
}
}
export function createActivityPublisher(session: AuthSession, deviceId = uuidv7()) {
let set = publishers.get(session);
if (!set) { set = new Set(); publishers.set(session, set); }
const publisher = new ActivityPublisher(async body => { unwrap(await session.client.PUT('/api/v1/me/social/activity', { body })); }, deviceId, Date.now, () => set!.delete(publisher));
set.add(publisher);
return publisher;
}
export function refreshListeningActivity(session: AuthSession) { publishers.get(session)?.forEach(p => p.refresh()); }
+4
View File
@@ -23,6 +23,10 @@ export {
} from './player';
export { normalizeGain, TARGET_LUFS } from './gain';
export { playlistShareUrl, trackShareUrl } from './share';
export { socialApi, attachmentPath, type SocialPerson, type SocialInbox, type SocialAction, type DirectMessage, type SendDirectMessage, type MusicAttachment } from './social';
export { CHAT_REACTIONS, chatDraftKey, type ChatMessageUpdate } from './social';
export { ActivityPublisher, createActivityPublisher, type ActivityVisibility, type ListeningActivity } from './activity';
export { ListeningCoordinator, refreshListening, type ListeningClientState, type ListeningRoom, type ListeningState, type ListeningPreferences, type ListeningAction } from './listening';
export { checkServer, normalizeServerUrl, type ServerCheck } from './server';
export { initialWaveState, mix, rgb, rgba, step, WAVE_COLORS, wavePalette, waveScale, type Rgb, type WaveState } from './wave-anim';
+78
View File
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest';
import type { AuthSession } from './auth';
import { ListeningCoordinator, type ListeningRoom, type ListeningState } from './listening';
const host='00000000-0000-4000-8000-000000000001';const guest='00000000-0000-4000-8000-000000000002';const device='00000000-0000-4000-8000-000000000003';
const room: ListeningRoom={id:'room',owner_id:host,owner_name:'Host',device_id:device,track_id:'track',playing:true,position_ms:1000,updated_at:'2026-10-23T00:00:00Z',participants:[{id:host,name:'Host'},{id:guest,name:'Guest'}]};
function setup(user=guest,sourceDevice='other'){
let now=1000;let state:ListeningState={preferences:{requests:'friends',auto_accept:'nobody',autoplay:true},room:{...room},requests:[],server_time:'2026-10-23T00:00:02Z'};
const put=vi.fn(async()=>({response:new Response(null,{status:204})}));const post=vi.fn(async()=>({response:new Response(null,{status:204})}));
const get=vi.fn(async()=>({data:state,response:new Response()}));
const session={state:{status:'signed_in',user:{id:user}},client:{GET:get,PUT:put,POST:post}} as unknown as AuthSession;
const adapter={read:()=>({track:{id:'track'},playing:true,positionMs:4000,context:null}),apply:vi.fn(async(_r:ListeningRoom,_p:number,_current:()=>boolean)=>{}),pause:vi.fn(),changed:vi.fn()};
const coordinator=new ListeningCoordinator(session,sourceDevice,adapter,()=>now);
return {coordinator,adapter,get,put,post,setState:(v:ListeningState)=>{state=v;},state,advance:(ms:number)=>{now+=ms;}};
}
describe('shared listening',()=>{
it('publishes only from the source device and leaves source playback running',async()=>{
const e=setup(host,device);await e.coordinator.poll(true);expect(e.put).toHaveBeenCalledWith('/api/v1/me/social/listening/state',expect.objectContaining({body:expect.objectContaining({device_id:device,position_ms:4000})}));expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();expect(e.adapter.pause).not.toHaveBeenCalled();
});
it('locks follower controls and extrapolates the host position from server time',async()=>{
const e=setup();await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(true);expect(e.adapter.apply).toHaveBeenCalledWith(expect.objectContaining({track_id:'track'}),3000,expect.any(Function));expect(e.put).not.toHaveBeenCalled();e.coordinator.dispose();
});
it('prepares silent audio when autoplay is disabled',async()=>{
const e=setup();e.state.preferences.autoplay=false;await e.coordinator.poll(true);expect(e.adapter.apply).toHaveBeenCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({locked:true,needsStart:true}));e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));e.coordinator.dispose();
});
it('invalidates late synchronization and allows local exit even without a connection',async()=>{
const e=setup();let current!:()=>boolean;let release!:()=>void;
e.adapter.apply.mockImplementation(async(_r,_p,live)=>{current=live;await new Promise<void>(r=>{release=r;});});
const pending=e.coordinator.poll(true);await vi.waitFor(()=>expect(current).toBeDefined());expect(current()).toBe(true);e.coordinator.leaveLocally();expect(current()).toBe(false);expect(e.coordinator.locked).toBe(false);release();await pending;e.coordinator.dispose();
});
it('pauses on connection loss and on room termination',async()=>{
const e=setup();await e.coordinator.poll(true);e.get.mockRejectedValueOnce(new TypeError('offline'));e.advance(11000);await e.coordinator.poll(true);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({disconnected:true}));e.setState({...e.state,room:null});await e.coordinator.poll(true);expect(e.coordinator.locked).toBe(false);expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
});
it('keeps a reserved source room alive without announcing a stream before approval',async()=>{
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]},requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
await e.coordinator.poll(true);
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
expect(e.put).toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();expect(e.adapter.apply).not.toHaveBeenCalled();e.coordinator.dispose();
});
it('releases an empty source reservation after its final request is cancelled or expires',async()=>{
const e=setup(host,device);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
await e.coordinator.poll(true);
expect(e.post).toHaveBeenCalledWith('/api/v1/me/social/listening/actions',expect.objectContaining({body:expect.objectContaining({action:'end',target_id:room.id})}));
expect(e.put).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
e.coordinator.dispose();expect(e.post).toHaveBeenCalledTimes(1);
});
it('does not take over another source device while its request is pending',async()=>{
const e=setup(host);e.setState({...e.state,room:{...room,participants:[{id:host,name:'Host'}]}});
await e.coordinator.poll(true);
expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null,locked:false}));
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.pause).not.toHaveBeenCalled();e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
});
it('does not start a pending guest request with no admitted room',async()=>{
const e=setup();e.setState({...e.state,room:null,requests:[{id:'request',owner_id:host,sender_id:guest,sender_name:'Guest',recipient_id:host,kind:'join',expires_at:'2026-10-23T00:02:00Z'}]});
await e.coordinator.poll(true);expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.coordinator.locked).toBe(false);e.coordinator.dispose();
});
it('requires explicit audio on a second owner device and does not end the source when it closes',async()=>{
const e=setup(host);await e.coordinator.poll(true);
expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:false}),3000,expect.any(Function));
e.coordinator.beginAudio();await vi.waitFor(()=>expect(e.adapter.apply).toHaveBeenLastCalledWith(expect.objectContaining({playing:true}),3000,expect.any(Function)));
e.coordinator.dispose();expect(e.post).not.toHaveBeenCalled();
});
it('discards a room lookup that arrives after a local exit',async()=>{
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
const pending=e.coordinator.poll(true);e.coordinator.leaveLocally();resolve({data:e.state,response:new Response()});await pending;
expect(e.adapter.apply).not.toHaveBeenCalled();expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null}));e.coordinator.dispose();
});
it('queues a refresh requested while a state lookup is in flight',async()=>{
const e=setup();let resolve!:(value:{data:ListeningState;response:Response})=>void;
e.get.mockImplementationOnce(()=>new Promise(done=>{resolve=done;}));
const pending=e.coordinator.poll(true);e.coordinator.refresh();
e.setState({...e.state,room:null});resolve({data:{...e.state,room:{...room}},response:new Response()});await pending;
await vi.waitFor(()=>expect(e.adapter.changed).toHaveBeenLastCalledWith(expect.objectContaining({room:null})));
expect(e.get).toHaveBeenCalledTimes(2);e.coordinator.dispose();
});
});
+146
View File
@@ -0,0 +1,146 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import type { ActivityPlaybackState } from './activity';
export type ListeningRoom = components['schemas']['ListeningRoom'];
export type ListeningState = components['schemas']['ListeningState'];
export type ListeningPreferences = components['schemas']['ListeningPreferences'];
export type ListeningAction = components['schemas']['ListeningAction'];
export interface ListeningClientState { room: ListeningRoom | null; locked: boolean; needsStart: boolean; disconnected: boolean }
export interface ListeningPlaybackState extends ActivityPlaybackState { positionMs: number }
interface Adapter {
read(): ListeningPlaybackState;
apply(room: ListeningRoom, position: number, current: () => boolean): Promise<void>;
pause(): void;
changed(state: ListeningClientState): void;
}
const clients = new WeakMap<AuthSession, Set<ListeningCoordinator>>();
/** Source keeps its own wave/queue. Followers play only the source's current track. */
export class ListeningCoordinator {
private snapshot: ListeningState | null = null;
private generation = 0;
private sequence = 0;
private busy = false;
private refreshPending = false;
private closed = false;
private lastPoll = 0;
private lastSuccess = 0;
private key = '';
private enabled = new Set<string>();
private ignored = new Set<string>();
private timer: ReturnType<typeof setInterval> | null = null;
locked = false;
constructor(private readonly session: AuthSession, readonly deviceId: string, private readonly adapter: Adapter, private readonly now = Date.now) {}
start() {
let set = clients.get(this.session); if (!set) { set = new Set(); clients.set(this.session, set); } set.add(this);
this.timer ??= setInterval(() => void this.poll(), 2000);
void this.poll(true);
}
private source(room: ListeningRoom) { return this.session.state.status === 'signed_in' && room.owner_id === this.session.state.user.id && room.device_id === this.deviceId; }
private emit(room: ListeningRoom | null, needsStart = false, disconnected = false) {
this.locked = !!room && !this.source(room);
this.adapter.changed({ room, locked: this.locked, needsStart, disconnected });
}
update() {
const room = this.snapshot?.room;
if (!room || !this.source(room)) return;
const state = this.adapter.read();
const key = `${state.track?.id}:${state.playing}`;
if (key !== this.key) { this.key = key; this.refresh(); }
}
refresh() {
if (this.busy) { this.refreshPending = true; return; }
void this.poll(true);
}
allowJoining() { this.ignored.clear(); }
beginAudio() { if (this.snapshot?.room) this.enabled.add(this.snapshot.room.id); this.refresh(); }
leaveLocally() {
if (this.snapshot?.room) this.ignored.add(this.snapshot.room.id);
this.generation++;
if (this.locked) this.adapter.pause();
this.emit(null);
}
prepare(action: ListeningAction): ListeningAction {
if (action.action !== 'invite') return action;
const state = this.adapter.read();
return { ...action, device_id: this.deviceId, track_id: state.track?.id, position_ms: Math.max(0, Math.round(state.positionMs)), playing: state.playing };
}
async poll(force = false) {
if (this.closed || this.busy || (!force && !this.snapshot?.room && this.now() - this.lastPoll < 10_000)) return;
if (this.session.state?.status !== 'signed_in') return;
this.busy = true; this.lastPoll = this.now();
const requestGeneration = this.generation;
try {
const state = unwrap(await this.session.client.GET('/api/v1/me/social/listening'));
if (this.closed || requestGeneration !== this.generation) return;
this.lastSuccess = this.now();
const previous = this.snapshot?.room;
this.snapshot = state;
const room = state.room;
if (previous?.id !== room?.id) this.generation++;
if (!room) { if (this.locked) this.adapter.pause(); this.emit(null); return; }
if (this.ignored.has(room.id)) {
this.emit(null);
await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'leave', target_id: room.id } });
return;
}
// The server reserves a room while a request is pending. Only admitted
// participants turn it into a shared session; keep the source heartbeat
// alive without displaying a stream or taking over another device.
const shared = room.participants.some(person => person.id !== room.owner_id);
if (!shared && !this.source(room)) {
if (this.locked) { this.generation++; this.adapter.pause(); }
this.emit(null);
return;
}
if (this.source(room)) {
// A cancelled or expired reservation must not keep an invisible room
// alive forever and prevent its owner from joining another stream.
if (!shared && !state.requests.some(request => request.owner_id === room.owner_id)) {
unwrap(await this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: 'end', target_id: room.id } }));
this.snapshot = { ...state, room: null };
this.emit(null);
return;
}
this.emit(shared ? room : null);
const playback = this.adapter.read();
unwrap(await this.session.client.PUT('/api/v1/me/social/listening/state', { body: { room_id: room.id, device_id: this.deviceId, sequence: ++this.sequence, track_id: playback.track?.id, playing: playback.playing && !!playback.track, position_ms: Math.max(0, Math.round(playback.positionMs)) } }));
return;
}
if (!this.locked) this.adapter.pause();
const ownOtherDevice = room.owner_id === this.session.state.user.id;
const needsStart = (!state.preferences.autoplay || ownOtherDevice) && !this.enabled.has(room.id);
this.emit(room, needsStart);
const version = this.generation;
const elapsed = room.playing ? Math.max(0, Date.parse(state.server_time) - Date.parse(room.updated_at)) : 0;
await this.adapter.apply(needsStart ? { ...room, playing: false } : room, room.position_ms + elapsed, () => !this.closed && this.locked && version === this.generation && !this.ignored.has(room.id));
} catch {
if (this.closed) return;
if (this.locked && this.now() - this.lastSuccess > 10_000) { this.adapter.pause(); this.emit(this.snapshot?.room ?? null, false, true); }
} finally {
this.busy = false;
if (this.refreshPending && !this.closed) { this.refreshPending = false; this.refresh(); }
}
}
dispose() {
if (this.closed) return;
this.closed = true; this.generation++;
if (this.timer) clearInterval(this.timer); this.timer = null;
clients.get(this.session)?.delete(this);
const room = this.snapshot?.room;
if (this.locked) this.adapter.pause();
const ownAccount = this.session.state.status === 'signed_in' && room?.owner_id === this.session.state.user.id;
if (room && (this.source(room) || !ownAccount)) void this.session.client.POST('/api/v1/me/social/listening/actions', { body: { id: this.deviceId, action: this.source(room) ? 'end' : 'leave', target_id: room.id } }).catch(() => undefined);
}
}
export async function sendListeningAction(session: AuthSession, action: ListeningAction) {
const set = clients.get(session);
if (action.action === 'leave' || action.action === 'end') set?.forEach(c => c.leaveLocally());
const client = set?.values().next().value as ListeningCoordinator | undefined;
unwrap(await session.client.POST('/api/v1/me/social/listening/actions', { body: client?.prepare(action) ?? action }));
if (action.action !== 'leave' && action.action !== 'end') set?.forEach(c => c.allowJoining());
set?.forEach(c => c.refresh());
}
export function refreshListening(session: AuthSession) { clients.get(session)?.forEach(c => c.refresh()); }
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest';
import { attachmentPath } from './social';
describe('music attachments', () => {
it('creates only internal catalog links and escapes identifiers', () => {
expect(attachmentPath({ attachment_kind: 'track', attachment_id: 'a/b?sig=secret' })).toBe('/track/a%2Fb%3Fsig%3Dsecret');
expect(attachmentPath({ attachment_kind: 'playlist', attachment_id: 'a/b' })).toBe('/shared/playlist/a%2Fb');
expect(attachmentPath({ attachment_kind: 'none' })).toBeNull();
expect(attachmentPath({ attachment_kind: 'track' })).toBeNull();
});
});
+44
View File
@@ -0,0 +1,44 @@
import type { components } from '@pipu/api-client';
import { type AuthSession, unwrap } from './auth';
import { refreshListeningActivity, type ActivityVisibility } from './activity';
import { sendListeningAction, refreshListening, type ListeningAction, type ListeningPreferences } from './listening';
export type SocialPerson = components['schemas']['SocialPerson'];
export type SocialInbox = components['schemas']['SocialInbox'];
export type SocialAction = components['schemas']['SocialAction'];
export type DirectMessage = components['schemas']['DirectMessage'];
export type SendDirectMessage = components['schemas']['SendDirectMessage'];
export type MusicAttachment = { kind: 'track' | 'playlist'; id: string; title: string };
export type ChatMessageUpdate = { action: 'edit' | 'delete' | 'react'; text?: string; emoji?: '❤️' | '👍' | '🔥' | '😂' | '🎵' | '👏' };
export const CHAT_REACTIONS = ['❤️', '👍', '🔥', '😂', '🎵', '👏'] as const;
export function chatDraftKey(server: string, user: string, peer: string) { return `pipu.chat-draft.${encodeURIComponent(server)}.${user}.${peer}`; }
/** All clients use the authenticated transport, including refresh and account isolation. */
export function socialApi(session: AuthSession) {
return {
inbox: async () => unwrap(await session.client.GET('/api/v1/me/social')),
setFriendsRecommendations: async (enabled: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/recommendations', { body: { enabled } })),
listening: async () => unwrap(await session.client.GET('/api/v1/me/social/listening')),
listeningAction: (action: ListeningAction) => sendListeningAction(session, action),
setListeningPreferences: async (body: ListeningPreferences) => { unwrap(await session.client.PUT('/api/v1/me/social/listening/preferences', { body })); refreshListening(session); },
setVisibility: async (visibility: ActivityVisibility) => {
unwrap(await session.client.PUT('/api/v1/me/social/activity/visibility', { body: { visibility } }));
refreshListeningActivity(session);
},
activityPlaylist: async (userId: string) => unwrap(await session.client.GET('/api/v1/social/activity/{userId}/playlist', { params: { path: { userId } } })),
search: async (q: string) => unwrap(await session.client.GET('/api/v1/me/social/people', { params: { query: { q } } })),
action: async (body: SocialAction) => unwrap(await session.client.POST('/api/v1/me/social/actions', { body })),
messages: async (peerId: string, before?: number, q?: string, music?: boolean) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId }, query: { before, q, music } } })),
chatMeta: async (peerId: string) => unwrap(await session.client.GET('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } } })),
typing: async (peerId: string, typing: boolean) => unwrap(await session.client.PUT('/api/v1/me/social/chats/{peerId}/meta', { params: { path: { peerId } }, body: { typing } })),
updateMessage: async (peerId: string, messageId: string, body: ChatMessageUpdate) => unwrap(await session.client.PATCH('/api/v1/me/social/chats/{peerId}/messages/{messageId}', { params: { path: { peerId, messageId } }, body })),
send: async (peerId: string, body: SendDirectMessage) => unwrap(await session.client.POST('/api/v1/me/social/chats/{peerId}', { params: { path: { peerId } }, body })),
};
}
export function attachmentPath(message: Pick<DirectMessage, 'attachment_kind' | 'attachment_id'>): string | null {
if (!message.attachment_id) return null;
if (message.attachment_kind === 'track') return `/track/${encodeURIComponent(message.attachment_id)}`;
if (message.attachment_kind === 'playlist') return `/shared/playlist/${encodeURIComponent(message.attachment_id)}`;
return null;
}
+113 -2
View File
@@ -32,7 +32,21 @@
"recs_confirm": "Reset {{username}}'s recommendations? The wave will start learning their taste from scratch and ask for favourite artists again. Likes and history stay.",
"recs_done": "{{username}}'s recommendations were reset",
"confirm": "Confirm",
"you": "you"
"you": "you",
"manage_updates": "Server updates",
"grant_updates": "Grant update management",
"revoke_updates": "Revoke update management"
},
"transfer": {
"export": "Export users",
"import": "Import users",
"apply": "Import",
"description": "Transfer accounts, roles and access permissions with existing passwords. Recommendations, history, likes and sessions are excluded. Existing usernames will be skipped without changes.",
"sensitive": "The JSON file contains password hashes. Keep it in a secure location.",
"file": "Users file (.json)",
"preview": "Accounts in file: {{count}}",
"invalid": "Invalid file. Select a Pipu JSON export, version 1, up to 16 MiB and 10000 users.",
"result": "Created: {{count}}. Existing accounts skipped: {{skipped}}."
},
"create": {
"title": "New user",
@@ -61,6 +75,8 @@
"empty": "No entries yet",
"more": "Show more",
"actions": {
"users.export": "Users exported",
"users.import": "Users imported",
"user.username_change": "Username changed",
"user.bootstrap_create": "Initial administrator created",
"user.bootstrap_credentials": "Administrator credentials set",
@@ -71,10 +87,13 @@
"user.unblock": "User unblocked",
"user.password_reset": "Password reset",
"user.recommendations_reset": "Recommendations reset",
"user.delete": "User deleted"
"user.delete": "User deleted",
"user.manages_updates": "Server update permission changed"
}
},
"sources": {
"cover_files": "Local covers: {{count}} of {{total}} files",
"cover_pending": "Albums awaiting processing: {{count}}",
"title": "Storage",
"subtitle": "Where the server takes music from: a folder on the server or NFS",
"add": "Add storage",
@@ -378,5 +397,97 @@
"server_settings": {
"title": "Server settings",
"subtitle": "Settings that apply to all users"
},
"management": {
"server": "Server management",
"updates": "Updates",
"config": "Configuration",
"updates_intro": "Choose a source and version. The server will build and report the result.",
"refresh": "Refresh status",
"current": "Installed version",
"manual": "Updates start manually. The latest stable release is resolved when you start the update.",
"update_target": "Next update",
"sources": "Update sources",
"source": "Source",
"channel": "Update target",
"channel_latest": "Latest release",
"channel_version": "Choose version",
"channel_main": "main",
"hint_latest": "Install the latest stable tag, excluding release candidates.",
"hint_version": "Choose a published tag. Downgrading to an older version requires a manual rollback.",
"hint_main": "The current main branch. May include unreleased changes.",
"version": "Version",
"choose_version": "Choose a version",
"source_unavailable": "Could not read the repository. Check its address, server access and SSH keys.",
"install": "Install update",
"confirm_update": "Update the server?",
"confirm_update_text": "Source: {{source}}. Target: {{target}}.",
"update_warning": "The service will be briefly unavailable during restart. Back up the database first. Database migrations are not rolled back.",
"cancel": "Cancel",
"sources_hint": "Git repositories compatible with Pipu. HTTP(S) or SSH; configure private repository keys on the agent host. Do not include tokens in the URL.",
"source_name": "Name",
"repository": "Git repository URL",
"remove_source": "Remove source",
"add_source": "Add source",
"save": "Save",
"job": "Last operation",
"started": "Started",
"finished": "Finished",
"log": "Execution log",
"waiting": "Waiting for command output…",
"running_hint": "The task runs on the server. You can close this page without interrupting it.",
"reconnecting": "Services are restarting. Waiting to reconnect to the server…",
"state": {
"running": "Running",
"succeeded": "Completed",
"failed": "Failed"
},
"config_intro": "API, recommender and deployment settings in deploy/config.local.",
"priority": "Priority order",
"defaults": "defaults",
"priority_hint": ".env values override config.local. Save and apply are separate actions.",
"search": "Search settings",
"group": "Section",
"all": "All sections",
"groups": {
"api": "API",
"deployment": "Deployment",
"shared": "Shared",
"recommender": "Recommender"
},
"config_files_hint": "Supported settings are listed here. “After applying” is the value from .env, config.local or a default. Fixed paths and service parameters are read-only. Secrets can be replaced; their values stay hidden.",
"value_source": {
"env": "From .env",
"local": "From config.local",
"default": "Default"
},
"effective": "After applying",
"readonly": "Service parameter. Change on the host together with its Compose configuration.",
"secret_saved": "Secret saved",
"will_reset": "The local value will be removed when saved.",
"overridden": "A value in .env takes priority. This local change will not take effect yet.",
"secret_hint": "Leave unchanged to keep the stored secret. Enter a new value to replace it.",
"default": "Default",
"reset": "Reset local value",
"no_results": "No settings found",
"saved": "Changes saved to config.local",
"unsaved": "Unsaved settings: {{count}}",
"pending_restart": "Saved changes are waiting to be applied",
"no_changes": "No changes to apply",
"discard": "Discard edits",
"apply": "Apply and restart",
"apply_warning": "Containers will be recreated with the saved configuration. The service will be briefly unavailable; persistent volume data is preserved. Values from .env will keep taking priority.",
"check_updates": "Check for updates",
"unsaved_panel": "Unsaved changes",
"availability": {
"checking": "Checking the selected version…",
"running": "Update in progress",
"available": "Update available",
"current": "This version is already installed — no updates",
"ahead": "A newer build is already installed",
"unknown": "Could not identify the installed commit. Update the server manually.",
"unavailable": "The selected version is unavailable",
"select_version": "Choose a version to check"
}
}
}
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more