Compare commits

...
27 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 976ffb574b feat(web): player bar, playable track lists and Collection (M3)
ci / ts / ts (packages + web) (push) Successful in 53s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 2m33s
- player bar with cover, like and dislike, previous / play / next,
  shuffle, repeat, seek bar and volume; PlayerProvider lives with the
  signed-in shell and restores the last track and position paused
- clicking a row (or Enter) plays the list from that track with its
  context (album, search, history, collection); the current track is
  highlighted and rows have a like button; a reaction refreshes lists
- Collection: history grouped by day with clearing, likes with "play
  all" and dislikes, all with paging; sidebar section
- events and playback state are flushed when the page is hidden;
  player timers survive StrictMode remounts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:11:12 +03:00
mrleo1nidandClaude Opus 5.5 ec3c2810c8 feat(core,web): player queue, play events and web player controller (M3, wip)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m6s
ci / ts / ts (packages + web) (push) Successful in 1m5s
- core: PlayQueue with shuffle and repeat, PlayAttempt counting only
  time actually played, EventBuffer with batching and backoff, uuidv7
- web: Player over HTMLAudioElement with Media Session, preload of the
  next track, expired-link refresh, local and server playback state,
  hotkeys; PlayerProvider is not wired into the shell yet
- ru/en strings and icons for the player and Collection

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:28:20 +03:00
mrleo1nidandClaude Opus 5.5 c05e523cbe feat(api): streaming, transcoding, events, history and likes (M3)
ci / api / api (push) Successful in 29s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s
ci / ts / ts (packages + web) (push) Failing after 49s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
- tracks carry a stream_url signed for the user for 12 h and the
  user's reaction; /stream checks the signature, expiry and that the
  user is not blocked, then hands the file to nginx by X-Accel-Redirect
  (or serves it with Range support when X_ACCEL=false)
- original by default; formats browsers can't play and explicit
  high/medium/low go through Opus transcoding, never upwards: a lossy
  source under the target bitrate stays original; transcodes are
  cached per file and size, deduplicated in flight, limited by
  TRANSCODE_CONCURRENCY and trimmed hourly by LRU to the size limit
- /events takes up to 500 events with client UUIDs, drops repeats and
  events older than 30 days, counts plays (≥ half or 4 min, or
  finished), early skips and history entries (≥ 30 s)
- history with clearing, likes and dislikes with cursors and totals,
  track reactions, playback state with last-write-wins, tracks batch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:25:15 +03:00
mrleo1nidandClaude Opus 5.5 8b76aa3964 feat: album covers with palettes and signed links (M2)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 55s
ci / ts / ts (packages + web) (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 34s
ci / api / api (push) Successful in 26s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
- covers are taken from the embedded picture of the first track or a
  cover/folder/front image in the album folder, converted by ffmpeg to
  WebP 64/300/1200 and named by content hash, so albums sharing a cover
  share the files; a full scan looks again for albums without one
- a 3-colour palette (dominant, second, most saturated accent) from a
  deterministic k-means feeds placeholders, page tints and the wave
- /covers/{id} is public but HMAC-signed and immutable; nginx serves
  the file via X-Accel-Redirect, X_ACCEL=false serves it from api
- the web client picks the size by display size and pixel density
- install.sh and update.sh generate STREAM_SIGNING_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:06:15 +03:00
mrleo1nidandClaude Opus 5.5 a685b1b7ca feat(web): storage admin, search and catalog pages (M2)
ci / ts / ts (packages + web) (push) Successful in 1m56s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m59s
- Storage admin: local folders with a folder picker and NFS sources,
  connection test, scan and full scan with live progress, enable,
  disable, delete
- path pattern editor with reorder, examples and a preview table that
  shows which pattern matched and what came from tags; compile errors
  land under the broken pattern
- Library page with counters, scans and unreadable files; search
  reindex for admins
- search box in the top bar: combobox with keyboard navigation, 150 ms
  debounce, cancelled stale requests, "/" and Ctrl+K, layout fix hint
- search page with tabs and paging, artist and album pages, album
  cards and track lists; unavailable tracks are dimmed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 07:01:52 +03:00
mrleo1nidandClaude Opus 5.5 c1160cccd3 feat(api): search with Meilisearch (M2)
ci / api / api (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m1s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 34s
ci / ts / ts (packages + web) (push) Successful in 1m1s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 25s
- artists, albums and tracks indexes are rebuilt from PostgreSQL next
  to the live ones and swapped in, so search keeps working; a rebuild
  runs on start, after every scan and when a source is removed
- transliteration fields (Кино ↔ kino) and a retry in the other
  keyboard layout (ghbdtn → привет) reported as corrected_query
- /search/suggest (3 artists, 3 albums, 5 tracks), /search by type
  with cursor pages, /admin/search/reindex; Meilisearch is part of
  /readyz
- found ids are hydrated from the catalog, so a lagging index never
  returns deleted entries

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:51:50 +03:00
mrleo1nidandClaude Opus 5.5 c583991d14 feat(api): library sources, scanner and catalog (M2)
ci / api / api (push) Successful in 26s
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 22s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 37s
ci / ts / ts (packages + web) (push) Successful in 51s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 53s
- schema for sources, path patterns, media files, artists, albums,
  tracks, genres (migration 00003)
- path patterns (RE2 with named groups, skip), merge with tags by
  source priority, fallbacks from the file name and folder, name
  normalization and multi-artist split
- scanner: walk, skip unchanged files by size and mtime, ffprobe in 8
  workers with a single catalog writer, renames by content hash keep
  the track, duplicates fold into one track with the best file as
  primary, missing files are kept 30 days
- storage-agent mounts NFS with validated server, export and an option
  whitelist, no shell; test mount counts audio files
- admin API: sources CRUD, connection test, patterns with preview,
  scan, library status, file errors, host folder picker; scheduled
  scans and health checks; mounts are restored on start
- catalog API: artist, artist albums, album with tracks, track
- api image is alpine with ffmpeg for ffprobe

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:48:37 +03:00
mrleo1nidandClaude Opus 5.5 ab092def54 feat(web): sign-in, first-run setup, app shell and user admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 50s
ci / ts / ts (packages + web) (push) Successful in 43s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 27s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m22s
- core: AuthSession keeps the access token in memory, refreshes it
  once for all parallel requests (single-flight) and retries them;
  refresh races between tabs are retried, reuse signs out; ApiError
  carries problem+json codes and field errors
- web: TanStack Router with guards — guests go to /login, the bootstrap
  admin to /setup/admin, temporary passwords to /setup/password, admin
  pages by role
- login, setup screens, Classic shell (top bar, role-based sidebar,
  empty player), settings: name, theme, language, password, devices
- users admin: search, role filter, create with a one-time temporary
  password, reset, block, role change, delete as the policy allows;
  audit log for admins
- ru/en strings for auth, admin and error codes; language and theme
  are saved in the profile

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:33:46 +03:00
mrleo1nidandClaude Opus 5.5 f075863f15 feat(api): user management and audit log for admins (M1)
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 38s
ci / api / api (push) Successful in 18s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 35s
- /admin/users: list with search, role filter and cursor pages; create
  with a one-time temporary password; rename, change role, block;
  reset password; delete
- every change is checked against the role policy, a forbidden field
  rejects the whole patch; the last active admin is locked in the
  transaction and cannot be demoted, blocked or deleted
- role change and blocking revoke access tokens at once; blocking and
  password reset also end all sessions
- /admin/audit: newest-first journal with actor names
- path and query parameter errors are problem+json now

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:18:02 +03:00
mrleo1nidandClaude Opus 5.5 ed4a492dba feat(api): sign-in, refresh rotation and bootstrap admin (M1)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m15s
ci / api / api (push) Successful in 1m31s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 47s
ci / ts / ts (packages + web) (push) Successful in 39s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 26s
- users, refresh_tokens and audit_log schema (migration 00002)
- argon2id passwords with rehash on login, password policy, lockout
  with exponential back-off and a per-IP rate limit in Redis
- Ed25519 JWT access tokens with kid-based key rotation; rotating
  refresh tokens with reuse detection (cookie for web, body for Android)
- bootstrap admin on first start; must_change_credentials and
  must_change_password gate every other endpoint
- /auth/login, /auth/refresh, /auth/logout(-all), /me, /me/password,
  /me/bootstrap-credentials, /me/sessions; server-info reports
  bootstrap_required
- role policy from docs/auth-and-roles.md with table tests
- problem+json errors with field errors; ru/en translations of the codes
- install.sh and update.sh generate JWT_PRIVATE_KEY

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 06:12:25 +03:00
mrleo1nidandClaude Opus 5.5 b6def39e29 fix(deploy): probe readyz via 127.0.0.1 in update.sh
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 20s
In the alpine nginx image localhost resolves to ::1 first while nginx
listens on IPv4 only, so the readiness wait never succeeded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:51:50 +03:00
mrleo1nidandClaude Opus 5.5 6fe76aea90 fix(deploy): mount the local music folder read-only in storage-agent
Upload import is not implemented yet, so nothing needs write access to
LIBRARY_HOST. Write access will come back together with the import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:51:50 +03:00
mrleo1nidandClaude Opus 5.5 0acdc3fbb3 fix(deploy): make install.sh and update.sh executable
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 21s
Scripts were committed without the executable bit, so ./install.sh failed
with "command not found" on a fresh clone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:42:11 +03:00
mrleo1nidandClaude Opus 5.5 a996b97694 fix(release): run the android job steps in bash
release / release (версия и заметки) (push) Successful in 4s
release / android (APK в релиз) (push) Successful in 1m55s
With a container block act runs steps through sh, which rejected the bash
array in the build step (v0.1.13: Syntax error: "(" unexpected).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:38:33 +03:00
mrleo1nidandClaude Opus 5.5 b8cfd64208 fix(release): run React Native codegen before the APK build
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Failing after 26s
The app's CMake configure reads JNI sources generated by library codegen
(react-native-edge-to-edge), but the RN Gradle plugin does not declare that
dependency. With a warm Gradle cache configureCMake started before codegen
and v0.1.12 failed. Run generateCodegenArtifactsFromSchema in a separate
Gradle call first; both calls share one daemon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:37:14 +03:00
mrleo1nidandClaude Opus 5.5 0d9418df48 ci(release): skip lintVitalRelease in the APK build
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Failing after 54s
lintVital runs Google Play pre-release checks on every module (~1 min);
the APK is only published to Gitea releases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:34:39 +03:00
mrleo1nidandClaude Opus 5.5 bf3c00ca71 ci(release): rename the diagnostic steps of the Android build
"Free space" no longer described them: the pre-build step now also prints
memory and the size of the persistent Gradle/Android caches, and the
failure step reports disk, memory and OOM kills.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:25:50 +03:00
mrleo1nidandClaude Opus 5.5 633a8f79af ci: cache turbo, npx and Metro; install web deps before copying code
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m41s
ci / ts / ts (packages + web) (push) Successful in 52s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 21s
- ci-ts restores the local turbo cache, so unchanged packages skip
  typecheck, test and build
- ci-mobile caches the npx copy of expo-doctor and Metro's transform cache
- the web image copies only workspace manifests before pnpm install, so the
  dependency layer survives code changes (CI docker job and update.sh)
- docs: table of what is cached where

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:24:29 +03:00
mrleo1nidandClaude Opus 5.5 a239650075 fix(release): public links in release notes and a cancelled status
ci / ts / ts (packages + web) (push) Successful in 1m24s
release / release (версия и заметки) (push) Successful in 45s
release / android (APK в релиз) (push) Failing after 12m0s
The job reaches the Gitea API at its internal address, so html_url in the
response pointed at http://10.10.1.129:3000 and every link in the notes was
unreachable from outside. Build links from PUBLIC_URL (repo variable,
defaults to https://gitea.hsrv.site). A cancelled Android build now shows
"cancelled" instead of staying at "building".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 05:09:59 +03:00
mrleo1nidandClaude Opus 5.5 718125b719 ci: split CI by area with path filters and speed up releases
ci / api / api (push) Successful in 54s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 53s
ci / recommender / recommender (push) Successful in 38s
release / release (версия и заметки) (push) Successful in 7s
ci / ts / ts (packages + web) (push) Successful in 37s
release / android (APK в релиз) (push) Canceled after 4m25s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m47s
- ci.yml becomes ci-api, ci-contract, ci-recommender, ci-ts, ci-mobile and
  docker workflows, each filtered by paths; docs-only commits run nothing
- docker builds all four images in one job with plain docker build on the
  runner host, so the layer cache survives between runs
- release no longer re-runs the test suite (the tagged commit already passed
  CI on main); version and release notes are one job
- the android job mounts persistent Gradle and Android SDK caches from
  /opt/ci-cache (when the runner allows the volumes) and uses --build-cache
- RELEASE_TOKEN is optional: fall back to the built-in job token
- docs: CI layout and act_runner settings (capacity, cache, valid_volumes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:55:45 +03:00
mrleo1nidandClaude Opus 5.5 d3b2e7bfc3 ci(release): publish the release first, then attach the APK
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / ts (packages + web) (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 44s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 29s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / api (push) Successful in 1m23s
ci / recommender (push) Successful in 37s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 1m33s
release / release (заметки) (push) Failing after 5s
release / android (APK в релиз) (push) Skipped
release / verify (push) Successful in 1m19s
The release job now creates the Gitea release right after verification with
generated notes: Android build status, changes grouped by Conventional Commit
type with commit links, server update steps and APK install/check notes.
The android job attaches the APK and .sha256 and rewrites only the status
block (building / built with size, versionCode and SHA-256 / failed with a
log link). scripts/release.mjs is idempotent on re-runs and unit-tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:45:21 +03:00
mrleo1nidandClaude Opus 5.5 622eb84bc9 fix(mobile): hoist expo-modules-autolinking instead of depending on it
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 47s
ci / api (push) Successful in 54s
ci / recommender (push) Canceled after 8s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
expo-doctor rejects expo-modules-autolinking as a direct dependency, which
failed the CI mobile job. Expose it through publicHoistPattern so Expo can
still require it from the apps/mobile/node_modules/expo symlink and the
autolinking import stays expo.modules.ExpoModulesPackage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:28:58 +03:00
mrleo1nidandClaude Opus 5.5 65aef3164a fix(mobile): make expo-modules-autolinking resolvable from the app
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m23s
ci / recommender (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 51s
ci / api (push) Successful in 58s
ci / ts (packages + web) (push) Successful in 39s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 45s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 53s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 28s
ci / mobile (Metro-бандл Android) (push) Failing after 38s
release / verify (push) Successful in 1m17s
release / android + release (push) Failing after 12m33s
pnpm 11 ignores node-linker=hoisted in .npmrc, so node_modules uses the
isolated layout. Expo evaluates expo's react-native.config.js through the
apps/mobile/node_modules/expo symlink, where require('expo-modules-autolinking')
fails silently; autolinking then guessed the import from the Gradle namespace
(expo.core.ExpoModulesPackage) and the release Java compile failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 03:18:23 +03:00
mrleo1nidandClaude Opus 5.5 12fc866c26 ci(release): cap memory of the Android release build
ci / api (push) Canceled after 5m58s
ci / recommender (push) Canceled after 25s
ci / ts (packages + web) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
release / verify (push) Successful in 1m21s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 45s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
release / android + release (push) Failing after 9m39s
v0.1.5 got past Metro, but the Gradle daemon was killed mid-bundle on the
8 GB runner. Compile Kotlin inside the Gradle JVM instead of a second
daemon, limit Gradle and Metro to two workers, set the heap explicitly and
print free/dmesg when the build fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:55:48 +03:00
mrleo1nidandClaude Opus 5.5 f76b3c89b3 fix(mobile): hide Gradle outputs inside node_modules from Metro
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m6s
ci / api (push) Successful in 51s
ci / recommender (push) Successful in 36s
ci / ts (packages + web) (push) Successful in 39s
ci / mobile (Metro-бандл Android) (push) Successful in 52s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 43s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 48s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 28s
release / verify (push) Successful in 1m14s
release / android + release (push) Failing after 7m41s
Gradle builds native modules in place, under node_modules/<pkg>/android/
{build,.cxx,.gradle}. The root node_modules is a watch folder, so Metro still
crawled and hashed those artifacts and the release bundle step stalled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 02:09:32 +03:00
mrleo1nidandClaude Opus 5.5 38edcd3e0e docs(install): music on a local disk at /mnt/music, repo in /opt/pipu-music
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Canceled after 0s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Canceled after 0s
ci / mobile (Metro-бандл Android) (push) Canceled after 0s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / recommender (push) Canceled after 0s
ci / ts (packages + web) (push) Canceled after 0s
Add an install step for mounting the music disk via fstab and wire it into
the stack: LIBRARY_HOST in .env points at the host folder, compose mounts it
as /library-host (read-only for api, nginx and analyzer; writable only for
storage-agent), nginx serves it through X-Accel, and install.sh validates the
path and records it in .env.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:51:26 +03:00
mrleo1nidandClaude Opus 5.5 42d64cd49f fix(mobile): keep Metro out of the native android/ios build directories
ci / api (push) Successful in 52s
ci / recommender (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/recommender/Dockerfile, recommender, ) (push) Successful in 32s
ci / mobile (Metro-бандл Android) (push) Successful in 51s
ci / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 53s
ci / ts (packages + web) (push) Successful in 38s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (apps/web/Dockerfile, web, ) (push) Successful in 57s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, api, api) (push) Successful in 51s
ci / docker (образы собираются — их сервер собирает сам при обновлении) (services/api/Dockerfile, storage-agent, storage-agent) (push) Successful in 53s
release / verify (push) Successful in 1m11s
release / android + release (push) Canceled after 24m53s
Expo's monorepo config watches all of apps/mobile, so during the release
build Metro crawled and hashed gigabytes of Gradle/CMake intermediates in
android/ and the createBundleReleaseJsAndAssets task appeared to hang.
Run the release build with CI=1 (no file watcher) and NODE_ENV=production.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 01:28:57 +03:00
159 changed files with 31117 additions and 505 deletions

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
# Go: сервис api и storage-agent (docs/ci-cd.md). Только при изменении их кода или контракта.
name: ci / api
on:
push:
branches: [main]
paths:
- 'services/api/**'
- 'api/openapi.yaml'
- '.gitea/workflows/ci-api.yml'
pull_request:
paths:
- 'services/api/**'
- 'api/openapi.yaml'
- '.gitea/workflows/ci-api.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
api:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/api } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go build ./cmd/...
+59
View File
@@ -0,0 +1,59 @@
# Сгенерированный код (Go-сервер, TS-клиент, дизайн-токены) совпадает с исходниками (docs/ci-cd.md).
name: ci / contract
on:
push:
branches: [main]
paths:
- 'api/openapi.yaml'
- 'services/api/internal/api/gen/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'packages/api-client/**'
- 'packages/design-tokens/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-contract.yml'
pull_request:
paths:
- 'api/openapi.yaml'
- 'services/api/internal/api/gen/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'packages/api-client/**'
- 'packages/design-tokens/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-contract.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
contract:
name: contract (сгенерированный код совпадает с openapi.yaml)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
run: |
(cd services/api && go generate ./...)
pnpm generate
- name: Проверка, что всё закоммичено
# status, а не diff: diff не видит новые неотслеживаемые файлы
run: |
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
+51
View File
@@ -0,0 +1,51 @@
# Expo: expo-doctor и Metro-бандл Android (docs/ci-cd.md). Полная сборка APK — только в release.yml.
name: ci / mobile
on:
push:
branches: [main]
paths:
- 'apps/mobile/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-mobile.yml'
pull_request:
paths:
- 'apps/mobile/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-mobile.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
mobile:
name: mobile (Metro-бандл Android)
runs-on: ubuntu-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
# npx expo-doctor качает пакет в кэш npm; Metro хранит трансформации в /tmp/metro-cache
- uses: actions/cache@v4
with:
path: |
~/.npm/_npx
/tmp/metro-cache
key: mobile-${{ hashFiles('pnpm-lock.yaml') }}-${{ github.sha }}
restore-keys: mobile-${{ hashFiles('pnpm-lock.yaml') }}-
- run: npx expo-doctor
- run: npx expo export --platform android --output-dir dist
+31
View File
@@ -0,0 +1,31 @@
# Python: recommender и analyzer (docs/ci-cd.md).
name: ci / recommender
on:
push:
branches: [main]
paths:
- 'services/recommender/**'
- '.gitea/workflows/ci-recommender.yml'
pull_request:
paths:
- 'services/recommender/**'
- '.gitea/workflows/ci-recommender.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
recommender:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/recommender } }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy pipu_rec
- run: uv run pytest -q
+53
View File
@@ -0,0 +1,53 @@
# TypeScript: пакеты, веб, typecheck мобильного приложения, скрипты релиза (docs/ci-cd.md).
name: ci / ts
on:
push:
branches: [main]
paths:
- 'apps/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-ts.yml'
pull_request:
paths:
- 'apps/**'
- 'packages/**'
- 'scripts/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/ci-ts.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
ts:
name: ts (packages + web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "22", cache: pnpm }
- run: pnpm install --frozen-lockfile
# Локальный кэш turbo: typecheck/test/build пропускаются для пакетов, чьи входы не менялись
- uses: actions/cache@v4
with:
path: .turbo/cache
key: turbo-ts-${{ github.sha }}
restore-keys: turbo-ts-
- run: pnpm i18n:check
- run: pnpm test:scripts
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pipu/web build
-111
View File
@@ -1,111 +0,0 @@
# Проверки на каждый push в main и на каждый PR (docs/ci-cd.md).
name: ci
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
GO_VERSION: "1.24"
NODE_VERSION: "22"
jobs:
contract:
name: contract (сгенерированный код совпадает с openapi.yaml)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Регенерация Go-сервера, TS-клиента и дизайн-токенов
run: |
(cd services/api && go generate ./...)
pnpm generate
- name: Проверка, что всё закоммичено
# status, а не diff: diff не видит новые неотслеживаемые файлы
run: |
changes="$(git status --porcelain -- services/api/internal/api/gen packages)"
if [ -n "$changes" ]; then echo "$changes"; git diff -- services/api/internal/api/gen packages; exit 1; fi
api:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/api } }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- run: test -z "$(gofmt -l .)" || (gofmt -l . && exit 1)
- run: go vet ./...
- run: go test -race -coverprofile=coverage.out ./...
- run: go build ./cmd/...
recommender:
runs-on: ubuntu-latest
defaults: { run: { working-directory: services/recommender } }
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2", enable-cache: true, cache-dependency-glob: services/recommender/uv.lock }
- run: uv sync --frozen
- run: uv run ruff check .
- run: uv run ruff format --check .
- run: uv run mypy pipu_rec
- run: uv run pytest -q
ts:
name: ts (packages + web)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: pnpm i18n:check
- run: pnpm test:scripts
- run: pnpm typecheck
- run: pnpm test
- run: pnpm --filter @pipu/web build
docker:
name: docker (образы собираются — их сервер собирает сам при обновлении)
runs-on: ubuntu-latest
strategy:
matrix:
include:
- { name: api, dockerfile: services/api/Dockerfile, target: api }
- { name: storage-agent, dockerfile: services/api/Dockerfile, target: storage-agent }
- { name: recommender, dockerfile: services/recommender/Dockerfile, target: "" }
- { name: web, dockerfile: apps/web/Dockerfile, target: "" }
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
target: ${{ matrix.target }}
push: false
build-args: VERSION=ci
mobile:
name: mobile (Metro-бандл Android)
runs-on: ubuntu-latest
defaults: { run: { working-directory: apps/mobile } }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- run: npx expo-doctor
- run: npx expo export --platform android --output-dir dist
+61
View File
@@ -0,0 +1,61 @@
# Сборка всех Docker-образов без push: сервер собирает их сам при обновлении, и здесь ловятся поломки
# Dockerfile и зависимостей. Только при изменении Dockerfile, deploy/ и манифестов зависимостей.
name: ci / docker
on:
push:
branches: [main]
paths:
- '**/Dockerfile'
- '.dockerignore'
- 'deploy/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'services/recommender/pyproject.toml'
- 'services/recommender/uv.lock'
- 'apps/web/package.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/docker.yml'
pull_request:
paths:
- '**/Dockerfile'
- '.dockerignore'
- 'deploy/**'
- 'services/api/go.mod'
- 'services/api/go.sum'
- 'services/recommender/pyproject.toml'
- 'services/recommender/uv.lock'
- 'apps/web/package.json'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- 'turbo.json'
- 'tsconfig.base.json'
- '.gitea/workflows/docker.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
docker:
name: docker (образы собираются — их сервер собирает сам при обновлении)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Обычный docker build через Docker хоста раннера, а не buildx-контейнер: кэш слоёв и
# --mount=type=cache переживают job, повторная сборка занимает секунды. Фиксированные теги
# не дают копиться образам; кэш сборки чистит на хосте `docker builder prune` (docs/ci-cd.md).
- name: Сборка образов
env: { DOCKER_BUILDKIT: "1" }
run: |
set -euo pipefail
build() { echo "::group::$1"; docker build --build-arg VERSION=ci -t "pipu-ci/$1" "${@:2}" .; echo "::endgroup::"; }
build api -f services/api/Dockerfile --target api
build storage-agent -f services/api/Dockerfile --target storage-agent
build recommender -f services/recommender/Dockerfile
build web -f apps/web/Dockerfile
+90 -70
View File
@@ -1,8 +1,12 @@
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): проверки → подписанный APK → Gitea Release.
# Релиз по тегу vMAJOR.MINOR.PATCH[-rc.N] (docs/ci-cd.md): Gitea Release с заметками → APK.
# Релиз создаётся сразу (статус Android — «собирается»); сборка APK докладывает в него файлы
# и переписывает статус: собран / не удалась (scripts/release.mjs).
# Docker-образы не публикуются: сервер собирает их сам из клонированного репозитория (deploy/update.sh).
#
# Секреты (Settings → Actions → Secrets):
# RELEASE_TOKEN — создание релиза и загрузка ассетов (scope: write:repository)
# RELEASE_TOKEN (необязательно) — токен для релиза; без него — встроенный токен job (права на запись в репозиторий)
# Переменные (Settings → Actions → Variables):
# PUBLIC_URL (необязательно) — внешний адрес Gitea для ссылок в тексте релиза; по умолчанию https://gitea.hsrv.site
# ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD — подпись APK
name: release
@@ -11,11 +15,12 @@ on:
tags: ["v*.*.*"]
env:
GO_VERSION: "1.24"
NODE_VERSION: "22"
jobs:
verify:
# Тесты здесь не повторяются: тот же коммит уже проверен CI в main (ci-*.yml). Ставьте тег после зелёного CI.
release:
name: release (версия и заметки)
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.PIPU_VERSION }}
@@ -23,35 +28,54 @@ jobs:
prerelease: ${{ steps.v.outputs.PIPU_PRERELEASE }}
steps:
- uses: actions/checkout@v4
- id: v
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
- uses: actions/setup-go@v5
with: { go-version: "${{ env.GO_VERSION }}", cache-dependency-path: services/api/go.sum }
- uses: astral-sh/setup-uv@v6
with: { version: "0.10.2" }
- uses: pnpm/action-setup@v4
# вся история и теги: заметки строятся по коммитам с прошлого релиза
with: { fetch-depth: 0 }
- uses: actions/setup-node@v4
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Go
working-directory: services/api
run: go vet ./... && go test ./...
- name: Python
working-directory: services/recommender
run: uv sync --frozen && uv run ruff check . && uv run mypy pipu_rec && uv run pytest -q
- name: TypeScript
run: pnpm i18n:check && pnpm test:scripts && pnpm typecheck && pnpm test
with: { node-version: "${{ env.NODE_VERSION }}" }
- id: v
name: Версия из тега
run: node scripts/version.mjs "${GITHUB_REF_NAME}" | tee -a "$GITHUB_OUTPUT"
- name: Gitea Release
env:
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ steps.v.outputs.PIPU_VERSION }}
PRERELEASE: ${{ steps.v.outputs.PIPU_PRERELEASE }}
run: node scripts/release.mjs create
android-release:
name: android + release
needs: verify
android:
name: android (APK в релиз)
needs: release
runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
# (container.valid_volumes: ['/opt/ci-cache/**'], docs/ci-cd.md); иначе раннер их пропускает и всё качается заново.
# с блоком container act запускает шаги через sh; скриптам нужен bash (массивы, pipefail)
defaults:
run:
shell: bash
container:
image: docker.gitea.com/runner-images:ubuntu-latest
volumes:
- /opt/ci-cache/gradle:/root/.gradle
- /opt/ci-cache/android:/root/.android
env:
PIPU_VERSION: ${{ needs.verify.outputs.version }}
PIPU_VERSION_CODE: ${{ needs.verify.outputs.version_code }}
PIPU_VERSION: ${{ needs.release.outputs.version }}
PIPU_VERSION_CODE: ${{ needs.release.outputs.version_code }}
# для scripts/release.mjs
GITEA_API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
GITEA_TOKEN: ${{ secrets.RELEASE_TOKEN || secrets.GITHUB_TOKEN }}
PUBLIC_URL: ${{ vars.PUBLIC_URL || 'https://gitea.hsrv.site' }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.release.outputs.version }}
VERSION_CODE: ${{ needs.release.outputs.version_code }}
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
# при перезапуске одной этой сборки статус «не удалась» сменится на «собирается»
- name: Статус в релизе — собирается
run: node scripts/release.mjs android building
- uses: actions/setup-java@v4
with: { distribution: temurin, java-version: "17" }
- uses: android-actions/setup-android@v3
@@ -64,24 +88,45 @@ jobs:
with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
- run: pnpm install --frozen-lockfile
- name: Свободное место
run: df -h / /tmp
# Диск, память и наполненность постоянных кэшей: пустые ~/.gradle и ~/.android значат, что тома не подключились
- name: Ресурсы раннера и кэши
run: |
df -h / /tmp; free -m
du -sh ~/.gradle ~/.android 2>/dev/null || true
- name: Сборка APK
working-directory: apps/mobile
env:
# CI=1 — Metro без file watcher и с 2 воркерами (metro.config.js); NODE_ENV нужен expo для .env-файлов
CI: "1"
NODE_ENV: production
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
# Kotlin компилируется внутри JVM Gradle (без второго демона, поэтому heap 2.5 ГБ вместо 2), задач — не больше 2.
# -x lintVitalRelease: lint-проверки для Google Play, а APK публикуется только в Gitea (−1 мин).
# Codegen отдельным вызовом до сборки: CMake приложения читает JNI-код, который генерирует codegen
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
run: |
npx expo prebuild --platform android --clean --no-install
cd android && ./gradlew --no-daemon assembleRelease -PreactNativeArchitectures=arm64-v8a
cd android
gradle=(./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
"${gradle[@]}" generateCodegenArtifactsFromSchema
"${gradle[@]}" assembleRelease -x lintVitalRelease
./gradlew --stop
- name: Свободное место после сбоя
- name: Диагностика сбоя (диск, память, OOM)
if: failure()
run: df -h / /tmp; du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
run: |
df -h / /tmp; free -m || true
du -sh ~/.gradle ~/.android apps/mobile/android 2>/dev/null || true
dmesg 2>/dev/null | grep -iE "killed process|out of memory" | tail -5 || true
- name: Подпись APK
env:
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
PRERELEASE: ${{ needs.release.outputs.prerelease }}
KEYSTORE_B64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
@@ -96,9 +141,11 @@ jobs:
"$bt/apksigner" sign --ks release.keystore --ks-pass env:KEYSTORE_PASSWORD \
--ks-key-alias "$KEY_ALIAS" --key-pass env:KEY_PASSWORD --out "$out" "$src"
rm release.keystore
echo "SIGNING=release" >> "$GITHUB_ENV"
elif [ "$PRERELEASE" = "true" ]; then
echo "::warning::ANDROID_KEYSTORE_BASE64 не задан — APK подписан отладочным ключом и не обновится поверх релизного"
cp "$src" "$out"
echo "SIGNING=debug" >> "$GITHUB_ENV"
else
echo "::error::ANDROID_KEYSTORE_BASE64 не задан — релиз с отладочной подписью не выпускается (rc-теги допускают)"
exit 1
@@ -107,41 +154,14 @@ jobs:
sha256sum "$out" | cut -d' ' -f1 > "$out.sha256"
echo "APK=$out" >> "$GITHUB_ENV"
- name: Changelog
env:
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
run: |
# для финального релиза — изменения с прошлого финального, а не с последнего rc
exclude=()
[ "$PRERELEASE" = "true" ] || exclude=(--exclude '*-rc*')
prev="$(git describe --tags --abbrev=0 "${exclude[@]}" "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
range="${prev:+$prev..}${GITHUB_REF_NAME}"
{
echo "## Pipu Music ${PIPU_VERSION}"
echo
for t in feat fix perf refactor docs ci chore; do
lines="$(git log --no-merges --format='%s' "$range" | grep -E "^${t}(\(|:|!)" || true)"
[ -n "$lines" ] || continue
case $t in feat) h="Новое";; fix) h="Исправления";; perf) h="Производительность";; *) h="$t";; esac
echo "### $h"; echo "$lines" | sed 's/^/- /'; echo
done
} > CHANGELOG_RELEASE.md
cat CHANGELOG_RELEASE.md
- name: APK в релиз
run: node scripts/release.mjs android success "$APK"
- name: Gitea Release
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
API: ${{ github.server_url }}/api/v1/repos/${{ github.repository }}
PRERELEASE: ${{ needs.verify.outputs.prerelease }}
run: |
set -euo pipefail
body="$(jq -n --arg tag "$GITHUB_REF_NAME" --arg name "Pipu Music $PIPU_VERSION" \
--rawfile notes CHANGELOG_RELEASE.md --argjson pre "$PRERELEASE" \
'{tag_name:$tag, name:$name, body:$notes, prerelease:$pre, draft:false}')"
id="$(curl -fsS -X POST -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
-d "$body" "$API/releases" | jq -r .id)"
for f in "$APK" "$APK.sha256"; do
curl -fsS -X POST -H "Authorization: token $TOKEN" \
-F "attachment=@$f" "$API/releases/$id/assets?name=$(basename "$f")" > /dev/null
echo "uploaded $(basename "$f")"
done
- name: Статус в релизе — сборка не удалась
if: failure()
run: node scripts/release.mjs android failure
# срабатывает при отмене из интерфейса; если раннер убит целиком, статус обновит следующий запуск
- name: Статус в релизе — сборка отменена
if: cancelled()
run: node scripts/release.mjs android cancelled
+1
View File
@@ -34,3 +34,4 @@ Thumbs.db
.idea/
.vscode/*
!.vscode/extensions.json
.cache/
+3 -3
View File
@@ -20,7 +20,7 @@ Self-hosted музыкальный сервис: веб и Android-прилож
| `packages/*` | Общий TS-код: `api-client`, `core`, `i18n`, `design-tokens` |
| `deploy/` | Docker Compose, nginx, скрипты установки и обновления |
| `tests/fixtures/music` | Генератор тестовой библиотеки (нужен ffmpeg) |
| `.gitea/workflows` | CI (`ci.yml`) и релиз по тегу (`release.yml`) |
| `.gitea/workflows` | CI по областям (`ci-*.yml`, `docker.yml`) и релиз по тегу (`release.yml`) |
## Разработка
@@ -34,7 +34,7 @@ docker compose -f deploy/compose.dev.yaml up -d
API (из `services/api`):
```bash
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" go run ./cmd/api
DATABASE_URL="postgres://pipu:pipu@localhost:5432/pipu?sslmode=disable" REDIS_URL="redis://localhost:6379/0" MEILI_URL="http://localhost:7700" MEILI_MASTER_KEY="dev-master-key-change-me" LIBRARY_HOST_ROOT="$PWD/../../tests/fixtures/music/out" X_ACCEL=false CACHE_DIR="$PWD/.cache" go run ./cmd/api
```
Веб на http://localhost:5173 (проксирует `/api` на `:8080`):
@@ -65,6 +65,6 @@ pnpm generate && (cd services/api && go generate ./...)
## Релиз
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: проверки → подписанный APK → Gitea Release.
Тег `vX.Y.Z` (или `vX.Y.Z-rc.N`) запускает `.gitea/workflows/release.yml`: Gitea Release с заметками → подписанный APK докладывается в него.
Docker-образы не публикуются: сервер клонирует репозиторий и собирает их сам (`deploy/update.sh`,
[docs/install-guide.md](docs/install-guide.md)). Нужные секреты — в шапке workflow и в [docs/ci-cd.md](docs/ci-cd.md).
+1957 -1
View File
File diff suppressed because it is too large. Load diff
+18 -1
View File
@@ -1,4 +1,21 @@
// Expo SDK 52+ сам настраивает Metro для монорепо (watchFolders, nodeModulesPaths).
const path = require('node:path');
const { getDefaultConfig } = require('expo/metro-config');
module.exports = getDefaultConfig(__dirname);
const config = getDefaultConfig(__dirname);
// watchFolders включает весь apps/mobile, а без watchman Metro обходит и хэширует каждый файл.
// android/ и ios/ после prebuild содержат гигабайты промежуточных файлов Gradle/CMake —
// в release-сборке Metro на них «висит». В JS-бандл из этих папок ничего не попадает.
const escape = (p) => p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const nativeDirs = ['android', 'ios'].map((d) => new RegExp(`^${escape(path.join(__dirname, d) + path.sep)}.*`));
// Gradle собирает нативные модули прямо в node_modules/<пакет>/android/{build,.cxx,.gradle},
// а корневой node_modules тоже в watchFolders.
const nativeBuildOutputs = /[\\/]android[\\/](build|\.cxx|\.gradle)[\\/].*/;
config.resolver.blockList = [...[].concat(config.resolver.blockList ?? []), ...nativeDirs, nativeBuildOutputs];
// В CI раннер делит 8 ГБ RAM с Gradle: по умолчанию Metro запускает воркер на каждое ядро.
if (process.env.CI) config.maxWorkers = 2;
module.exports = config;
+10 -1
View File
@@ -5,9 +5,18 @@
FROM node:22-alpine AS build
RUN npm install -g pnpm@11.9.0
WORKDIR /repo
COPY . .
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/
COPY apps/mobile/package.json apps/mobile/
COPY packages/api-client/package.json packages/api-client/
COPY packages/core/package.json packages/core/
COPY packages/design-tokens/package.json packages/design-tokens/
COPY packages/i18n/package.json packages/i18n/
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --filter @pipu/web...
COPY . .
# Сгенерированные клиенты и токены лежат в репозитории (их актуальность проверяет CI), поэтому generate здесь не нужен.
RUN pnpm --filter @pipu/web build
+4
View File
@@ -14,18 +14,22 @@
"@fontsource/jetbrains-mono": "^5.2.6",
"@fontsource/onest": "^5.2.6",
"@fontsource/unbounded": "^5.2.6",
"@material-symbols/svg-400": "^0.31.9",
"@pipu/api-client": "workspace:*",
"@pipu/core": "workspace:*",
"@pipu/design-tokens": "workspace:*",
"@pipu/i18n": "workspace:*",
"@tanstack/react-query": "^5.80.7",
"@tanstack/react-router": "^1.120.20",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.5.3"
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.5.2",
+275 -29
View File
@@ -1,51 +1,297 @@
import { createApiClient } from '@pipu/api-client';
import { AuthSession } from '@pipu/core';
import { createI18n } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { QueryClient } from '@tanstack/react-query';
import { createMemoryHistory } from '@tanstack/react-router';
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { I18nextProvider } from 'react-i18next';
import { afterEach, describe, expect, it } from 'vitest';
import { App } from './App';
import { createAppRouter, safeRedirect } from './app/router';
import { fakeApi, makeUser, type FakeUser } from './test/fakeApi';
afterEach(cleanup);
const info = {
name: 'pipu-music',
version: '0.1.0',
api_version: 'v1',
supported_locales: ['ru', 'en'],
min_android_version_code: 100,
};
async function renderApp(fetchImpl: typeof fetch) {
async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) {
const api = fakeApi(users);
const i18n = await createI18n('ru');
const client = createApiClient({ baseUrl: 'http://localhost', fetch: fetchImpl });
const session = new AuthSession({ baseUrl: 'http://localhost', fetch: api.fetch, clientType: 'web' });
if (opts.signedInAs) await session.login(opts.signedInAs.username, opts.signedInAs.password);
else await session.restore();
const router = createAppRouter(session, { history: createMemoryHistory({ initialEntries: [path] }) });
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}>
<App client={client} />
</QueryClientProvider>
<App session={session} router={router} queryClient={queryClient} />
</I18nextProvider>,
);
return { api, router, session, i18n, user: userEvent.setup() };
}
describe('App', () => {
it('shows the server version when the server is online', async () => {
await renderApp(async () => Response.json(info));
expect(await screen.findByText('Сервер 0.1.0 на связи')).toBeTruthy();
describe('sign-in', () => {
it('sends a guest to the login page and back after signing in', async () => {
const alice = makeUser('alice', 'user', { display_name: 'Алиса' });
const { user, router } = await renderApp([alice], '/settings');
await screen.findByRole('heading', { name: 'Вход' });
expect(router.state.location.pathname).toBe('/login');
await user.type(screen.getByLabelText('Логин'), 'alice');
await user.type(screen.getByLabelText('Пароль'), 'wrong');
await user.click(screen.getByRole('button', { name: 'Войти' }));
expect(await screen.findByRole('alert')).toHaveTextContent('Неверный логин или пароль');
await user.type(screen.getByLabelText('Пароль'), alice.password);
await user.click(screen.getByRole('button', { name: 'Войти' }));
await screen.findByRole('heading', { name: 'Настройки' });
expect(router.state.location.pathname).toBe('/settings');
});
it('shows an error when the server is unreachable', async () => {
await renderApp(async () => {
throw new TypeError('Failed to fetch');
it('shows the bootstrap hint on first start', async () => {
await renderApp([makeUser('admin', 'admin', { must_change_credentials: true })], '/login');
expect(await screen.findByText(/Первый запуск/)).toBeInTheDocument();
});
it('forces the bootstrap admin to change credentials', async () => {
const admin = makeUser('admin', 'admin', { must_change_credentials: true });
const { user, router, api } = await renderApp([admin], '/admin/users', { signedInAs: admin });
await screen.findByRole('heading', { name: 'Смените логин и пароль' });
expect(router.state.location.pathname).toBe('/setup/admin');
const login = screen.getByLabelText('Новый логин');
expect(login).toHaveValue('admin');
await user.clear(login);
await user.type(login, 'owner');
await user.type(screen.getByLabelText('Новый пароль'), 'short');
await user.type(screen.getByLabelText('Повторите пароль'), 'shorter');
expect(screen.getByText('Пароли не совпадают')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Сохранить и продолжить' })).toBeDisabled();
await user.clear(screen.getByLabelText('Повторите пароль'));
await user.type(screen.getByLabelText('Повторите пароль'), 'short');
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
expect(await screen.findByText('Пароль должен быть не короче 12 символов')).toBeInTheDocument();
for (const label of ['Новый пароль', 'Повторите пароль']) {
await user.clear(screen.getByLabelText(label));
await user.type(screen.getByLabelText(label), 'a-strong-owner-pass');
}
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
await screen.findByText('Привет, owner!');
expect(router.state.location.pathname).toBe('/');
expect(api.calls.find((c) => c.path === '/api/v1/me/bootstrap-credentials' && c.body)?.body).toMatchObject({
new_username: 'owner',
});
expect(await screen.findByText(/Сервер не отвечает/)).toBeTruthy();
});
it('switches the interface language', async () => {
await renderApp(async () => Response.json(info));
await screen.findByText('Сервер 0.1.0 на связи');
fireEvent.click(screen.getByRole('button', { name: 'EN' }));
expect(await screen.findByText('Server 0.1.0 is online')).toBeTruthy();
it('forces a temporary password to be replaced', async () => {
const bob = makeUser('bob', 'user', { must_change_password: true });
const { user } = await renderApp([bob], '/', { signedInAs: bob });
await screen.findByRole('heading', { name: 'Задайте новый пароль' });
await user.type(screen.getByLabelText('Новый пароль'), 'bobs-own-pass');
await user.type(screen.getByLabelText('Повторите пароль'), 'bobs-own-pass');
await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' }));
await screen.findByText('Привет, bob!');
});
it('signs out from the profile menu', async () => {
const alice = makeUser('alice', 'user');
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Меню профиля' }));
await user.click(screen.getByRole('menuitem', { name: 'Выйти' }));
await screen.findByRole('heading', { name: 'Вход' });
expect(router.state.location.pathname).toBe('/login');
});
});
describe('navigation by role', () => {
it('hides the admin section from listeners and blocks the route', async () => {
const alice = makeUser('alice', 'user');
const { router } = await renderApp([alice], '/admin/users', { signedInAs: alice });
await screen.findByText('Привет, alice!');
expect(router.state.location.pathname).toBe('/');
expect(screen.queryByRole('link', { name: 'Пользователи' })).not.toBeInTheDocument();
});
it('shows users but not the audit log to moderators', async () => {
const mod = makeUser('mod', 'moderator');
await renderApp([mod], '/', { signedInAs: mod });
expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument();
});
it('shows the audit log to admins', async () => {
const root = makeUser('root', 'admin');
await renderApp([root], '/admin/audit', { signedInAs: root });
expect(await screen.findByText('Записей пока нет')).toBeInTheDocument();
});
});
describe('users admin', () => {
it('lets an admin create a moderator and shows the temporary password once', async () => {
const root = makeUser('root', 'admin');
const anna = makeUser('anna', 'user', { last_login_at: new Date().toISOString() });
const { user } = await renderApp([root, anna], '/admin/users', { signedInAs: root });
const table = await screen.findByRole('table');
await within(table).findByText('@anna');
expect(within(table).getByText('(вы)')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' });
await user.type(within(dialog).getByLabelText('Логин'), 'ilya');
await user.selectOptions(within(dialog).getByRole('combobox'), 'moderator');
await user.click(within(dialog).getByRole('button', { name: 'Создать' }));
const secret = await screen.findByRole('dialog', { name: 'Временный пароль' });
expect(within(secret).getByTestId('temporary-password')).toHaveTextContent('Temp-Pass-123');
await user.click(within(secret).getByRole('button', { name: 'Готово' }));
expect(screen.queryByTestId('temporary-password')).not.toBeInTheDocument();
expect(await within(table).findByText('@ilya')).toBeInTheDocument();
});
it('offers moderators only what they may do', async () => {
const mod = makeUser('mod', 'moderator');
const anna = makeUser('anna', 'user');
const other = makeUser('other', 'moderator');
const { user } = await renderApp([mod, anna, other], '/admin/users', { signedInAs: mod });
await screen.findByText('@anna');
expect(screen.queryByRole('button', { name: 'Действия с other' })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Действия с mod' })).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Действия с anna' }));
const items = screen.getAllByRole('menuitem').map((i) => i.textContent);
expect(items).toEqual(['Сбросить пароль', 'Заблокировать']);
await user.keyboard('{Escape}');
await user.click(screen.getByRole('button', { name: 'Создать пользователя' }));
const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' });
expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument();
});
it('filters by role', async () => {
const root = makeUser('root', 'admin');
const { user, api } = await renderApp([root, makeUser('anna', 'user'), makeUser('mod', 'moderator')], '/admin/users', {
signedInAs: root,
});
await screen.findByText('@anna');
await user.click(screen.getByRole('button', { name: 'Модератор' }));
await waitFor(() => expect(screen.queryByText('@anna')).not.toBeInTheDocument());
expect(screen.getByText('@mod')).toBeInTheDocument();
expect(api.calls.some((c) => c.path === '/api/v1/admin/users' && c.method === 'GET')).toBe(true);
});
});
describe('language and settings', () => {
it('saves the language in the profile', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/settings', { signedInAs: alice });
await screen.findByRole('heading', { name: 'Настройки' });
const groups = screen.getAllByRole('group', { name: 'Язык' });
await user.click(within(groups[0]!).getByRole('button', { name: 'EN' }));
await screen.findByRole('heading', { name: 'Settings' });
await waitFor(() => expect(api.users.get(alice.id)?.locale).toBe('en'));
});
it('applies the theme from the profile', async () => {
const alice = makeUser('alice', 'user');
const { user } = await renderApp([alice], '/settings', { signedInAs: alice });
await user.click(await screen.findByRole('button', { name: 'Светлая' }));
await waitFor(() => expect(document.documentElement.dataset.theme).toBe('light'));
await user.click(screen.getByRole('button', { name: 'Тёмная' }));
await waitFor(() => expect(document.documentElement.dataset.theme).toBe('dark'));
});
it('switches the language on the login page without a session', async () => {
const { user } = await renderApp([], '/login');
await user.click(await screen.findByRole('button', { name: 'EN' }));
expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument();
});
});
describe('search box', () => {
it('opens the search page on Enter and an item with the arrows', async () => {
const alice = makeUser('alice', 'user');
const { user, router } = await renderApp([alice], '/', { signedInAs: alice });
const box = await screen.findByRole('combobox', { name: 'Поиск' });
await user.type(box, 'rbyj');
const list = await screen.findByRole('listbox');
const options = within(list).getAllByRole('option');
expect(options.map((o) => o.textContent)).toEqual(['КиноИсполнитель', 'Группа кровиКино · 1988']);
expect(screen.getByText('Показаны результаты для «кино»')).toBeInTheDocument();
await user.keyboard('{Enter}');
await waitFor(() => expect(router.state.location.pathname).toBe('/search'));
expect(router.state.location.search).toEqual({ q: 'rbyj' });
await user.clear(box);
await user.type(box, 'кино');
await screen.findByRole('listbox');
await user.keyboard('{ArrowDown}{Enter}');
await waitFor(() => expect(router.state.location.pathname).toBe('/artist/00000000-0000-4000-8000-0000000000aa'));
expect(await screen.findByRole('heading', { name: 'Кино' })).toBeInTheDocument();
});
it('focuses on the slash key', async () => {
const alice = makeUser('alice', 'user');
const { user } = await renderApp([alice], '/', { signedInAs: alice });
const box = await screen.findByRole('combobox', { name: 'Поиск' });
await user.keyboard('/');
expect(box).toHaveFocus();
});
});
describe('player', () => {
it('plays an album from a clicked row, likes and pauses with the keyboard', async () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
expect(row).toHaveAttribute('aria-current', 'true');
expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument();
await user.click(within(bar).getByRole('button', { name: 'Нравится' }));
await waitFor(() =>
expect(api.calls).toContainEqual({
method: 'PUT',
path: '/api/v1/me/reactions/tracks/00000000-0000-4000-8000-000000000101',
body: { reaction: 'like' },
}),
);
expect(within(bar).getByRole('button', { name: 'Убрать из лайков' })).toHaveAttribute('aria-pressed', 'true');
// Пробел на кнопке нажимает её саму; горячая клавиша работает, когда фокус не на элементе управления.
(document.activeElement as HTMLElement).blur();
await user.keyboard(' ');
expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument();
// Уход со страницы досылает события и сохраняет состояние плеера.
window.dispatchEvent(new Event('pagehide'));
await waitFor(() => {
const sent = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events);
expect(sent.find((e) => e.type === 'play_start')).toMatchObject({ context_type: 'album' });
});
expect(api.calls.find((c) => c.method === 'PUT' && c.path === '/api/v1/me/playback-state')?.body).toMatchObject({
track_id: '00000000-0000-4000-8000-000000000101',
queue_index: 1,
});
});
});
describe('safeRedirect', () => {
it.each([
['/settings', '/settings'],
['//evil.example', '/'],
['https://evil.example', '/'],
[undefined, '/'],
])('%s → %s', (input, expected) => {
expect(safeRedirect(input)).toBe(expected);
});
});
+24 -51
View File
@@ -1,57 +1,30 @@
import { createApiClient, type ApiClient } from '@pipu/api-client';
import { checkServer } from '@pipu/core';
import { SUPPORTED_LOCALES } from '@pipu/i18n';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import type { AuthSession } from '@pipu/core';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider } from '@tanstack/react-router';
import { useState } from 'react';
const defaultClient = createApiClient();
import type { createAppRouter } from './app/router';
import { SessionProvider } from './app/session';
export function App({ client = defaultClient }: { client?: ApiClient }) {
const { t, i18n } = useTranslation();
const server = useQuery({ queryKey: ['server-info'], queryFn: () => checkServer(client) });
export function createQueryClient() {
return new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
}
export function App({
session,
router,
queryClient,
}: {
session: AuthSession;
router: ReturnType<typeof createAppRouter>;
queryClient?: QueryClient;
}) {
const [client] = useState(() => queryClient ?? createQueryClient());
return (
<div className="shell">
<header className="top">
<div className="logo">
<span className="logo-dot" aria-hidden />
{t('app.name')}
</div>
<div className="langs" role="group" aria-label={t('language.label')}>
{SUPPORTED_LOCALES.map((l) => (
<button
key={l}
type="button"
aria-pressed={i18n.language === l}
onClick={() => {
void i18n.changeLanguage(l);
document.documentElement.lang = l;
}}
>
{l.toUpperCase()}
</button>
))}
</div>
</header>
<main className="main">
<section className="card">
<h1>{t('app.name')}</h1>
<p className="muted">{t('app.tagline')}</p>
{server.data?.status === 'ok' ? (
<div className="status" data-state="ok" role="status">
{t('server.online', { version: server.data.info.version })}
</div>
) : server.data ? (
<div className="status" data-state="error" role="status">
{t('server.offline')}
</div>
) : (
<div className="status" data-state="pending" role="status">
{t('server.checking')}
</div>
)}
</section>
</main>
</div>
<SessionProvider session={session}>
<QueryClientProvider client={client}>
<RouterProvider router={router} />
</QueryClientProvider>
</SessionProvider>
);
}
+240
View File
@@ -0,0 +1,240 @@
import type { AuthSession, AuthUser } from '@pipu/core';
import {
createRootRouteWithContext,
createRoute,
createRouter,
Outlet,
redirect,
type ParsedLocation,
} from '@tanstack/react-router';
import { AuditPage } from '../pages/admin/AuditPage';
import { LibraryPage } from '../pages/admin/LibraryPage';
import { PatternsPage } from '../pages/admin/PatternsPage';
import { SourcesPage } from '../pages/admin/SourcesPage';
import { UsersPage } from '../pages/admin/UsersPage';
import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { HomePage } from '../pages/HomePage';
import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage';
import { SetupPage } from '../pages/SetupPage';
import { AppShell } from '../widgets/AppShell';
import { RouteError } from '../widgets/RouteError';
export interface RouterContext {
session: AuthSession;
}
/** Куда пользователю можно попасть сейчас: обязательные шаги первого входа важнее всего. */
export function homeFor(user: AuthUser): '/setup/admin' | '/setup/password' | '/' {
if (user.must_change_credentials) return '/setup/admin';
if (user.must_change_password) return '/setup/password';
return '/';
}
function signedInUser(session: AuthSession): AuthUser | null {
return session.state.status === 'signed_in' ? session.state.user : null;
}
/** Внутренний путь из ?redirect=; внешние адреса игнорируются. */
export function safeRedirect(target: unknown): string {
return typeof target === 'string' && target.startsWith('/') && !target.startsWith('//') ? target : '/';
}
function requireUser(session: AuthSession, location: ParsedLocation): AuthUser {
const user = signedInUser(session);
if (!user) throw redirect({ to: '/login', search: { redirect: location.href } });
return user;
}
const rootRoute = createRootRouteWithContext<RouterContext>()({
component: Outlet,
errorComponent: RouteError,
});
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
validateSearch: (search: Record<string, unknown>): { redirect?: string } =>
typeof search.redirect === 'string' ? { redirect: search.redirect } : {},
beforeLoad: ({ context, search }) => {
const user = signedInUser(context.session);
if (!user) return;
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
throw redirect({ href: safeRedirect(search.redirect) });
},
component: LoginPage,
});
const setupAdminRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/admin',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
if (homeFor(user) !== '/setup/admin') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="admin" />,
});
const setupPasswordRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/setup/password',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
if (homeFor(user) !== '/setup/password') throw redirect({ to: homeFor(user) });
},
component: () => <SetupPage variant="password" />,
});
/** Каркас приложения: только для вошедших, прошедших обязательные шаги. */
const appRoute = createRoute({
getParentRoute: () => rootRoute,
id: 'app',
beforeLoad: ({ context, location }) => {
const user = requireUser(context.session, location);
const home = homeFor(user);
if (home !== '/') throw redirect({ to: home });
return { user };
},
component: AppShell,
});
const adminOnly = ({ context }: { context: { user: AuthUser } }) => {
if (context.user.role !== 'admin') throw redirect({ to: '/' });
};
const homeRoute = createRoute({ getParentRoute: () => appRoute, path: '/', component: HomePage });
const settingsRoute = createRoute({ getParentRoute: () => appRoute, path: '/settings', component: SettingsPage });
const usersRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/users',
beforeLoad: ({ context }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
},
component: UsersPage,
});
const auditRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/audit',
beforeLoad: adminOnly,
component: AuditPage,
});
const historyRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/history',
component: () => <CollectionPage tab="history" />,
});
const likesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/likes',
component: () => <CollectionPage tab="likes" />,
});
const dislikesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/collection/dislikes',
component: () => <CollectionPage tab="dislikes" />,
});
const searchRoute = createRoute({
getParentRoute: () => appRoute,
path: '/search',
validateSearch: (search: Record<string, unknown>): { q: string; type?: Exclude<SearchType, 'all'> } => {
const type = search.type;
return {
q: typeof search.q === 'string' ? search.q : '',
...(type === 'artists' || type === 'albums' || type === 'tracks' ? { type } : {}),
};
},
component: function SearchRoute() {
const { q, type } = searchRoute.useSearch();
return <SearchPage q={q} type={type ?? 'all'} />;
},
});
const artistRoute = createRoute({
getParentRoute: () => appRoute,
path: '/artist/$id',
component: function ArtistRoute() {
const { id } = artistRoute.useParams();
return <ArtistPage key={id} id={id} />;
},
});
const albumRoute = createRoute({
getParentRoute: () => appRoute,
path: '/album/$id',
component: function AlbumRoute() {
const { id } = albumRoute.useParams();
return <AlbumPage key={id} id={id} />;
},
});
const sourcesRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/sources',
beforeLoad: adminOnly,
component: SourcesPage,
});
const patternsRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/sources/$id/patterns',
beforeLoad: adminOnly,
component: function PatternsRoute() {
const { id } = patternsRoute.useParams();
return <PatternsPage id={id} />;
},
});
const libraryRoute = createRoute({
getParentRoute: () => appRoute,
path: '/admin/library',
beforeLoad: ({ context }) => {
if (context.user.role === 'user') throw redirect({ to: '/' });
},
component: LibraryPage,
});
const routeTree = rootRoute.addChildren([
loginRoute,
setupAdminRoute,
setupPasswordRoute,
appRoute.addChildren([
homeRoute,
settingsRoute,
searchRoute,
historyRoute,
likesRoute,
dislikesRoute,
artistRoute,
albumRoute,
usersRoute,
auditRoute,
sourcesRoute,
patternsRoute,
libraryRoute,
]),
]);
export function createAppRouter(session: AuthSession, opts: { history?: Parameters<typeof createRouter>[0]['history'] } = {}) {
const router = createRouter({ routeTree, context: { session }, defaultPreload: false, ...opts });
// Выход, блокировка, отзыв сессии — guards пересчитываются, и пользователь уходит на /login.
session.subscribe(() => void router.invalidate());
return router;
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
+67
View File
@@ -0,0 +1,67 @@
import type { AuthSession, AuthState, AuthUser } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { createContext, useContext, useEffect, useSyncExternalStore, type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
const SessionContext = createContext<AuthSession | null>(null);
export function SessionProvider({ session, children }: { session: AuthSession; children: ReactNode }) {
return (
<SessionContext.Provider value={session}>
<UserEffects session={session} />
{children}
</SessionContext.Provider>
);
}
export function useSession(): AuthSession {
const s = useContext(SessionContext);
if (!s) throw new Error('useSession outside SessionProvider');
return s;
}
export function useAuthState(): AuthState {
const session = useSession();
return useSyncExternalStore(
(fn) => session.subscribe(fn),
() => session.state,
);
}
/** Текущий пользователь; вызывать только на защищённых страницах. */
export function useUser(): AuthUser {
const state = useAuthState();
if (state.status !== 'signed_in') throw new Error('useUser: not signed in');
return state.user;
}
const isLocale = (l: string): l is Locale => (SUPPORTED_LOCALES as readonly string[]).includes(l);
/** Применяет настройки вошедшего пользователя: язык и тему. */
function UserEffects({ session }: { session: AuthSession }) {
const { i18n } = useTranslation();
const state = useSyncExternalStore(
(fn) => session.subscribe(fn),
() => session.state,
);
const user = state.status === 'signed_in' ? state.user : null;
const locale = user?.locale;
const theme = user?.ui_prefs.theme;
useEffect(() => {
if (locale && isLocale(locale) && locale !== i18n.language) void i18n.changeLanguage(locale);
}, [locale, i18n]);
useEffect(() => {
applyTheme(theme);
}, [theme]);
return null;
}
/** Тёмная тема по умолчанию (docs/design.md); system — по ОС через prefers-color-scheme (tokens.css). */
export function applyTheme(theme: string | undefined) {
const root = document.documentElement;
if (theme === 'system') delete root.dataset.theme;
else root.dataset.theme = theme === 'light' ? 'light' : 'dark';
}
+222
View File
@@ -0,0 +1,222 @@
.cover {
position: relative;
display: inline-grid;
place-items: center;
flex: none;
overflow: hidden;
border-radius: 8px;
color: rgba(255, 255, 255, 0.85);
}
.cover img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.round {
border-radius: 50%;
}
.link {
color: inherit;
text-decoration: none;
}
.link:hover {
text-decoration: underline;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(168px, 1fr));
gap: 20px;
}
.card,
.artistChip {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
padding: 10px;
margin: -10px;
border-radius: var(--radius-lg);
color: inherit;
text-decoration: none;
}
.card:hover,
.artistChip:hover {
background: var(--hover);
}
.artistChip {
align-items: center;
text-align: center;
width: 128px;
}
.cardCover {
aspect-ratio: 1;
border-radius: 12px;
margin-bottom: 4px;
}
.cardTitle {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.cardMeta {
font-size: 13px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.tracks {
display: flex;
flex-direction: column;
}
.disc {
margin: 14px 12px 6px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text3);
}
.track {
display: grid;
grid-template-columns: 40px minmax(0, 1fr) auto;
align-items: center;
gap: 14px;
min-height: 56px;
padding: 6px 12px;
border-radius: 10px;
}
.track:has(.trackAlbum) {
grid-template-columns: 40px minmax(0, 2fr) minmax(0, 1fr) auto;
}
.track:hover {
background: var(--hover);
}
.unavailable {
opacity: 0.45;
}
.num {
position: relative;
display: grid;
place-items: center;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.playHint {
position: absolute;
inset: 0;
display: none;
place-items: center;
border-radius: 8px;
background: rgba(5, 7, 13, 0.55);
color: #fff;
}
.track:not(.unavailable) {
cursor: pointer;
}
.track:hover .playHint,
.track:focus-visible .playHint {
display: grid;
}
.current {
background: var(--accent-soft);
}
.current .trackTitle {
color: var(--accent2);
}
.end {
display: flex;
align-items: center;
gap: 10px;
}
.like {
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
color: var(--text3);
opacity: 0;
}
.track:hover .like,
.track:focus-within .like,
.liked {
opacity: 1;
}
.liked {
color: var(--accent2);
}
.rowMeta {
color: var(--text3);
}
.trackMain {
display: flex;
flex-direction: column;
min-width: 0;
}
.trackTitle {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.trackArtists,
.trackAlbum {
font-size: 13px;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.duration {
font-size: 13px;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
@media (max-width: 767px) {
.track:has(.trackAlbum) {
grid-template-columns: 40px minmax(0, 1fr) auto;
}
.trackAlbum {
display: none;
}
}
+203
View File
@@ -0,0 +1,203 @@
import type { components } from '@pipu/api-client';
import { Link } from '@tanstack/react-router';
import { Fragment } from 'react';
import { useTranslation } from 'react-i18next';
import { formatDuration } from '../shared/lib/format';
import { usePlayer, usePlayerState } from '../player/context';
import type { PlayContext } from '../player/player';
import { albumIcon, likeFillIcon, likeIcon, personIcon, playIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './catalog.module.css';
export type Track = components['schemas']['Track'];
export type AlbumSummary = components['schemas']['AlbumSummary'];
export type ArtistRef = components['schemas']['ArtistRef'];
const WAVE = ['#2F80FF', '#7C5CFF', '#35D3FF'];
/** Детерминированная палитра по id, пока у альбома нет своей обложки. */
function fallbackColors(id: string): string[] {
let h = 0;
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
const base = h % 360;
return [`hsl(${base} 70% 55%)`, `hsl(${(base + 50) % 360} 65% 45%)`, `hsl(${(base + 110) % 360} 70% 60%)`];
}
/** Размер WebP с сервера (64 / 300 / 1200) под отображаемый размер и плотность экрана. */
export function coverSrc(url: string, cssPx: number): string {
const px = cssPx * (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1);
const size = px <= 64 ? 64 : px <= 300 ? 300 : 1200;
return `${url}&size=${size}`;
}
/** Обложка альбома; без картинки — градиент из палитры обложки или из id. */
export function Cover({
album,
size = 48,
round,
className,
}: {
album: { id: string; cover_url?: string | null; cover_colors?: string[] };
size?: number | string;
round?: boolean;
className?: string;
}) {
const colors = album.cover_colors?.length === 3 ? album.cover_colors : album.id ? fallbackColors(album.id) : WAVE;
return (
<span
className={cx(s.cover, round && s.round, className)}
style={{
width: size,
// Резиновая обложка (size="100%") — высоту задаёт aspect-ratio карточки.
height: typeof size === 'number' ? size : undefined,
background: `radial-gradient(circle at 30% 25%, ${colors[2]}, transparent 60%), linear-gradient(135deg, ${colors[0]}, ${colors[1]})`,
}}
aria-hidden
>
{album.cover_url ? (
<img src={coverSrc(album.cover_url, typeof size === 'number' ? size : 300)} alt="" loading="lazy" />
) : (
<Icon src={round ? personIcon : albumIcon} size={typeof size === 'number' ? Math.max(16, size * 0.4) : 40} />
)}
</span>
);
}
/** Исполнители через запятую, каждый — ссылка. */
export function ArtistLinks({ artists }: { artists: ArtistRef[] }) {
return (
<>
{artists.map((a, i) => (
<Fragment key={a.id}>
{i > 0 && ', '}
<Link to="/artist/$id" params={{ id: a.id }} className={s.link}>
{a.name}
</Link>
</Fragment>
))}
</>
);
}
export function AlbumCard({ album }: { album: AlbumSummary }) {
return (
<Link to="/album/$id" params={{ id: album.id }} className={s.card}>
<Cover album={album} size="100%" className={s.cardCover} />
<span className={s.cardTitle}>{album.title}</span>
<span className={s.cardMeta}>
{[album.artist.name, album.year].filter(Boolean).join(' · ')}
</span>
</Link>
);
}
export function AlbumGrid({ albums }: { albums: AlbumSummary[] }) {
return (
<div className={s.grid}>
{albums.map((a) => (
<AlbumCard key={a.id} album={a} />
))}
</div>
);
}
/**
* Список треков. showAlbum — колонка альбома (поиск); numbered — номер трека (страница альбома).
* Клик по строке запускает весь список с этого трека в контексте context; Enter — то же с клавиатуры.
*/
export function TrackList({
tracks,
showAlbum,
numbered,
context,
rowMeta,
}: {
tracks: Track[];
showAlbum?: boolean;
numbered?: boolean;
context: PlayContext;
/** Дополнительный текст под исполнителями (например, время прослушивания в истории). */
rowMeta?: (t: Track, i: number) => string | undefined;
}) {
const { t } = useTranslation();
const player = usePlayer();
const current = usePlayerState().track;
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
lastDisc = disc;
const isCurrent = current?.id === tr.id;
const play = () => tr.available && player.playList(tracks, i, context);
const liked = tr.reaction === 'like';
const meta = rowMeta?.(tr, i);
return (
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
role="row"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
title={tr.available ? undefined : t('catalog.unavailable')}
onClick={(e) => {
if (!(e.target as HTMLElement).closest('a, button')) play();
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && e.target === e.currentTarget) play();
}}
>
<span className={s.num}>
{numbered ? (tr.track_no ?? i + 1) : <Cover album={tr.album} size={40} />}
{tr.available && (
<span className={s.playHint} aria-hidden>
<Icon src={playIcon} size={20} />
</span>
)}
</span>
<span className={s.trackMain}>
<span className={s.trackTitle}>{tr.title}</span>
<span className={s.trackArtists}>
<ArtistLinks artists={tr.artists} />
{meta && <span className={s.rowMeta}> · {meta}</span>}
</span>
</span>
{showAlbum && (
<Link to="/album/$id" params={{ id: tr.album.id }} className={cx(s.link, s.trackAlbum)}>
{tr.album.title}
</Link>
)}
<span className={s.end}>
<button
type="button"
className={cx(s.like, liked && s.liked)}
aria-label={t(liked ? 'player.unlike' : 'player.like')}
aria-pressed={liked}
onClick={() => void player.react(tr, liked ? null : 'like').catch(() => undefined)}
>
<Icon src={liked ? likeFillIcon : likeIcon} size={18} />
</button>
<span className={s.duration}>{formatDuration(tr.duration_ms)}</span>
</span>
</div>
</Fragment>
);
})}
</div>
);
}
export function ArtistChip({ artist }: { artist: ArtistRef }) {
const { t } = useTranslation();
return (
<Link to="/artist/$id" params={{ id: artist.id }} className={s.artistChip}>
<Cover album={{ id: artist.id }} size={88} round />
<span className={s.cardTitle}>{artist.name}</span>
<span className={s.cardMeta}>{t('search.artist_kind')}</span>
</Link>
);
}
+36 -5
View File
@@ -1,12 +1,14 @@
import './styles.css';
import { AuthSession } from '@pipu/core';
import { createI18n, pickLocale } from '@pipu/i18n';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { I18nextProvider } from 'react-i18next';
import { App } from './App';
import { createAppRouter } from './app/router';
import { applyTheme } from './app/session';
const LOCALE_KEY = 'pipu.locale';
@@ -19,9 +21,37 @@ function storedLocale(): string[] {
}
}
/** «Chrome / Windows» — подпись сессии в списке устройств. */
function deviceName(): string {
const ua = navigator.userAgent;
const browser = /Edg\//.test(ua)
? 'Edge'
: /Firefox\//.test(ua)
? 'Firefox'
: /Chrome\//.test(ua)
? 'Chrome'
: /Safari\//.test(ua)
? 'Safari'
: 'Browser';
const os = /Windows/.test(ua)
? 'Windows'
: /Android/.test(ua)
? 'Android'
: /iPhone|iPad/.test(ua)
? 'iOS'
: /Mac OS X/.test(ua)
? 'macOS'
: /Linux/.test(ua)
? 'Linux'
: '';
return os ? `${browser} / ${os}` : browser;
}
applyTheme(undefined);
const i18n = await createI18n(pickLocale([...storedLocale(), ...navigator.languages]));
document.documentElement.lang = i18n.language;
i18n.on('languageChanged', (l) => {
document.documentElement.lang = l;
try {
localStorage.setItem(LOCALE_KEY, l);
} catch {
@@ -29,14 +59,15 @@ i18n.on('languageChanged', (l) => {
}
});
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } } });
const session = new AuthSession({ clientType: 'web', deviceName: deviceName() });
// До первого рендера узнаём, есть ли сессия (refresh-cookie), — guards роутера решают сразу.
await session.restore();
const router = createAppRouter(session);
createRoot(document.getElementById('root')!).render(
<StrictMode>
<I18nextProvider i18n={i18n}>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
<App session={session} router={router} />
</I18nextProvider>
</StrictMode>,
);
+53
View File
@@ -0,0 +1,53 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover, TrackList } from '../entities/catalog';
import { formatDuration } from '../shared/lib/format';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
export function AlbumPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const album = useQuery({
queryKey: ['album', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/albums/{id}', { params: { path: { id } } })),
});
if (album.isError) return <NotFound error={album.error} />;
const a = album.data;
const colors = a?.cover_colors.length === 3 ? a.cover_colors : null;
return (
<div className={s.page}>
<header className={c.hero}>
{colors && (
<span
className={c.heroBg}
style={{ background: `linear-gradient(135deg, ${colors[0]}, ${colors[1]})` }}
aria-hidden
/>
)}
<Cover album={a ?? { id }} size={200} />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.album')}</span>
<h1 className={c.heroTitle}>{a?.title ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
<Link to="/artist/$id" params={{ id: a.artist.id }}>
{a.artist.name}
</Link>
{a.year ? ` · ${a.year}` : ''} · {t('catalog.track_count', { count: a.tracks.length })} ·{' '}
{formatDuration(a.duration_ms)}
</span>
)}
</div>
</header>
{a && <TrackList tracks={a.tracks} numbered context={{ type: 'album', id: a.id, label: a.title }} />}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, Cover } from '../entities/catalog';
import { NotFound } from './NotFound';
import s from './page.module.css';
import c from './catalog.module.css';
export function ArtistPage({ id }: { id: string }) {
const { t } = useTranslation();
const session = useSession();
const artist = useQuery({
queryKey: ['artist', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/artists/{id}', { params: { path: { id } } })),
});
const albums = useQuery({
queryKey: ['artist-albums', id],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/artists/{id}/albums', { params: { path: { id } } })).items,
});
if (artist.isError) return <NotFound error={artist.error} />;
const a = artist.data;
const own = albums.data?.filter((al) => al.artist.id === id) ?? [];
const appears = albums.data?.filter((al) => al.artist.id !== id) ?? [];
return (
<div className={s.page}>
<header className={c.hero}>
<Cover album={{ id }} size={168} round />
<div className={c.heroBody}>
<span className={c.kind}>{t('catalog.artist')}</span>
<h1 className={c.heroTitle}>{a?.name ?? '…'}</h1>
{a && (
<span className={c.heroMeta}>
{t('catalog.album_count', { count: a.album_count })} · {t('catalog.track_count', { count: a.track_count })}
</span>
)}
</div>
</header>
{own.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.albums')}</h2>
<AlbumGrid albums={own} />
</section>
)}
{appears.length > 0 && (
<section className={c.section}>
<h2>{t('catalog.appears_on')}</h2>
<AlbumGrid albums={appears} />
</section>
)}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { Logo } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './auth.module.css';
/** Экран без каркаса: декоративный фон волны и стеклянная карточка по центру. */
export function AuthLayout({ children, footer }: { children: ReactNode; footer?: ReactNode }) {
return (
<div className={s.screen}>
<div className={s.backdrop} aria-hidden>
<span className={s.blob1} />
<span className={s.blob2} />
<span className={s.blob3} />
</div>
<header className={s.top}>
<Logo />
<LanguageSwitch />
</header>
<main className={s.center}>
<section className={s.card}>{children}</section>
{footer && <div className={s.footer}>{footer}</div>}
</main>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { TrackList, type Track } from '../entities/catalog';
import { usePlayer } from '../player/context';
import { useErrorText } from '../shared/lib/errors';
import { playIcon } from '../shared/ui/icons';
import { Button, Dialog, FormError } from '../shared/ui/ui';
import c from './catalog.module.css';
import s from './page.module.css';
export type CollectionTab = 'history' | 'likes' | 'dislikes';
const TABS: CollectionTab[] = ['history', 'likes', 'dislikes'];
export function CollectionPage({ tab }: { tab: CollectionTab }) {
const { t } = useTranslation();
return (
<div className={s.page}>
<div className={s.head}>
<h1>{t('collection.title')}</h1>
</div>
<div className={s.chips} role="tablist" aria-label={t('collection.title')}>
{TABS.map((k) => (
<Link key={k} to={`/collection/${k}`} role="tab" aria-selected={tab === k} className={c.tab}>
{t(`collection.${k}`)}
</Link>
))}
</div>
{tab === 'history' ? <History /> : <Reacted reaction={tab === 'likes' ? 'like' : 'dislike'} />}
</div>
);
}
/** Ключ дня в локальном времени: история группируется на клиенте. */
function dayKey(iso: string): string {
const d = new Date(iso);
return `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
}
function dayTitle(iso: string, locale: string, t: (k: 'collection.today' | 'collection.yesterday') => string): string {
const d = new Date(iso);
const today = new Date();
const yesterday = new Date(today.getFullYear(), today.getMonth(), today.getDate() - 1);
if (dayKey(iso) === dayKey(today.toISOString())) return t('collection.today');
if (dayKey(iso) === dayKey(yesterday.toISOString())) return t('collection.yesterday');
return new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'long', year: 'numeric' }).format(d);
}
function History() {
const { t, i18n } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [confirm, setConfirm] = useState(false);
const history = useInfiniteQuery({
queryKey: ['history'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET('/api/v1/me/history', { params: { query: { cursor: pageParam, limit: 100 } } })),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const clear = useMutation({
mutationFn: async () => unwrap(await session.client.DELETE('/api/v1/me/history')),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['history'] }),
});
const items = history.data?.pages.flatMap((p) => p.items) ?? [];
const groups: { key: string; title: string; items: typeof items }[] = [];
for (const it of items) {
const key = dayKey(it.played_at);
const last = groups.at(-1);
if (last?.key === key) last.items.push(it);
else groups.push({ key, title: dayTitle(it.played_at, i18n.language, t), items: [it] });
}
const time = new Intl.DateTimeFormat(i18n.language, { hour: '2-digit', minute: '2-digit' });
return (
<>
{items.length > 0 && (
<div className={s.toolbar}>
<span style={{ flex: 1 }} />
<Button variant="ghost" onClick={() => setConfirm(true)}>
{t('collection.clear_history')}
</Button>
</div>
)}
<FormError>{history.error || clear.error ? errorText(history.error ?? clear.error) : null}</FormError>
{history.isSuccess && items.length === 0 && <div className={s.empty}>{t('collection.empty_history')}</div>}
{groups.map((g) => (
<section key={g.key} className={c.section}>
<h2>{g.title}</h2>
<TrackList
tracks={g.items.map((i) => i.track)}
context={{ type: 'history' }}
rowMeta={(_, i) => time.format(new Date(g.items[i]!.played_at))}
/>
</section>
))}
{history.hasNextPage && (
<Button className={s.more} busy={history.isFetchingNextPage} onClick={() => void history.fetchNextPage()}>
{t('collection.more')}
</Button>
)}
{confirm && (
<Dialog
title={t('collection.clear_history')}
onClose={() => setConfirm(false)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirm(false)}>
{t('form.cancel')}
</Button>
<Button
variant="danger"
onClick={() => {
setConfirm(false);
clear.mutate();
}}
>
{t('collection.clear_history')}
</Button>
</>
}
>
<p className={s.note}>{t('collection.clear_confirm')}</p>
</Dialog>
)}
</>
);
}
function Reacted({ reaction }: { reaction: 'like' | 'dislike' }) {
const { t } = useTranslation();
const session = useSession();
const player = usePlayer();
const errorText = useErrorText();
const path = reaction === 'like' ? '/api/v1/me/likes' : '/api/v1/me/dislikes';
const list = useInfiniteQuery({
queryKey: [reaction === 'like' ? 'likes' : 'dislikes'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET(path, { params: { query: { cursor: pageParam, limit: 100 } } })),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const tracks: Track[] = list.data?.pages.flatMap((p) => p.items.map((i) => i.track)) ?? [];
const total = list.data?.pages[0]?.total ?? 0;
const context = { type: 'collection' as const, id: reaction === 'like' ? 'likes' : 'dislikes' };
return (
<>
{reaction === 'like' && tracks.length > 0 && (
<div className={s.toolbar}>
<Button variant="primary" icon={playIcon} onClick={() => player.playList(tracks, 0, context)}>
{t('collection.play_all')}
</Button>
<span className={s.note}>{t('catalog.track_count', { count: total })}</span>
</div>
)}
<FormError>{list.error ? errorText(list.error) : null}</FormError>
{list.isSuccess && tracks.length === 0 && (
<div className={s.empty}>{t(reaction === 'like' ? 'collection.empty_likes' : 'collection.empty_dislikes')}</div>
)}
{tracks.length > 0 && <TrackList tracks={tracks} showAlbum context={context} />}
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('collection.more')}
</Button>
)}
</>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useTranslation } from 'react-i18next';
import { useUser } from '../app/session';
import { playIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import s from './home.module.css';
/** Главная. Кнопка волны — заглушка до M4 (рекомендации). */
export function HomePage() {
const { t } = useTranslation();
const user = useUser();
return (
<div className={s.home}>
<h1 className={s.greeting}>{t('home.greeting', { name: user.display_name ?? user.username })}</h1>
<div className={s.orb} aria-disabled="true">
<Icon src={playIcon} size={44} />
<span className={s.orbLabel}>{t('home.wave')}</span>
</div>
<p className={s.soon}>{t('home.wave_soon')}</p>
</div>
);
}
+75
View File
@@ -0,0 +1,75 @@
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { useErrorText } from '../shared/lib/errors';
import { Button, Field, FormError } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
export function LoginPage() {
const { t } = useTranslation(['auth', 'common']);
const session = useSession();
const errorText = useErrorText();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
const info = useQuery({
queryKey: ['server-info'],
queryFn: async () => (await session.client.GET('/api/v1/server-info')).data ?? null,
});
const submit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
// Переход выполнит роутер: смена состояния сессии пересчитывает guards.
await session.login(username.trim(), password);
} catch (err) {
setError(err);
setPassword('');
setBusy(false);
}
};
return (
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
<h1>{t('login.title')}</h1>
<p className={s.lead}>{t('login.subtitle')}</p>
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
<form onSubmit={(e) => void submit(e)} noValidate>
<Field
label={t('login.username')}
value={username}
onChange={setUsername}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
required
/>
<Field
label={t('login.password')}
type="password"
value={password}
onChange={setPassword}
autoComplete="current-password"
required
/>
<FormError>{error ? errorText(error) : null}</FormError>
<Button
type="submit"
variant="primary"
className={s.submit}
busy={busy}
disabled={!username.trim() || !password}
>
{t('login.submit')}
</Button>
</form>
</AuthLayout>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { ApiError } from '@pipu/core';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useErrorText } from '../shared/lib/errors';
import s from './page.module.css';
/** Сущность каталога не найдена (удалена сканом) или не загрузилась. */
export function NotFound({ error }: { error: unknown }) {
const { t } = useTranslation();
const errorText = useErrorText();
const missing = error instanceof ApiError && error.status === 404;
return (
<div className={s.empty} role="alert">
<p>{missing ? t('catalog.not_found') : errorText(error)}</p>
<Link to="/">{t('catalog.back_home')}</Link>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { AlbumGrid, ArtistChip, TrackList } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { Button, FormError } from '../shared/ui/ui';
import s from './page.module.css';
import c from './catalog.module.css';
export type SearchType = 'all' | 'artists' | 'albums' | 'tracks';
const TYPES: SearchType[] = ['all', 'artists', 'albums', 'tracks'];
export function SearchPage({ q, type }: { q: string; type: SearchType }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const query = q.trim();
const results = useInfiniteQuery({
queryKey: ['search', query, type],
enabled: query !== '',
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam, signal }) =>
unwrap(
await session.client.GET('/api/v1/search', {
params: { query: { q: query, type, cursor: pageParam, limit: type === 'all' ? undefined : 40 } },
signal,
}),
),
getNextPageParam: (p) => p.next_cursor ?? undefined,
});
const pages = results.data?.pages ?? [];
const first = pages[0];
const artists = pages.flatMap((p) => p.artists);
const albums = pages.flatMap((p) => p.albums);
const tracks = pages.flatMap((p) => p.tracks);
const empty = results.isSuccess && artists.length + albums.length + tracks.length === 0;
const count = (k: Exclude<SearchType, 'all'>) => first?.totals[k];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('search.title', { query })}</h1>
{first?.corrected_query && (
<p className={s.subtitle}>{t('search.corrected', { query: first.corrected_query })}</p>
)}
</div>
</div>
<div className={s.chips} role="tablist" aria-label={t('search.label')}>
{TYPES.map((k) => (
<Link
key={k}
to="/search"
search={{ q, type: k === 'all' ? undefined : k }}
role="tab"
aria-selected={type === k}
className={c.tab}
>
{t(`search.${k}`)}
{k !== 'all' && count(k) !== undefined && type === 'all' && <span className={c.tabCount}>{count(k)}</span>}
</Link>
))}
</div>
<FormError>{results.error ? errorText(results.error) : null}</FormError>
{empty && <div className={s.empty}>{t('search.nothing')}</div>}
{artists.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.artists')}</h2>}
<div className={c.artistRow}>
{artists.map((a) => (
<ArtistChip key={a.id} artist={a} />
))}
</div>
</section>
)}
{albums.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.albums')}</h2>}
<AlbumGrid albums={albums} />
</section>
)}
{tracks.length > 0 && (
<section className={c.section}>
{type === 'all' && <h2>{t('search.tracks')}</h2>}
<TrackList tracks={tracks} showAlbum context={{ type: 'search', id: query, label: query }} />
</section>
)}
{results.hasNextPage && (
<Button className={s.more} busy={results.isFetchingNextPage} onClick={() => void results.fetchNextPage()}>
{t('search.more')}
</Button>
)}
</div>
);
}
+230
View File
@@ -0,0 +1,230 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../app/session';
import { useErrorText, useFieldError } from '../shared/lib/errors';
import { formatDateTime } from '../shared/lib/format';
import { Button, Field, FormError, Segmented } from '../shared/ui/ui';
import { LanguageSwitch } from '../widgets/LanguageSwitch';
import s from './page.module.css';
type Theme = NonNullable<components['schemas']['UiPrefs']['theme']>;
export function SettingsPage() {
const { t } = useTranslation();
return (
<div className={`${s.page} ${s.narrow}`}>
<div className={s.head}>
<h1>{t('settings.title')}</h1>
</div>
<ProfileSection />
<AppearanceSection />
<PasswordSection />
<SessionsSection />
</div>
);
}
function ProfileSection() {
const { t } = useTranslation();
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const [name, setName] = useState(user.display_name ?? '');
const save = useMutation({
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/me', { body: { display_name: name.trim() } })),
onSuccess: (u) => session.setUser(u),
});
return (
<section className={s.section}>
<h2>{t('settings.account')}</h2>
<form
onSubmit={(e) => {
e.preventDefault();
save.mutate();
}}
>
<div className={s.row}>
<Field
label={t('settings.display_name')}
value={name}
onChange={(v) => {
setName(v);
save.reset();
}}
maxLength={64}
hint={`@${user.username}`}
/>
<Button type="submit" busy={save.isPending} disabled={name.trim() === (user.display_name ?? '')}>
{t('settings.save')}
</Button>
</div>
{save.isSuccess && <span className={s.ok}>{t('settings.saved')}</span>}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
</form>
</section>
);
}
function AppearanceSection() {
const { t } = useTranslation();
const session = useSession();
const user = useUser();
const theme: Theme = user.ui_prefs.theme ?? 'dark';
const setTheme = async (value: Theme) => {
// Оптимистично: тема применяется сразу, сервер подтверждает.
session.setUser({ ...user, ui_prefs: { ...user.ui_prefs, theme: value } });
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { ui_prefs: { theme: value } } })));
} catch {
session.setUser(user);
}
};
return (
<section className={s.section}>
<h2>{t('settings.appearance')}</h2>
<div className={s.settingRow}>
<span>{t('settings.theme')}</span>
<Segmented<Theme>
label={t('settings.theme')}
value={theme}
onChange={(v) => void setTheme(v)}
options={[
{ value: 'dark', label: t('settings.theme_dark') },
{ value: 'light', label: t('settings.theme_light') },
{ value: 'system', label: t('settings.theme_system') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('language.label')}</span>
<LanguageSwitch />
</div>
</section>
);
}
function PasswordSection() {
const { t } = useTranslation(['common', 'auth']);
const session = useSession();
const user = useUser();
const queryClient = useQueryClient();
const errorText = useErrorText();
const fieldError = useFieldError();
const [current, setCurrent] = useState('');
const [next, setNext] = useState('');
const [repeat, setRepeat] = useState('');
const mismatch = repeat !== '' && repeat !== next;
const change = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/me/password', { body: { current_password: current, new_password: next } }),
),
onSuccess: async (tokens) => {
await session.apply(tokens);
setCurrent('');
setNext('');
setRepeat('');
await queryClient.invalidateQueries({ queryKey: ['sessions'] });
},
});
const err = change.error;
const currentError = fieldError(err, 'current_password');
const nextError = fieldError(err, 'new_password');
const submit = (e: FormEvent) => {
e.preventDefault();
if (!mismatch) change.mutate();
};
return (
<section className={s.section}>
<h2>{t('settings.password')}</h2>
<form onSubmit={submit} noValidate>
<Field
label={t('settings.current_password')}
type="password"
value={current}
onChange={setCurrent}
autoComplete="current-password"
error={currentError}
/>
<Field
label={t('settings.new_password')}
type="password"
value={next}
onChange={setNext}
autoComplete="new-password"
hint={t('auth:setup.min_length', { min: user.role === 'user' ? 8 : 12 })}
error={nextError}
/>
<Field
label={t('settings.repeat_password')}
type="password"
value={repeat}
onChange={setRepeat}
autoComplete="new-password"
error={mismatch ? t('form.passwords_mismatch') : undefined}
/>
{change.isSuccess && <span className={s.ok}>{t('settings.password_changed')}</span>}
<FormError>{err && !currentError && !nextError ? errorText(err) : null}</FormError>
<div className={s.actions}>
<Button type="submit" variant="primary" busy={change.isPending} disabled={!current || !next || next !== repeat}>
{t('settings.change_password')}
</Button>
</div>
</form>
</section>
);
}
function SessionsSection() {
const { t, i18n } = useTranslation();
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const sessions = useQuery({
queryKey: ['sessions'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/me/sessions')).items,
});
const revoke = useMutation({
mutationFn: async (id: string) =>
unwrap(await session.client.DELETE('/api/v1/me/sessions/{id}', { params: { path: { id } } })),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['sessions'] }),
});
return (
<section className={s.section}>
<h2>{t('settings.sessions')}</h2>
<div className={s.sessions}>
{sessions.data?.map((ss) => (
<div key={ss.id} className={s.session}>
<div>
<div>{ss.device_name ?? ss.client_type}</div>
<div className={s.sessionMeta}>
{ss.current
? t('settings.session_current')
: t('settings.session_last_used', { date: formatDateTime(ss.last_used_at, i18n.language) })}
</div>
</div>
{!ss.current && (
<Button variant="ghost" busy={revoke.isPending && revoke.variables === ss.id} onClick={() => revoke.mutate(ss.id)}>
{t('settings.session_end')}
</Button>
)}
</div>
))}
</div>
<FormError>{sessions.error || revoke.error ? errorText(sessions.error ?? revoke.error) : null}</FormError>
<div>
<Button variant="danger" onClick={() => void session.logout(true)}>
{t('settings.logout_all')}
</Button>
</div>
</section>
);
}
+108
View File
@@ -0,0 +1,108 @@
import { unwrap } from '@pipu/core';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import { useErrorText, useFieldError } from '../shared/lib/errors';
import { lockResetIcon, shieldIcon } from '../shared/ui/icons';
import { Button, Field, FormError, Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/** Обязательный шаг после входа: новые логин и пароль админа (admin) или замена временного пароля (password). */
export function SetupPage({ variant }: { variant: 'admin' | 'password' }) {
return useAuthState().status === 'signed_in' ? <SetupForm variant={variant} /> : null;
}
function SetupForm({ variant }: { variant: 'admin' | 'password' }) {
const { t } = useTranslation(['auth', 'common']);
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const fieldError = useFieldError();
const admin = variant === 'admin';
const minLength = user.role === 'user' ? 8 : 12;
const [username, setUsername] = useState(user.username);
const [password, setPassword] = useState('');
const [repeat, setRepeat] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<unknown>(null);
const mismatch = repeat !== '' && repeat !== password;
const submit = async (e: FormEvent) => {
e.preventDefault();
if (mismatch) return;
setBusy(true);
setError(null);
try {
const res = admin
? await session.client.POST('/api/v1/me/bootstrap-credentials', {
body: { new_username: username.trim(), new_password: password },
})
: await session.client.POST('/api/v1/me/password', { body: { new_password: password } });
await session.apply(unwrap(res));
} catch (err) {
setError(err);
setBusy(false);
}
};
const usernameError = fieldError(error, 'new_username');
const passwordError = fieldError(error, 'new_password');
const generalError = error && !usernameError && !passwordError ? errorText(error) : null;
return (
<AuthLayout>
<span className={s.badge}>
<Icon src={admin ? shieldIcon : lockResetIcon} size={16} />
{t(admin ? 'setup.admin_badge' : 'setup.password_badge')}
</span>
<h1>{t(admin ? 'setup.admin_title' : 'setup.password_title')}</h1>
<p className={s.lead}>{t(admin ? 'setup.admin_text' : 'setup.password_text')}</p>
<form onSubmit={(e) => void submit(e)} noValidate>
{admin && (
<Field
label={t('setup.new_username')}
value={username}
onChange={setUsername}
autoComplete="username"
autoCapitalize="none"
spellCheck={false}
error={usernameError}
/>
)}
<Field
label={t('setup.new_password')}
type="password"
value={password}
onChange={setPassword}
autoComplete="new-password"
hint={t('setup.min_length', { min: minLength })}
error={passwordError}
/>
<Field
label={t('setup.repeat_password')}
type="password"
value={repeat}
onChange={setRepeat}
autoComplete="new-password"
error={mismatch ? t('common:form.passwords_mismatch') : undefined}
/>
<FormError>{generalError}</FormError>
<Button
type="submit"
variant="primary"
className={s.submit}
busy={busy}
disabled={!password || password !== repeat || (admin && !username.trim())}
>
{t('setup.submit')}
</Button>
<Button variant="ghost" className={s.link} onClick={() => void session.logout()}>
{t('setup.logout')}
</Button>
</form>
</AuthLayout>
);
}
+79
View File
@@ -0,0 +1,79 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { formatDateTime } from '../../shared/lib/format';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
type Entry = components['schemas']['AuditEntry'];
/** Краткое описание полезной нагрузки записи: «user → moderator», «anna (user)». */
function details(e: Entry, roleName: (r: string) => string): string {
const p = e.payload ?? {};
if (e.action === 'user.role_change') return `${roleName(String(p.from))} → ${roleName(String(p.to))}`;
const parts = [p.username, p.role && roleName(String(p.role)), p.display_name].filter(
(v): v is string => typeof v === 'string' && v !== '',
);
return parts.join(' · ');
}
export function AuditPage() {
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const list = useInfiniteQuery({
queryKey: ['admin-audit'],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(await session.client.GET('/api/v1/admin/audit', { params: { query: { cursor: pageParam, limit: 50 } } })),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const entries = list.data?.pages.flatMap((p) => p.items) ?? [];
const tr = t as unknown as (key: string, opts?: Record<string, unknown>) => string;
const roleName = (r: string) => tr(`common:roles.${r}`, { defaultValue: r });
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('audit.title')}</h1>
<p className={s.subtitle}>{t('audit.subtitle')}</p>
</div>
</div>
<FormError>{list.error ? errorText(list.error) : null}</FormError>
<div className={s.tableWrap}>
<table className={s.table}>
<thead>
<tr>
<th>{t('audit.col_when')}</th>
<th>{t('audit.col_who')}</th>
<th>{t('audit.col_action')}</th>
<th>{t('audit.col_details')}</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.id}>
<td className={s.muted}>{formatDateTime(e.created_at, i18n.language)}</td>
<td>{e.actor ? `@${e.actor.username}` : <span className={s.muted}>{t('audit.system')}</span>}</td>
<td>{tr(`admin:audit.actions.${e.action}`, { defaultValue: e.action })}</td>
<td className={s.muted}>{details(e, roleName)}</td>
</tr>
))}
</tbody>
</table>
{list.isSuccess && entries.length === 0 && <div className={s.empty}>{t('audit.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('audit.more')}
</Button>
)}
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { refreshIcon } from '../../shared/ui/icons';
import { Button, FormError } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
/** Состояние библиотеки: счётчики, хранилища и сканы (admin, moderator), ошибки чтения (admin). */
export function LibraryPage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const user = useUser();
const errorText = useErrorText();
const admin = user.role === 'admin';
const status = useQuery({
queryKey: ['library-status'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/status')),
refetchInterval: (q) => (q.state.data?.sources.some((x) => x.scan?.running) ? 2000 : 15000),
});
const errors = useQuery({
queryKey: ['library-errors'],
enabled: admin,
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/library/errors', { params: { query: { limit: 50 } } })),
});
const reindex = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
});
const d = status.data;
const tiles = d
? ([
['artists', d.artists],
['albums', d.albums],
['tracks', d.tracks],
['files', d.files],
['missing', d.missing_files],
['errors', d.file_errors],
] as const)
: [];
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('library.title')}</h1>
<p className={s.subtitle}>{t('library.subtitle')}</p>
</div>
{admin && (
<Button icon={refreshIcon} busy={reindex.isPending} onClick={() => reindex.mutate()}>
{reindex.isSuccess ? t('library.reindex_started') : t('library.reindex')}
</Button>
)}
</div>
<FormError>{status.error || reindex.error ? errorText(status.error ?? reindex.error) : null}</FormError>
<div className={l.tiles}>
{tiles.map(([k, v]) => (
<div key={k} className={l.tile}>
<span className={l.tileValue}>{v.toLocaleString()}</span>
<span className={l.tileLabel}>{t(`library.${k}`)}</span>
</div>
))}
</div>
{d && d.sources.length > 0 && (
<section className={s.section}>
<h2>{t('library.sources')}</h2>
{d.sources.map((src) => (
<div key={src.id} className={l.cardFoot}>
<div className={l.cardTitle}>
{src.name} <StatusBadge status={src.status} />
</div>
<ScanLine scan={src.scan} lastScanAt={src.last_scan_at} />
</div>
))}
</section>
)}
{admin && errors.data && errors.data.items.length > 0 && (
<section className={s.section}>
<h2>{t('library.error_files')}</h2>
{errors.data.items.map((e) => (
<div key={e.file_id} className={l.cardFoot}>
<span className={l.location}>{e.rel_path}</span>
<span className={l.scanError}>{e.error}</span>
</div>
))}
</section>
)}
</div>
);
}
+215
View File
@@ -0,0 +1,215 @@
import type { components } from '@pipu/api-client';
import { ApiError, unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText } from '../../shared/lib/errors';
import { addIcon, arrowDownIcon, arrowUpIcon, deleteIcon } from '../../shared/ui/icons';
import { Button, cx, Field, FormError, Icon } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
type Pattern = components['schemas']['PathPattern'];
type Preview = components['schemas']['PatternPreview'];
/** Примеры из docs/library-and-storage.md — частые раскладки папок. */
const EXAMPLES = [
String.raw`^(?P<artist>[^/]+)/(?P<year>\d{4}) - (?P<album>[^/]+)/(?:(?P<disc>\d)-)?(?P<track>\d{1,3}) - (?P<title>[^/]+)\.\w+$`,
String.raw`^(?P<genre>[^/]+)/(?P<artist>[^/]+)/(?P<album>.+?) \((?P<year>\d{4})\)/(?P<track>\d+)\. (?P<title>[^/]+)\.\w+$`,
String.raw`^Compilations/(?P<album>[^/]+)/(?P<track>\d+) - (?P<artist>.+?) - (?P<title>[^/]+)\.\w+$`,
];
/** Ошибка компиляции из 422: номер шаблона и причина. */
function patternError(err: unknown): { index: number; reason: string } | null {
if (err instanceof ApiError && err.code === 'validation.pattern_invalid') {
return { index: Number(err.params.index), reason: String(err.params.reason) };
}
return null;
}
export function PatternsPage({ id }: { id: string }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const source = useQuery({
queryKey: ['source', id],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
});
const saved = useQuery({
queryKey: ['patterns', id],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/admin/sources/{id}/patterns', { params: { path: { id } } })).patterns,
});
const [rows, setRows] = useState<Pattern[]>([]);
useEffect(() => {
if (saved.data) setRows(saved.data);
}, [saved.data]);
const preview = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/admin/sources/{id}/patterns/preview', {
params: { path: { id } },
body: { patterns: rows, sample: 50 },
}),
),
});
const save = useMutation({
mutationFn: async () =>
unwrap(
await session.client.PUT('/api/v1/admin/sources/{id}/patterns', {
params: { path: { id } },
body: { patterns: rows },
}),
),
onSuccess: (r) => setRows(r.patterns),
});
const err = save.error ?? preview.error;
const perr = patternError(err);
const update = (i: number, p: Partial<Pattern>) => {
save.reset();
setRows((rs) => rs.map((r, k) => (k === i ? { ...r, ...p } : r)));
};
const move = (i: number, d: -1 | 1) =>
setRows((rs) => {
const next = [...rs];
[next[i], next[i + d]] = [next[i + d]!, next[i]!];
return next;
});
return (
<div className={`${s.page} ${s.narrow}`} style={{ maxWidth: 1100 }}>
<div className={s.head}>
<div>
<h1>{t('patterns.title', { name: source.data?.name ?? '…' })}</h1>
<p className={s.subtitle}>
<Link to="/admin/sources">{t('sources.title')}</Link>
</p>
</div>
</div>
<section className={s.section}>
<p className={s.note}>{t('patterns.intro')}</p>
<p className={l.groups}>{t('patterns.groups')}</p>
{rows.map((r, i) => (
<div key={i} className={l.patternRow}>
<span className={l.patternNum}>{i + 1}</span>
<Field
label={t('patterns.regex', { n: i + 1 })}
value={r.regex}
onChange={(v) => update(i, { regex: v })}
maxLength={1000}
spellCheck={false}
autoCapitalize="none"
mono
error={perr?.index === i ? t('patterns.error_at', { n: i + 1, reason: perr.reason }) : undefined}
/>
<div className={l.patternTools} style={{ marginTop: 22 }}>
<label className={l.check}>
<input type="checkbox" checked={r.is_enabled} onChange={(e) => update(i, { is_enabled: e.target.checked })} />
{t('patterns.enabled')}
</label>
<Button variant="ghost" aria-label={t('patterns.up')} disabled={i === 0} onClick={() => move(i, -1)}>
<Icon src={arrowUpIcon} size={18} />
</Button>
<Button
variant="ghost"
aria-label={t('patterns.down')}
disabled={i === rows.length - 1}
onClick={() => move(i, 1)}
>
<Icon src={arrowDownIcon} size={18} />
</Button>
<Button
variant="ghost"
aria-label={t('patterns.remove')}
onClick={() => setRows((rs) => rs.filter((_, k) => k !== i))}
>
<Icon src={deleteIcon} size={18} />
</Button>
</div>
</div>
))}
<div className={s.toolbar}>
<Button icon={addIcon} onClick={() => setRows((rs) => [...rs, { regex: '', is_enabled: true }])}>
{t('patterns.add')}
</Button>
<Button
variant="ghost"
onClick={() => setRows((rs) => [...rs, ...EXAMPLES.map((regex) => ({ regex, is_enabled: true }))])}
>
{t('patterns.examples')}
</Button>
<span style={{ flex: 1 }} />
<Button busy={preview.isPending} onClick={() => preview.mutate()}>
{t('patterns.preview')}
</Button>
<Button variant="primary" busy={save.isPending} onClick={() => save.mutate()}>
{t('patterns.save')}
</Button>
</div>
{save.isSuccess && <span className={s.ok}>{t('patterns.saved')}</span>}
<FormError>{err && !perr ? errorText(err) : null}</FormError>
</section>
{preview.data && <PreviewTable preview={preview.data} />}
</div>
);
}
function PreviewTable({ preview }: { preview: Preview }) {
const { t } = useTranslation('admin');
const fields = ['artist', 'album', 'title'] as const;
return (
<>
<p className={s.note}>{t('patterns.unmatched', { count: preview.unmatched, total: preview.items.length })}</p>
<div className={s.tableWrap} style={{ overflowX: 'auto' }}>
<table className={s.table}>
<thead>
<tr>
<th>{t('patterns.col_path')}</th>
<th>{t('patterns.col_pattern')}</th>
<th>{t('patterns.col_result')}</th>
</tr>
</thead>
<tbody>
{preview.items.map((it) => (
<tr key={it.rel_path}>
<td className={l.match}>{it.rel_path}</td>
<td className={cx(l.match, it.pattern_index === null && l.noMatch)}>
{it.skipped
? t('patterns.skipped')
: it.pattern_index === null
? t('patterns.no_match')
: `#${it.pattern_index + 1}`}
</td>
<td>
<div className={l.result}>
{fields.map((f) => {
const fromPath = f in it.path_fields;
const fromTags = (f === 'artist' ? 'artist' : f) in it.tags;
return (
<span key={f}>
<b>{it.result[f]}</b>{' '}
{!fromPath && fromTags && <span className={l.fromTags}>{t('patterns.from_tags')}</span>}
</span>
);
})}
<span className={l.fromTags}>
{[it.result.year, it.result.track && `#${it.result.track}`, it.result.genres.join(', ')]
.filter(Boolean)
.join(' · ')}
</span>
{it.error && <span className={l.scanError}>{it.error}</span>}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
);
}
+44
View File
@@ -0,0 +1,44 @@
import type { components } from '@pipu/api-client';
import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../../shared/lib/format';
import { cx } from '../../shared/ui/ui';
import s from './library.module.css';
type ScanProgress = components['schemas']['ScanProgress'];
export type SourceStatus = components['schemas']['SourceStatus'];
export function StatusBadge({ status }: { status: SourceStatus }) {
const { t } = useTranslation('admin');
return <span className={cx(s.status, s[`st_${status}`])}>{t(`sources.status_${status}`)}</span>;
}
/** Прогресс идущего скана или итог последнего. */
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
const { t, i18n } = useTranslation('admin');
if (scan?.running) {
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
return (
<div className={s.scan}>
<div className={s.scanText}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
</div>
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} />
</div>
</div>
);
}
return (
<div className={s.scan}>
<div className={s.scanText}>
{lastScanAt ? t('sources.last_scan', { date: formatDateTime(lastScanAt, i18n.language) }) : t('sources.never_scanned')}
</div>
{scan?.error ? (
<div className={s.scanError}>{t('sources.scan_failed', { error: scan.error })}</div>
) : (
scan && <div className={s.scanSummary}>{t('sources.scan_done', { ...scan })}</div>
)}
</div>
);
}
+352
View File
@@ -0,0 +1,352 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { addIcon, chevronRightIcon, folderIcon, moreIcon, refreshIcon } from '../../shared/ui/icons';
import { Button, Dialog, Field, FormError, Icon, Menu, Segmented, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
import l from './library.module.css';
import { ScanLine, StatusBadge } from './ScanStatus';
type Source = components['schemas']['Source'];
type TestResult = components['schemas']['SourceTestResult'];
const SCAN_POLL_MS = 2000;
export function SourcesPage() {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const [editing, setEditing] = useState<Source | 'new' | null>(null);
const sources = useQuery({
queryKey: ['sources'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/sources')).items,
// Пока идёт скан — опрашиваем прогресс.
refetchInterval: (q) => (q.state.data?.some((x) => x.scan?.running) ? SCAN_POLL_MS : false),
});
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('sources.title')}</h1>
<p className={s.subtitle}>{t('sources.subtitle')}</p>
</div>
<Button variant="primary" icon={addIcon} onClick={() => setEditing('new')}>
{t('sources.add')}
</Button>
</div>
<FormError>{sources.error ? errorText(sources.error) : null}</FormError>
{sources.isSuccess && sources.data.length === 0 && <div className={s.empty}>{t('sources.empty')}</div>}
<div className={l.list}>
{sources.data?.map((src) => (
<SourceCard key={src.id} source={src} onEdit={() => setEditing(src)} />
))}
</div>
{editing && <SourceDialog source={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />}
</div>
);
}
function location(src: Source): string {
if (src.type === 'nfs') return `${src.nfs_server ?? ''}:${src.nfs_export ?? ''}`;
return `LIBRARY_HOST/${src.local_path ?? ''}`;
}
function SourceCard({ source, onEdit }: { source: Source; onEdit: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const navigate = useNavigate();
const errorText = useErrorText();
const [test, setTest] = useState<TestResult | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const id = source.id;
const refresh = () => queryClient.invalidateQueries({ queryKey: ['sources'] });
const scan = useMutation({
mutationFn: async (full: boolean) =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/scan', { params: { path: { id } }, body: { full } })),
onSuccess: refresh,
});
const check = useMutation({
mutationFn: async () =>
unwrap(await session.client.POST('/api/v1/admin/sources/{id}/test', { params: { path: { id } } })),
onSuccess: setTest,
});
const patch = useMutation({
mutationFn: async (enabled: boolean) =>
unwrap(await session.client.PATCH('/api/v1/admin/sources/{id}', { params: { path: { id } }, body: { enabled } })),
onSuccess: refresh,
});
const remove = useMutation({
mutationFn: async () =>
unwrap(await session.client.DELETE('/api/v1/admin/sources/{id}', { params: { path: { id } } })),
onSuccess: refresh,
});
const error = scan.error ?? check.error ?? patch.error ?? remove.error;
const running = source.scan?.running ?? false;
const disabled = source.status === 'disabled';
const menu: MenuItem[] = [
{ label: t('sources.full_scan'), icon: refreshIcon, onSelect: () => scan.mutate(true) },
{ label: t('sources.edit'), onSelect: onEdit },
disabled
? { label: t('sources.enable'), onSelect: () => patch.mutate(true) }
: { label: t('sources.disable'), onSelect: () => patch.mutate(false) },
{ label: t('sources.delete'), danger: true, onSelect: () => setConfirmDelete(true) },
];
return (
<article className={l.card} aria-label={source.name}>
<div className={l.cardHead}>
<div className={l.cardTitle}>
{source.name}
<span className={l.typeBadge}>{t(`sources.type_${source.type}`)}</span>
<StatusBadge status={source.status} />
</div>
<span className={l.location}>{location(source)}</span>
{source.status === 'error' && source.status_message && (
<span className={l.message}>{source.status_message}</span>
)}
</div>
<div className={l.cardActions}>
<Button
variant="primary"
busy={scan.isPending}
disabled={running || source.status !== 'mounted'}
onClick={() => scan.mutate(false)}
>
{t('sources.scan')}
</Button>
<Button busy={check.isPending} onClick={() => check.mutate()}>
{t('sources.test')}
</Button>
<Button onClick={() => void navigate({ to: '/admin/sources/$id/patterns', params: { id } })}>
{t('sources.patterns')}
</Button>
<Menu label={t('users.actions', { username: source.name })} trigger={<Icon src={moreIcon} />} items={menu} />
</div>
<div className={l.cardFoot}>
<ScanLine scan={source.scan} lastScanAt={source.last_scan_at} />
{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 })}
</span>
)}
<FormError>{error ? errorText(error) : null}</FormError>
</div>
{confirmDelete && (
<Dialog
title={t('sources.delete')}
onClose={() => setConfirmDelete(false)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirmDelete(false)}>
{t('common:form.cancel')}
</Button>
<Button
variant="danger"
onClick={() => {
setConfirmDelete(false);
remove.mutate();
}}
>
{t('users.confirm')}
</Button>
</>
}
>
<p className={s.note}>{t('sources.delete_confirm', { name: source.name })}</p>
</Dialog>
)}
</article>
);
}
const INTERVALS = [0, 60, 360, 1440];
function SourceDialog({ source, onClose }: { source: Source | null; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const queryClient = useQueryClient();
const errorText = useErrorText();
const fieldError = useFieldError();
const [type, setType] = useState<'local' | 'nfs'>(source?.type ?? 'local');
const [name, setName] = useState(source?.name ?? '');
const [localPath, setLocalPath] = useState(source?.local_path ?? '');
const [server, setServer] = useState(source?.nfs_server ?? '');
const [exportPath, setExportPath] = useState(source?.nfs_export ?? '');
const [options, setOptions] = useState(source?.nfs_options ?? 'nfsvers=4.1,soft,timeo=150,retrans=3');
const [priority, setPriority] = useState<'tags_first' | 'path_first'>(source?.tag_priority ?? 'tags_first');
const [interval, setIntervalMin] = useState(source?.scan_interval_minutes ?? 0);
const save = useMutation({
mutationFn: async () => {
const common = {
name: name.trim(),
tag_priority: priority,
...(type === 'local'
? { local_path: localPath }
: { nfs_server: server.trim(), nfs_export: exportPath.trim(), nfs_options: options.trim() }),
};
if (source)
return unwrap(
await session.client.PATCH('/api/v1/admin/sources/{id}', {
params: { path: { id: source.id } },
body: { ...common, scan_interval_minutes: interval },
}),
);
return unwrap(
await session.client.POST('/api/v1/admin/sources', {
body: { ...common, type, scan_interval_minutes: interval || null },
}),
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['sources'] });
onClose();
},
});
const err = save.error;
const fields = ['name', 'local_path', 'nfs_server', 'nfs_export', 'nfs_options'];
const general = err && !fields.some((f) => fieldError(err, f)) ? errorText(err) : null;
const submit = (e: FormEvent) => {
e.preventDefault();
save.mutate();
};
return (
<Dialog
title={t(source ? 'sources.form_title_edit' : 'sources.form_title_new')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button type="submit" form="source-form" variant="primary" busy={save.isPending} disabled={!name.trim()}>
{t('sources.save')}
</Button>
</>
}
>
<form id="source-form" onSubmit={submit} noValidate className={s.sessions}>
<Field label={t('sources.name')} value={name} onChange={setName} maxLength={100} error={fieldError(err, 'name')} />
{!source && (
<div className={l.field}>
<span className={l.label}>{t('sources.type')}</span>
<Segmented
label={t('sources.type')}
value={type}
onChange={setType}
options={[
{ value: 'local', label: t('sources.type_local') },
{ value: 'nfs', label: t('sources.type_nfs') },
]}
/>
</div>
)}
{type === 'local' ? (
<FolderPicker value={localPath} onChange={setLocalPath} error={fieldError(err, 'local_path')} />
) : (
<>
<Field
label={t('sources.nfs_server')}
value={server}
onChange={setServer}
hint={t('sources.nfs_server_hint')}
error={fieldError(err, 'nfs_server')}
mono
/>
<Field
label={t('sources.nfs_export')}
value={exportPath}
onChange={setExportPath}
hint={t('sources.nfs_export_hint')}
error={fieldError(err, 'nfs_export')}
mono
/>
<Field
label={t('sources.nfs_options')}
value={options}
onChange={setOptions}
hint={t('sources.nfs_options_hint')}
error={fieldError(err, 'nfs_options')}
mono
/>
</>
)}
<div className={l.field}>
<span className={l.label}>{t('sources.tag_priority')}</span>
<Segmented
label={t('sources.tag_priority')}
value={priority}
onChange={setPriority}
options={[
{ value: 'tags_first', label: t('sources.tags_first') },
{ value: 'path_first', label: t('sources.path_first') },
]}
/>
</div>
<label className={l.field}>
<span className={l.label}>{t('sources.interval')}</span>
<select className={s.select} value={interval} onChange={(e) => setIntervalMin(Number(e.target.value))}>
{[...new Set([...INTERVALS, interval])].map((m) => (
<option key={m} value={m}>
{m === 0 ? t('sources.interval_off') : t('sources.interval_hours', { count: Math.round(m / 60) })}
</option>
))}
</select>
</label>
<FormError>{general}</FormError>
</form>
</Dialog>
);
}
/** Выбор подпапки LIBRARY_HOST: хлебные крошки и список вложенных папок. */
function FolderPicker({ value, onChange, error }: { value: string; onChange: (v: string) => void; error?: string }) {
const { t } = useTranslation('admin');
const session = useSession();
const dirs = useQuery({
queryKey: ['host-dirs', value],
queryFn: async () =>
unwrap(await session.client.GET('/api/v1/admin/library/host-dirs', { params: { query: { path: value } } })),
});
const parts = value ? value.split('/') : [];
return (
<div className={l.field}>
<span className={l.label}>{t('sources.folder')}</span>
<div className={l.picker}>
<div className={l.crumbs}>
<button type="button" className={l.dir} onClick={() => onChange('')}>
LIBRARY_HOST
</button>
{parts.map((p, i) => (
<span key={i} className={l.crumbs}>
<Icon src={chevronRightIcon} size={14} />
<button type="button" className={l.dir} onClick={() => onChange(parts.slice(0, i + 1).join('/'))}>
{p}
</button>
</span>
))}
</div>
{dirs.data?.dirs.map((d) => (
<button key={d} type="button" className={l.dir} onClick={() => onChange(value ? `${value}/${d}` : d)}>
<Icon src={folderIcon} size={18} className={l.dirIcon} />
{d}
</button>
))}
</div>
<span className={s.note}>{error ?? (value ? value : t('sources.folder_root'))}</span>
</div>
);
}
+362
View File
@@ -0,0 +1,362 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState, type FormEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
import { useErrorText, useFieldError } from '../../shared/lib/errors';
import { formatRelative } from '../../shared/lib/format';
import { assignableRoles, canManageUser, type Role } from '../../shared/lib/policy';
import { addIcon, checkIcon, copyIcon, lockResetIcon, moreIcon, searchIcon } from '../../shared/ui/icons';
import { Avatar, Button, cx, Dialog, Field, FormError, Icon, Menu, type MenuItem } from '../../shared/ui/ui';
import s from '../page.module.css';
type AdminUser = components['schemas']['AdminUser'];
const ROLES: Role[] = ['admin', 'moderator', 'user'];
type Confirm = { kind: 'reset' | 'block' | 'delete'; user: AdminUser };
function useDebounced<T>(value: T, ms: number): T {
const [v, setV] = useState(value);
useEffect(() => {
const id = setTimeout(() => setV(value), ms);
return () => clearTimeout(id);
}, [value, ms]);
return v;
}
function status(u: AdminUser): 'blocked' | 'must_change' | 'active' {
if (u.is_blocked) return 'blocked';
if (u.must_change_password || u.must_change_credentials) return 'must_change';
return 'active';
}
export function UsersPage() {
const { t, i18n } = useTranslation(['admin', 'common']);
const session = useSession();
const me = useUser();
const queryClient = useQueryClient();
const errorText = useErrorText();
const [q, setQ] = useState('');
const [role, setRole] = useState<Role | null>(null);
const query = useDebounced(q.trim(), 200);
const [creating, setCreating] = useState(false);
const [confirm, setConfirm] = useState<Confirm | null>(null);
const [secret, setSecret] = useState<{ username: string; password: string } | null>(null);
const [actionError, setActionError] = useState<unknown>(null);
const list = useInfiniteQuery({
queryKey: ['admin-users', query, role],
initialPageParam: undefined as string | undefined,
queryFn: async ({ pageParam }) =>
unwrap(
await session.client.GET('/api/v1/admin/users', {
params: { query: { q: query || undefined, role: role ?? undefined, cursor: pageParam, limit: 50 } },
}),
),
getNextPageParam: (page) => page.next_cursor ?? undefined,
});
const users = list.data?.pages.flatMap((p) => p.items) ?? [];
const refresh = () => queryClient.invalidateQueries({ queryKey: ['admin-users'] });
const patch = useMutation({
mutationFn: async ({ id, body }: { id: string; body: components['schemas']['UpdateUserRequest'] }) =>
unwrap(await session.client.PATCH('/api/v1/admin/users/{id}', { params: { path: { id } }, body })),
onSuccess: refresh,
onError: setActionError,
});
const reset = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(
await session.client.POST('/api/v1/admin/users/{id}/reset-password', { params: { path: { id: u.id } } }),
),
onSuccess: (r, u) => {
setSecret({ username: u.username, password: r.temporary_password });
void refresh();
},
onError: setActionError,
});
const remove = useMutation({
mutationFn: async (u: AdminUser) =>
unwrap(await session.client.DELETE('/api/v1/admin/users/{id}', { params: { path: { id: u.id } } })),
onSuccess: refresh,
onError: setActionError,
});
const runConfirm = () => {
if (!confirm) return;
const { kind, user } = confirm;
setConfirm(null);
setActionError(null);
if (kind === 'reset') reset.mutate(user);
if (kind === 'block') patch.mutate({ id: user.id, body: { is_blocked: true } });
if (kind === 'delete') remove.mutate(user);
};
const actionsFor = (u: AdminUser): MenuItem[] => {
const items: MenuItem[] = [];
const can = (a: Parameters<typeof canManageUser>[2]) => canManageUser(me, u, a);
if (can('reset_password'))
items.push({ label: t('users.reset_password'), icon: lockResetIcon, onSelect: () => setConfirm({ kind: 'reset', user: u }) });
if (can('block'))
items.push(
u.is_blocked
? { label: t('users.unblock'), onSelect: () => patch.mutate({ id: u.id, body: { is_blocked: false } }) }
: { label: t('users.block'), onSelect: () => setConfirm({ kind: 'block', user: u }) },
);
if (can('change_role'))
for (const r of ROLES.filter((r) => r !== u.role))
items.push({
label: t('users.make_role', { role: t(`common:roles.${r}`) }),
onSelect: () => patch.mutate({ id: u.id, body: { role: r } }),
});
if (can('delete'))
items.push({ label: t('users.delete'), danger: true, onSelect: () => setConfirm({ kind: 'delete', user: u }) });
return items;
};
return (
<div className={s.page}>
<div className={s.head}>
<div>
<h1>{t('users.title')}</h1>
<p className={s.subtitle}>{t('users.subtitle')}</p>
</div>
<Button variant="primary" icon={addIcon} onClick={() => setCreating(true)}>
{t('users.create')}
</Button>
</div>
<div className={s.toolbar}>
<label className={s.search}>
<Icon src={searchIcon} size={18} className={s.searchIcon} />
<input
type="search"
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder={t('users.search')}
aria-label={t('users.search')}
/>
</label>
<div className={s.chips} role="group" aria-label={t('users.col_role')}>
<button type="button" className={s.chip} aria-pressed={role === null} onClick={() => setRole(null)}>
{t('users.all')}
</button>
{ROLES.map((r) => (
<button key={r} type="button" className={s.chip} aria-pressed={role === r} onClick={() => setRole(r)}>
{t(`common:roles.${r}`)}
</button>
))}
</div>
</div>
<FormError>{actionError || list.error ? errorText(actionError ?? list.error) : null}</FormError>
<div className={s.tableWrap}>
<table className={s.table}>
<thead>
<tr>
<th>{t('users.col_user')}</th>
<th>{t('users.col_role')}</th>
<th>{t('users.col_status')}</th>
<th>{t('users.col_last_login')}</th>
<th aria-hidden />
</tr>
</thead>
<tbody>
{users.map((u) => {
const items = actionsFor(u);
const st = status(u);
return (
<tr key={u.id}>
<td>
<div className={s.userCell}>
<Avatar name={u.display_name ?? u.username} />
<div>
<div className={s.userName}>
{u.display_name ?? u.username}
{u.id === me.id && <span className={s.you}>({t('users.you')})</span>}
</div>
<div className={s.userLogin}>@{u.username}</div>
</div>
</div>
</td>
<td>
<span className={cx(s.badge, s[`role_${u.role}`])}>{t(`common:roles.${u.role}`)}</span>
</td>
<td>
<span className={cx(s.status, s[`status_${st}`])}>{t(`users.status_${st}`)}</span>
</td>
<td className={s.muted}>
{u.last_login_at ? formatRelative(u.last_login_at, i18n.language) : t('users.never')}
</td>
<td>
{items.length > 0 && (
<Menu label={t('users.actions', { username: u.username })} trigger={<Icon src={moreIcon} />} items={items} />
)}
</td>
</tr>
);
})}
</tbody>
</table>
{list.isSuccess && users.length === 0 && <div className={s.empty}>{t('users.empty')}</div>}
{list.isPending && <div className={s.empty}>{t('common:loading')}</div>}
</div>
{list.hasNextPage && (
<Button className={s.more} busy={list.isFetchingNextPage} onClick={() => void list.fetchNextPage()}>
{t('users.more')}
</Button>
)}
{creating && (
<CreateUserDialog
actorRole={me.role}
onClose={() => setCreating(false)}
onCreated={(username, password) => {
setCreating(false);
setSecret({ username, password });
void refresh();
}}
/>
)}
{confirm && (
<Dialog
title={t(`users.${confirm.kind === 'reset' ? 'reset_password' : confirm.kind}`)}
onClose={() => setConfirm(null)}
footer={
<>
<Button variant="ghost" onClick={() => setConfirm(null)}>
{t('common:form.cancel')}
</Button>
<Button variant={confirm.kind === 'reset' ? 'primary' : 'danger'} onClick={runConfirm}>
{t('users.confirm')}
</Button>
</>
}
>
<p className={s.note}>{t(`users.${confirm.kind}_confirm`, { username: confirm.user.username })}</p>
</Dialog>
)}
{secret && <TemporaryPasswordDialog {...secret} onClose={() => setSecret(null)} />}
</div>
);
}
function CreateUserDialog({
actorRole,
onClose,
onCreated,
}: {
actorRole: Role;
onClose: () => void;
onCreated: (username: string, password: string) => void;
}) {
const { t } = useTranslation(['admin', 'common']);
const session = useSession();
const errorText = useErrorText();
const fieldError = useFieldError();
const roles = assignableRoles(actorRole);
const [username, setUsername] = useState('');
const [name, setName] = useState('');
const [role, setRole] = useState<Role>('user');
const create = useMutation({
mutationFn: async () =>
unwrap(
await session.client.POST('/api/v1/admin/users', {
body: { username: username.trim(), display_name: name.trim() || undefined, role },
}),
),
onSuccess: (r) => onCreated(r.user.username, r.temporary_password),
});
const usernameError = fieldError(create.error, 'username');
const nameError = fieldError(create.error, 'display_name');
const submit = (e: FormEvent) => {
e.preventDefault();
create.mutate();
};
return (
<Dialog
title={t('create.title')}
onClose={onClose}
footer={
<>
<Button variant="ghost" onClick={onClose}>
{t('common:form.cancel')}
</Button>
<Button type="submit" form="create-user" variant="primary" busy={create.isPending} disabled={!username.trim()}>
{t('create.submit')}
</Button>
</>
}
>
<form id="create-user" onSubmit={submit} noValidate className={s.sessions}>
<Field
label={t('create.username')}
value={username}
onChange={setUsername}
autoCapitalize="none"
spellCheck={false}
hint={t('create.username_hint')}
error={usernameError}
mono
/>
<Field label={t('create.display_name')} value={name} onChange={setName} maxLength={64} error={nameError} />
{roles.length > 1 && (
<label className={s.sessions}>
<span className={s.subtitle}>{t('create.role')}</span>
<select className={s.select} value={role} onChange={(e) => setRole(e.target.value as Role)}>
{roles.map((r) => (
<option key={r} value={r}>
{t(`common:roles.${r}`)}
</option>
))}
</select>
</label>
)}
<p className={s.note}>{t('create.password_note')}</p>
<FormError>{create.error && !usernameError && !nameError ? errorText(create.error) : null}</FormError>
</form>
</Dialog>
);
}
function TemporaryPasswordDialog({ username, password, onClose }: { username: string; password: string; onClose: () => void }) {
const { t } = useTranslation(['admin', 'common']);
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(password);
setCopied(true);
} catch {
// Буфер обмена недоступен (http, права) — пароль виден на экране.
}
};
return (
<Dialog
title={t('temp_password.title')}
onClose={onClose}
footer={
<Button variant="primary" onClick={onClose}>
{t('temp_password.done')}
</Button>
}
>
<p className={s.note}>{t('temp_password.text', { username })}</p>
<div className={s.secret}>
<span data-testid="temporary-password">{password}</span>
<Button variant="ghost" icon={copied ? checkIcon : copyIcon} onClick={() => void copy()}>
{copied ? t('common:form.copied') : t('common:form.copy')}
</Button>
</div>
</Dialog>
);
}
+301
View File
@@ -0,0 +1,301 @@
.list {
display: flex;
flex-direction: column;
gap: 14px;
}
.card {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 14px 20px;
padding: 20px 22px;
border-radius: var(--radius-lg);
background: var(--surface-a);
border: 1px solid var(--line);
}
.cardHead {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.cardTitle {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 17px;
font-weight: 600;
}
.location {
font-family: var(--font-mono);
font-size: 13px;
color: var(--text2);
overflow-wrap: anywhere;
}
.message {
font-size: 13px;
color: var(--danger);
}
.cardActions {
display: flex;
align-items: flex-start;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.cardFoot {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 6px;
}
.typeBadge {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 9px;
border-radius: var(--radius-pill);
background: var(--surface3);
color: var(--text2);
font-size: 12px;
font-weight: 600;
}
.status {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
}
.status::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.st_mounted {
color: var(--ok);
}
.st_error {
color: var(--danger);
}
.st_pending {
color: var(--warn);
}
.st_disabled {
color: var(--text3);
}
.scan {
display: flex;
flex-direction: column;
gap: 6px;
}
.scanText,
.scanSummary {
font-size: 13px;
color: var(--text2);
}
.scanError {
font-size: 13px;
color: var(--danger);
}
.bar {
height: 6px;
border-radius: 3px;
background: var(--surface3);
overflow: hidden;
}
.bar span {
display: block;
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--violet));
transition: width 0.4s;
}
.test {
font-size: 13px;
}
.testOk {
color: var(--ok);
}
.testFail {
color: var(--danger);
}
.picker {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 240px;
overflow-y: auto;
padding: 6px;
border-radius: var(--radius-md);
background: var(--surface2);
border: 1px solid var(--line2);
}
.crumbs {
display: flex;
align-items: center;
gap: 4px;
flex-wrap: wrap;
padding: 4px 6px;
font-family: var(--font-mono);
font-size: 13px;
color: var(--text2);
}
.dir {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 8px;
border-radius: 8px;
text-align: left;
font-size: 14px;
}
.dir:hover {
background: var(--hover);
}
.dirIcon {
color: var(--accent2);
}
.label {
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.tile {
display: flex;
flex-direction: column;
gap: 4px;
padding: 16px 18px;
border-radius: var(--radius-lg);
background: var(--surface-a);
border: 1px solid var(--line);
}
.tileValue {
font-family: var(--font-display);
font-size: 26px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.tileLabel {
font-size: 13px;
color: var(--text2);
}
.patternRow {
display: grid;
grid-template-columns: 28px minmax(0, 1fr) auto;
align-items: start;
gap: 10px;
}
.patternNum {
height: 48px;
margin-top: 22px;
display: grid;
place-items: center;
color: var(--text3);
font-variant-numeric: tabular-nums;
}
.patternTools {
display: flex;
align-items: center;
gap: 2px;
height: 48px;
}
.check {
display: inline-flex;
align-items: center;
gap: 6px;
margin-right: 6px;
font-size: 13px;
color: var(--text2);
}
.groups {
font-family: var(--font-mono);
font-size: 12px;
color: var(--text3);
}
.match {
font-family: var(--font-mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.noMatch {
color: var(--warn);
}
.result {
display: flex;
flex-direction: column;
gap: 2px;
font-size: 13px;
}
.result b {
font-weight: 600;
}
.fromTags {
color: var(--text3);
font-size: 12px;
}
@media (max-width: 767px) {
.card {
grid-template-columns: 1fr;
}
.cardActions {
justify-content: flex-start;
}
}
+166
View File
@@ -0,0 +1,166 @@
.screen {
position: relative;
min-height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.backdrop {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: var(--amb-op);
}
.backdrop span {
position: absolute;
border-radius: 50%;
filter: blur(30px);
}
.blob1 {
width: 62vmax;
height: 62vmax;
left: -18vmax;
top: -24vmax;
background: radial-gradient(circle, rgba(47, 128, 255, 0.34), transparent 65%);
animation: drift1 16s ease-in-out infinite alternate;
}
.blob2 {
width: 55vmax;
height: 55vmax;
right: -20vmax;
bottom: -26vmax;
background: radial-gradient(circle, rgba(124, 92, 255, 0.3), transparent 65%);
animation: drift2 21s ease-in-out infinite alternate;
}
.blob3 {
width: 40vmax;
height: 40vmax;
left: 40%;
top: 30%;
background: radial-gradient(circle, rgba(53, 211, 255, 0.16), transparent 65%);
animation: drift1 26s ease-in-out infinite alternate-reverse;
}
@keyframes drift1 {
to {
transform: translate(6vmax, 4vmax);
}
}
@keyframes drift2 {
to {
transform: translate(-5vmax, -6vmax);
}
}
@media (prefers-reduced-motion: reduce) {
.backdrop span {
animation: none;
}
}
.top {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 20px;
}
.center {
position: relative;
z-index: 1;
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px 16px 48px;
}
.card {
width: 420px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 16px;
padding: 36px;
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
animation: pipuIn 0.2s ease-out;
}
@media (max-width: 480px) {
.card {
padding: 28px 20px;
}
}
.card h1 {
margin: 0;
font-family: var(--font-display);
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
}
.card form {
display: flex;
flex-direction: column;
gap: 14px;
}
.lead {
margin: -6px 0 4px;
color: var(--text2);
line-height: 1.5;
}
.badge {
align-self: flex-start;
display: inline-flex;
align-items: center;
gap: 6px;
height: 28px;
padding: 0 12px;
border-radius: var(--radius-pill);
background: var(--violet-soft);
color: var(--violet);
font-size: 12px;
font-weight: 700;
}
.submit {
height: 48px;
margin-top: 4px;
}
.hint {
padding: 10px 12px;
border-radius: 10px;
background: var(--accent-soft);
color: var(--text2);
font-size: 13px;
line-height: 1.45;
}
.footer {
font-size: 12px;
color: var(--text3);
}
.link {
align-self: center;
}
+116
View File
@@ -0,0 +1,116 @@
.section {
display: flex;
flex-direction: column;
gap: 14px;
}
.section h2 {
margin: 0;
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
}
.artistRow {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.tab {
display: inline-flex;
align-items: center;
gap: 6px;
height: 34px;
padding: 0 16px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
color: var(--text);
font-size: 14px;
font-weight: 600;
text-decoration: none;
}
.tab[aria-selected='true'] {
background: var(--text);
color: var(--bg);
border-color: var(--text);
}
.tabCount {
font-size: 12px;
color: var(--text3);
}
.tab[aria-selected='true'] .tabCount {
color: inherit;
opacity: 0.6;
}
.hero {
position: relative;
display: flex;
align-items: flex-end;
gap: 28px;
padding: 28px;
margin: -32px -36px 0;
overflow: hidden;
}
.heroBg {
position: absolute;
inset: 0;
opacity: 0.35;
filter: blur(60px);
pointer-events: none;
}
.heroBody {
position: relative;
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.kind {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text2);
}
.heroTitle {
margin: 0;
font-family: var(--font-display);
font-size: clamp(28px, 4vw, 48px);
font-weight: 600;
letter-spacing: -0.02em;
line-height: 1.1;
overflow-wrap: anywhere;
}
.heroMeta {
color: var(--text2);
}
.heroMeta a {
color: var(--text);
font-weight: 600;
text-decoration: none;
}
.heroMeta a:hover {
text-decoration: underline;
}
@media (max-width: 767px) {
.hero {
flex-direction: column;
align-items: flex-start;
margin: -20px -16px 0;
padding: 20px 16px;
}
}
+63
View File
@@ -0,0 +1,63 @@
.home {
min-height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
text-align: center;
}
.greeting {
margin: 0;
font-family: var(--font-display);
font-size: 24px;
font-weight: 600;
letter-spacing: -0.01em;
}
.orb {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 6px;
width: 300px;
height: 300px;
max-width: 70vw;
max-height: 70vw;
border-radius: 50%;
color: #fff;
background:
radial-gradient(circle at 35% 30%, rgba(156, 198, 255, 0.55), transparent 45%),
radial-gradient(circle at 65% 70%, var(--wave-color3), transparent 55%),
radial-gradient(circle at 50% 50%, var(--wave-color2), var(--wave-color1) 70%);
box-shadow: 0 0 80px rgba(47, 128, 255, 0.35);
opacity: 0.85;
animation: breathe 6s ease-in-out infinite;
}
.orbLabel {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
}
@keyframes breathe {
50% {
transform: scale(1.03);
}
}
@media (prefers-reduced-motion: reduce) {
.orb {
animation: none;
}
}
.soon {
max-width: 420px;
margin: 0;
color: var(--text2);
line-height: 1.5;
}
+343
View File
@@ -0,0 +1,343 @@
.page {
max-width: 1100px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.narrow {
max-width: 720px;
}
.head {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.head h1 {
margin: 0;
font-family: var(--font-display);
font-size: 28px;
font-weight: 600;
letter-spacing: -0.01em;
}
.subtitle {
margin: 6px 0 0;
color: var(--text2);
}
.section {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
border-radius: var(--radius-lg);
background: var(--surface-a);
border: 1px solid var(--line);
}
.section h2 {
margin: 0;
font-size: 17px;
font-weight: 600;
}
.section form {
display: flex;
flex-direction: column;
gap: 14px;
}
.row {
display: flex;
align-items: flex-end;
gap: 12px;
flex-wrap: wrap;
}
.row > :first-child {
flex: 1;
min-width: 220px;
}
.settingRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.ok {
color: var(--ok);
font-size: 13px;
font-weight: 500;
}
.actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.search {
position: relative;
flex: 1;
min-width: 220px;
max-width: 360px;
}
.search input {
width: 100%;
height: 40px;
padding: 0 12px 0 38px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
}
.search input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.searchIcon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text3);
}
.chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.chip {
height: 32px;
padding: 0 14px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.chip[aria-pressed='true'] {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--text);
}
.tableWrap {
border-radius: var(--radius-lg);
border: 1px solid var(--line);
background: var(--surface-a);
}
/* Горизонтальная прокрутка только там, где таблица не помещается: она обрезала бы меню действий. */
@media (max-width: 900px) {
.tableWrap {
overflow-x: auto;
}
}
.table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.table th {
height: 44px;
padding: 0 16px;
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
border-bottom: 1px solid var(--line);
white-space: nowrap;
}
.table td {
height: 60px;
padding: 0 16px;
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.table tr:last-child td {
border-bottom: 0;
}
.table tbody tr:hover {
background: var(--hover);
}
.userCell {
display: flex;
align-items: center;
gap: 12px;
min-width: 200px;
}
.userName {
font-weight: 600;
}
.userLogin {
font-size: 12px;
color: var(--text3);
}
.you {
margin-left: 6px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
}
.badge {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 10px;
border-radius: var(--radius-pill);
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.role_admin {
background: var(--violet-soft);
color: var(--violet);
}
.role_moderator {
background: var(--accent-soft);
color: var(--accent2);
}
.role_user {
background: var(--hover);
color: var(--text2);
}
.status {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
}
.status::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
.status_active {
color: var(--ok);
}
.status_blocked {
color: var(--danger);
}
.status_must_change {
color: var(--warn);
}
.muted {
color: var(--text3);
white-space: nowrap;
}
.empty {
padding: 40px 16px;
text-align: center;
color: var(--text3);
}
.more {
align-self: center;
}
.mono {
font-family: var(--font-mono);
}
.secret {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-radius: var(--radius-md);
background: var(--surface2);
border: 1px dashed var(--line2);
font-family: var(--font-mono);
font-size: 18px;
letter-spacing: 0.04em;
word-break: break-all;
}
.select {
height: 48px;
padding: 0 12px;
border-radius: var(--radius-md);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font: inherit;
}
.note {
margin: 0;
font-size: 13px;
color: var(--text2);
line-height: 1.5;
}
.sessions {
display: flex;
flex-direction: column;
gap: 8px;
}
.session {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: var(--radius-md);
background: var(--surface2);
}
.sessionMeta {
font-size: 12px;
color: var(--text3);
}
+78
View File
@@ -0,0 +1,78 @@
import { useQueryClient } from '@tanstack/react-query';
import { createContext, useContext, useEffect, useState, useSyncExternalStore, type ReactNode } from 'react';
import { useSession } from '../app/session';
import { Player, type PlayerState } from './player';
const PlayerContext = createContext<Player | null>(null);
/** Плеер живёт, пока пользователь вошёл; горячие клавиши и сохранение при уходе со страницы. */
export function PlayerProvider({ children }: { children: ReactNode }) {
const session = useSession();
const queryClient = useQueryClient();
const [player] = useState(() => new Player(session));
player.onReaction = () => void queryClient.invalidateQueries();
useEffect(() => {
player.start();
void player.restore().catch(() => undefined);
const onHide = () => player.onHide();
const onVisibility = () => document.visibilityState === 'hidden' && onHide();
window.addEventListener('pagehide', onHide);
document.addEventListener('visibilitychange', onVisibility);
const onKey = (e: KeyboardEvent) => handleHotkey(player, e);
document.addEventListener('keydown', onKey);
return () => {
window.removeEventListener('pagehide', onHide);
document.removeEventListener('visibilitychange', onVisibility);
document.removeEventListener('keydown', onKey);
player.dispose();
};
}, [player]);
return <PlayerContext.Provider value={player}>{children}</PlayerContext.Provider>;
}
/** Пробел — пауза, ←/→ — ±10 с, Shift+←/→ — трек, L — лайк (docs/web.md). */
export function handleHotkey(player: Player, e: KeyboardEvent) {
const target = e.target as HTMLElement | null;
if (target?.closest('input, textarea, select, [contenteditable="true"], [role="dialog"]')) return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const s = player.getState();
if (!s.track) return;
switch (e.key) {
case ' ':
if (target?.closest('button, a')) return; // пробел на кнопке нажимает её
e.preventDefault();
player.toggle();
break;
case 'ArrowLeft':
e.preventDefault();
if (e.shiftKey) player.prev();
else player.seek(s.positionMs - 10_000);
break;
case 'ArrowRight':
e.preventDefault();
if (e.shiftKey) player.next();
else player.seek(s.positionMs + 10_000);
break;
case 'l':
case 'L':
case 'д':
case 'Д':
e.preventDefault();
void player.react(s.track, s.track.reaction === 'like' ? null : 'like').catch(() => undefined);
break;
}
}
export function usePlayer(): Player {
const p = useContext(PlayerContext);
if (!p) throw new Error('usePlayer outside PlayerProvider');
return p;
}
export function usePlayerState(): PlayerState {
const p = usePlayer();
return useSyncExternalStore(p.subscribe, p.getState);
}
+535
View File
@@ -0,0 +1,535 @@
import {
EventBuffer,
PlayAttempt,
PlayQueue,
unwrap,
type AuthSession,
type PlayContext,
type RepeatMode,
type Track,
} from '@pipu/core';
export type { PlayContext, Track };
export interface PlayerState {
track: Track | null;
queue: Track[];
index: number;
context: PlayContext | null;
playing: boolean;
loading: boolean;
positionMs: number;
durationMs: number;
volume: number;
muted: boolean;
shuffle: boolean;
repeat: RepeatMode;
error: string | null;
}
const LOCAL_KEY = 'pipu.player';
const SAVE_EVERY_MS = 10_000;
const FLUSH_EVERY_MS = 30_000;
const PRELOAD_BEFORE_MS = 20_000;
/** Сохраняем не больше этого числа треков очереди (лимит сервера — 500). */
const MAX_SAVED_QUEUE = 500;
interface Saved {
queue: string[];
index: number;
positionMs: number;
context: PlayContext | null;
volume: number;
shuffle: boolean;
repeat: RepeatMode;
savedAt: string;
}
function deviceId(): string {
try {
let id = localStorage.getItem('pipu.device');
if (!id) {
id = crypto.randomUUID();
localStorage.setItem('pipu.device', id);
}
return id;
} catch {
return 'web';
}
}
/**
* Плеер веба. Логика очереди и учёта событий — из @pipu/core; здесь — адаптер к HTMLAudioElement,
* Media Session и сохранение «трек и момент» (локально и на сервере).
*/
export class Player {
private readonly audio: HTMLAudioElement;
private readonly queue = new PlayQueue();
private readonly events: EventBuffer;
private attempt: PlayAttempt | null = null;
private context: PlayContext | null = null;
private state: PlayerState;
private readonly listeners = new Set<() => void>();
private readonly device = deviceId();
private lastServerSave = 0;
private preloaded: string | null = null;
private refreshedFor: string | null = null;
private timers: ReturnType<typeof setInterval>[] = [];
/** Реакция сохранена на сервере — списки на экране нужно перечитать. */
onReaction: (() => void) | null = null;
constructor(
private readonly session: AuthSession,
audio?: HTMLAudioElement,
) {
this.audio = audio ?? new Audio();
this.audio.preload = 'auto';
this.events = new EventBuffer({
send: async (events) => {
unwrap(await this.session.client.POST('/api/v1/events', { body: { events } }));
},
});
this.state = {
track: null, queue: [], index: 0, context: null, playing: false, loading: false, positionMs: 0,
durationMs: 0, volume: 1, muted: false, shuffle: false, repeat: 'off', error: null,
};
this.bindAudio();
this.bindMediaSession();
}
/** Запуск фоновой отправки событий (парный вызов — dispose). */
start() {
this.timers.push(setInterval(() => void this.events.flush(), FLUSH_EVERY_MS));
}
// --- подписка (useSyncExternalStore) ---
subscribe = (fn: () => void) => {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
};
getState = () => this.state;
private update(patch: Partial<PlayerState>) {
this.state = { ...this.state, ...patch };
for (const fn of this.listeners) fn();
}
private syncQueue() {
this.update({
track: this.queue.current ?? null,
queue: this.queue.tracks,
index: this.queue.index,
shuffle: this.queue.shuffle,
repeat: this.queue.repeat,
context: this.context,
});
}
// --- управление ---
/** Запустить список с позиции index в контексте (альбом, поиск, коллекция...). */
playList(tracks: Track[], index: number, context: PlayContext) {
const playable = tracks.filter((t) => t.available && t.stream_url);
const target = tracks[index];
if (!target || !playable.length) return;
this.finishAttempt('skip');
this.context = context;
this.queue.set(playable, Math.max(playable.indexOf(target), 0));
this.syncQueue();
void this.load(true);
}
toggle() {
if (this.state.playing) this.pause();
else this.play();
}
play() {
if (!this.queue.current) return;
if (!this.audio.src) {
void this.load(true, this.state.positionMs);
return;
}
// play() в старых браузерах и jsdom может вернуть undefined вместо промиса.
void Promise.resolve(this.audio.play()).catch(() => this.update({ playing: false }));
}
pause() {
this.audio.pause();
}
next() {
this.finishAttempt('skip');
if (this.queue.next()) {
this.syncQueue();
void this.load(true);
} else {
this.pause();
}
}
/** «Назад»: первые 3 с — к предыдущему треку, дальше — в начало текущего. */
prev() {
if (this.audio.currentTime > 3) {
this.seek(0);
return;
}
this.finishAttempt('skip');
if (this.queue.prev()) {
this.syncQueue();
void this.load(true);
}
}
jump(index: number) {
this.finishAttempt('skip');
this.queue.jump(index);
this.syncQueue();
void this.load(true);
}
seek(ms: number) {
const track = this.queue.current;
if (!track) return;
const to = Math.min(Math.max(ms, 0), track.duration_ms);
const from = Math.round(this.audio.currentTime * 1000);
this.attempt?.seek(to);
this.audio.currentTime = to / 1000;
this.update({ positionMs: to });
this.events.push({ type: 'seek', track_id: track.id, from_ms: from, position_ms: to });
this.saveSoon();
}
setVolume(v: number) {
this.audio.volume = Math.min(Math.max(v, 0), 1);
this.audio.muted = false;
this.update({ volume: this.audio.volume, muted: false });
this.saveLocal();
}
toggleMute() {
this.audio.muted = !this.audio.muted;
this.update({ muted: this.audio.muted });
}
toggleShuffle() {
this.queue.setShuffle(!this.queue.shuffle);
this.syncQueue();
this.saveLocal();
}
cycleRepeat() {
const order: RepeatMode[] = ['off', 'all', 'one'];
this.queue.repeat = order[(order.indexOf(this.queue.repeat) + 1) % order.length]!;
this.syncQueue();
this.saveLocal();
}
/** Лайк / дизлайк / снять реакцию. Дизлайк текущего трека сразу переключает на следующий. */
async react(track: Track, reaction: 'like' | 'dislike' | null) {
const prev = track.reaction;
const apply = (r: Track['reaction']) => {
for (const t of this.queue.tracks) if (t.id === track.id) t.reaction = r;
this.syncQueue();
};
apply(reaction);
const type = reaction ?? (prev === 'like' ? 'unlike' : 'undislike');
this.events.push({ type, track_id: track.id, position_ms: this.state.positionMs });
try {
const params = { params: { path: { id: track.id } } };
if (reaction) unwrap(await this.session.client.PUT('/api/v1/me/reactions/tracks/{id}', { ...params, body: { reaction } }));
else unwrap(await this.session.client.DELETE('/api/v1/me/reactions/tracks/{id}', params));
} catch (e) {
apply(prev);
throw e;
}
this.onReaction?.();
if (reaction === 'dislike' && this.queue.current?.id === track.id) this.next();
}
// --- загрузка трека ---
private async load(autoplay: boolean, startMs = 0) {
const track = this.queue.current;
if (!track?.stream_url) return;
this.update({ loading: true, error: null, positionMs: startMs, durationMs: track.duration_ms });
this.audio.src = track.stream_url;
if (startMs > 0) this.audio.currentTime = startMs / 1000;
this.attempt = new PlayAttempt(track, startMs);
this.preloaded = null;
this.events.push({
type: 'play_start',
track_id: track.id,
position_ms: startMs,
context_type: this.context?.type,
context_id: this.context?.id,
});
this.updateMediaMetadata(track);
this.saveServer();
if (autoplay) {
try {
await this.audio.play();
} catch {
// Автозапуск заблокирован браузером — ждём нажатия «Слушать».
this.update({ playing: false, loading: false });
}
}
}
/** Завершает текущую попытку: событие с реально прослушанным временем. */
private finishAttempt(type: 'play_end' | 'skip') {
const a = this.attempt;
if (!a) return;
this.attempt = null;
const pos = Math.round(this.audio.currentTime * 1000);
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush();
}
private bindAudio() {
const a = this.audio;
a.addEventListener('playing', () => {
this.update({ playing: true, loading: false });
if (this.attempt && this.state.positionMs > 0 && a.currentTime > 0.5) {
this.events.push({ type: 'resume', track_id: this.attempt.track.id, position_ms: this.state.positionMs });
}
});
a.addEventListener('pause', () => {
if (a.ended) return;
this.update({ playing: false });
const t = this.queue.current;
if (t) this.events.push({ type: 'pause', track_id: t.id, position_ms: this.state.positionMs });
this.saveServer();
});
a.addEventListener('waiting', () => this.update({ loading: true }));
a.addEventListener('timeupdate', () => {
const ms = Math.round(a.currentTime * 1000);
this.attempt?.tick(ms);
this.update({ positionMs: ms });
this.updatePositionState();
this.maybePreload(ms);
if (this.state.playing && Date.now() - this.lastServerSave > SAVE_EVERY_MS) this.saveServer();
this.saveLocal();
});
a.addEventListener('ended', () => {
this.finishAttempt('play_end');
if (this.queue.next(true)) {
this.syncQueue();
void this.load(true);
} else {
this.update({ playing: false, positionMs: 0 });
this.saveServer();
}
});
a.addEventListener('error', () => void this.onError());
a.addEventListener('volumechange', () => this.update({ volume: a.volume, muted: a.muted }));
}
/** Ссылка могла истечь (12 ч) — один раз берём свежую; иначе пропускаем трек. */
private async onError() {
const track = this.queue.current;
if (!track) return;
if (this.refreshedFor !== track.id) {
this.refreshedFor = track.id;
try {
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: [track.id] } }));
const fresh = res.items[0];
if (fresh?.stream_url) {
Object.assign(track, fresh);
void this.load(true, this.state.positionMs);
return;
}
} catch {
// упадём в пропуск ниже
}
}
this.update({ error: 'player.error', loading: false, playing: false });
this.attempt = null;
if (this.queue.peekNext() && this.queue.peekNext()!.id !== track.id) setTimeout(() => this.next(), 1500);
}
/** За 20 с до конца запрашиваем первый байт следующего трека — на сервере прогревается транскод. */
private maybePreload(ms: number) {
const cur = this.queue.current;
const next = this.queue.peekNext();
if (!cur || !next?.stream_url || this.preloaded === next.id) return;
if (cur.duration_ms - ms > PRELOAD_BEFORE_MS) return;
this.preloaded = next.id;
void fetch(next.stream_url, { headers: { Range: 'bytes=0-0' } }).catch(() => undefined);
}
// --- Media Session ---
private bindMediaSession() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
if (!ms) return;
const set = (action: MediaSessionAction, fn: MediaSessionActionHandler) => {
try {
ms.setActionHandler(action, fn);
} catch {
// действие не поддерживается браузером
}
};
set('play', () => this.play());
set('pause', () => this.pause());
set('nexttrack', () => this.next());
set('previoustrack', () => this.prev());
set('seekbackward', (d) => this.seek(this.state.positionMs - (d.seekOffset ?? 10) * 1000));
set('seekforward', (d) => this.seek(this.state.positionMs + (d.seekOffset ?? 10) * 1000));
set('seekto', (d) => d.seekTime !== undefined && this.seek(d.seekTime * 1000));
}
private updateMediaMetadata(track: Track) {
if (typeof navigator === 'undefined' || !navigator.mediaSession || typeof MediaMetadata === 'undefined') return;
const cover = track.album.cover_url;
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artists.map((a) => a.name).join(', '),
album: track.album.title,
artwork: cover
? [
{ src: `${cover}&size=300`, sizes: '300x300', type: 'image/webp' },
{ src: `${cover}&size=1200`, sizes: '1200x1200', type: 'image/webp' },
]
: [],
});
}
private updatePositionState() {
const ms = typeof navigator !== 'undefined' ? navigator.mediaSession : undefined;
const d = this.audio.duration;
if (!ms?.setPositionState || !Number.isFinite(d) || d <= 0) return;
try {
ms.setPositionState({ duration: d, position: Math.min(this.audio.currentTime, d), playbackRate: 1 });
} catch {
// некорректная позиция во время смены трека
}
}
// --- сохранение состояния ---
private snapshot(): Saved {
const ids = this.queue.tracks.map((t) => t.id);
const start = Math.max(0, Math.min(this.queue.index - Math.floor(MAX_SAVED_QUEUE / 2), ids.length - MAX_SAVED_QUEUE));
return {
queue: ids.slice(start, start + MAX_SAVED_QUEUE),
index: this.queue.index - start,
positionMs: this.state.positionMs,
context: this.context,
volume: this.state.volume,
shuffle: this.queue.shuffle,
repeat: this.queue.repeat,
savedAt: new Date().toISOString(),
};
}
private saveLocal() {
if (!this.queue.current) return;
try {
localStorage.setItem(LOCAL_KEY, JSON.stringify(this.snapshot()));
} catch {
// недоступное хранилище не мешает воспроизведению
}
}
private saveTimer: ReturnType<typeof setTimeout> | null = null;
private saveSoon() {
if (this.saveTimer) clearTimeout(this.saveTimer);
this.saveTimer = setTimeout(() => this.saveServer(), 1000);
}
/** PUT /me/playback-state; keepalive — запрос переживёт закрытие вкладки. */
saveServer(keepalive = false) {
const cur = this.queue.current;
if (!cur) return;
this.lastServerSave = Date.now();
this.saveLocal();
const s = this.snapshot();
void this.session.client
.PUT('/api/v1/me/playback-state', {
body: {
track_id: cur.id,
position_ms: s.positionMs,
queue: s.queue,
queue_index: s.index,
context: s.context ? { type: s.context.type, id: s.context.id, label: s.context.label } : undefined,
shuffle: s.shuffle,
repeat_mode: s.repeat,
volume: s.volume,
device_id: this.device,
},
keepalive,
})
.catch(() => undefined);
}
/** Уход со страницы: дослать события и состояние. */
onHide() {
this.saveServer(true);
void this.events.flush();
}
/**
* Восстановление после запуска: сначала локальная копия (мгновенно), затем серверная,
* если она новее и с другого устройства. Автозапуска нет — плеер на паузе в сохранённой позиции.
*/
async restore() {
let local: Saved | null = null;
try {
const raw = localStorage.getItem(LOCAL_KEY);
local = raw ? (JSON.parse(raw) as Saved) : null;
} catch {
local = null;
}
let saved = local;
try {
const server = unwrap(await this.session.client.GET('/api/v1/me/playback-state'));
if (
server.track_id &&
server.updated_at &&
(!local || (server.device_id !== this.device && server.updated_at > local.savedAt))
) {
saved = {
queue: server.queue,
index: server.queue_index,
positionMs: server.position_ms,
context: (server.context?.type ? server.context : null) as PlayContext | null,
volume: server.volume,
shuffle: server.shuffle,
repeat: server.repeat_mode,
savedAt: server.updated_at,
};
}
} catch {
// офлайн — остаёмся с локальной копией
}
if (!saved?.queue.length) return;
const res = unwrap(await this.session.client.POST('/api/v1/tracks/batch', { body: { ids: saved.queue } }));
const wanted = saved.queue[saved.index];
const tracks = res.items.filter((t) => t.available && t.stream_url);
if (!tracks.length) return;
const idx = Math.max(tracks.findIndex((t) => t.id === wanted), 0);
this.context = saved.context;
this.queue.repeat = saved.repeat;
this.queue.set(tracks, idx);
if (saved.shuffle) this.queue.setShuffle(true);
this.audio.volume = saved.volume;
const pos = tracks[idx]?.id === wanted ? saved.positionMs : 0;
this.syncQueue();
this.update({ positionMs: pos, durationMs: tracks[idx]!.duration_ms, volume: saved.volume });
this.updateMediaMetadata(tracks[idx]!);
}
dispose() {
this.finishAttempt('skip');
this.audio.pause();
this.audio.removeAttribute('src');
for (const t of this.timers) clearInterval(t);
this.timers = [];
void this.events.flush();
}
}
+25
View File
@@ -0,0 +1,25 @@
import { ApiError } from '@pipu/core';
import { useTranslation } from 'react-i18next';
type Translate = (key: string, options?: Record<string, unknown>) => string;
/** Текст ошибки для пользователя: перевод `errors:<code>` с параметрами, иначе общий текст. */
export function useErrorText(): (err: unknown) => string {
const { t, i18n } = useTranslation();
const tr = t as unknown as Translate;
return (err) => {
const code = err instanceof ApiError ? err.code : 'common.internal';
const params = err instanceof ApiError ? err.params : {};
const key = `errors:${code}`;
return i18n.exists(key) ? tr(key, params) : tr('errors:common.internal');
};
}
/** Ошибка конкретного поля (если сервер её указал) — для подписи под полем. */
export function useFieldError(): (err: unknown, field: string) => string | undefined {
const text = useErrorText();
return (err, field) => {
if (!(err instanceof ApiError)) return undefined;
return err.fieldCode(field) ? text(err) : undefined;
};
}
+32
View File
@@ -0,0 +1,32 @@
/** Дата и время в локали интерфейса: «20 окт. 2026 г., 14:05». */
export function formatDateTime(iso: string, locale: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(iso));
}
const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
['year', 365 * 24 * 3600],
['month', 30 * 24 * 3600],
['week', 7 * 24 * 3600],
['day', 24 * 3600],
['hour', 3600],
['minute', 60],
];
/** Относительное время: «12 минут назад», «вчера», «сейчас». */
export function formatRelative(iso: string, locale: string, now: Date = new Date()): string {
const diff = (new Date(iso).getTime() - now.getTime()) / 1000;
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
for (const [unit, sec] of UNITS) {
if (Math.abs(diff) >= sec) return rtf.format(Math.round(diff / sec), unit);
}
return rtf.format(0, 'minute');
}
/** Длительность трека: 215000 → «3:35», 3725000 → «1:02:05». */
export function formatDuration(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = String(total % 60).padStart(2, '0');
return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
}
+23
View File
@@ -0,0 +1,23 @@
import type { components } from '@pipu/api-client';
export type Role = components['schemas']['Role'];
export type UserAction = 'reset_password' | 'block' | 'change_role' | 'delete';
/**
* Копия матрицы прав (docs/auth-and-roles.md, services/api/internal/auth/policy.go) — только для UI:
* какие действия показывать. Решает всё равно сервер.
*/
export function canManageUser(actor: { id: string; role: Role }, target: { id: string; role: Role }, action: UserAction) {
const self = actor.id === target.id;
if (self && action !== 'reset_password') return false;
if (actor.role === 'admin') return true;
if (actor.role === 'moderator') return (action === 'reset_password' || action === 'block') && target.role === 'user';
return false;
}
/** Роли, которые actor может выдать при создании. */
export function assignableRoles(actor: Role): Role[] {
if (actor === 'admin') return ['user', 'moderator', 'admin'];
if (actor === 'moderator') return ['user'];
return [];
}
+48
View File
@@ -0,0 +1,48 @@
// Иконки Material Symbols Rounded (Apache 2.0). Импортируются по одной — в сборку попадают только используемые.
export { default as addIcon } from '@material-symbols/svg-400/rounded/add.svg';
export { default as checkIcon } from '@material-symbols/svg-400/rounded/check.svg';
export { default as closeIcon } from '@material-symbols/svg-400/rounded/close.svg';
export { default as copyIcon } from '@material-symbols/svg-400/rounded/content_copy.svg';
export { default as groupIcon } from '@material-symbols/svg-400/rounded/group.svg';
export { default as groupFillIcon } from '@material-symbols/svg-400/rounded/group-fill.svg';
export { default as homeIcon } from '@material-symbols/svg-400/rounded/home.svg';
export { default as homeFillIcon } from '@material-symbols/svg-400/rounded/home-fill.svg';
export { default as lockResetIcon } from '@material-symbols/svg-400/rounded/lock_reset.svg';
export { default as logoutIcon } from '@material-symbols/svg-400/rounded/logout.svg';
export { default as moreIcon } from '@material-symbols/svg-400/rounded/more_horiz.svg';
export { default as musicIcon } from '@material-symbols/svg-400/rounded/music_note.svg';
export { default as playIcon } from '@material-symbols/svg-400/rounded/play_arrow-fill.svg';
export { default as auditIcon } from '@material-symbols/svg-400/rounded/receipt_long.svg';
export { default as auditFillIcon } from '@material-symbols/svg-400/rounded/receipt_long-fill.svg';
export { default as searchIcon } from '@material-symbols/svg-400/rounded/search.svg';
export { default as settingsIcon } from '@material-symbols/svg-400/rounded/settings.svg';
export { default as shieldIcon } from '@material-symbols/svg-400/rounded/shield_person.svg';
export { default as visibilityIcon } from '@material-symbols/svg-400/rounded/visibility.svg';
export { default as visibilityOffIcon } from '@material-symbols/svg-400/rounded/visibility_off.svg';
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 chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
export { default as libraryIcon } from '@material-symbols/svg-400/rounded/monitoring.svg';
export { default as libraryFillIcon } from '@material-symbols/svg-400/rounded/monitoring-fill.svg';
export { default as personIcon } from '@material-symbols/svg-400/rounded/person.svg';
export { default as refreshIcon } from '@material-symbols/svg-400/rounded/refresh.svg';
export { default as storageIcon } from '@material-symbols/svg-400/rounded/hard_drive.svg';
export { default as storageFillIcon } from '@material-symbols/svg-400/rounded/hard_drive-fill.svg';
export { default as pauseIcon } from '@material-symbols/svg-400/rounded/pause-fill.svg';
export { default as nextIcon } from '@material-symbols/svg-400/rounded/skip_next-fill.svg';
export { default as prevIcon } from '@material-symbols/svg-400/rounded/skip_previous-fill.svg';
export { default as likeIcon } from '@material-symbols/svg-400/rounded/favorite.svg';
export { default as likeFillIcon } from '@material-symbols/svg-400/rounded/favorite-fill.svg';
export { default as dislikeIcon } from '@material-symbols/svg-400/rounded/heart_broken.svg';
export { default as dislikeFillIcon } from '@material-symbols/svg-400/rounded/heart_broken-fill.svg';
export { default as volumeIcon } from '@material-symbols/svg-400/rounded/volume_up.svg';
export { default as volumeOffIcon } from '@material-symbols/svg-400/rounded/volume_off.svg';
export { default as shuffleIcon } from '@material-symbols/svg-400/rounded/shuffle.svg';
export { default as repeatIcon } from '@material-symbols/svg-400/rounded/repeat.svg';
export { default as repeatOneIcon } from '@material-symbols/svg-400/rounded/repeat_one.svg';
export { default as historyIcon } from '@material-symbols/svg-400/rounded/history.svg';
export { default as historyFillIcon } from '@material-symbols/svg-400/rounded/history-fill.svg';
+316
View File
@@ -0,0 +1,316 @@
.icon {
display: inline-block;
flex: none;
background-color: currentColor;
mask-repeat: no-repeat;
mask-position: center;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
-webkit-mask-size: contain;
}
.logo {
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text);
}
.logoDot {
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 44px;
padding: 0 18px;
border-radius: var(--radius-md);
font-weight: 600;
font-size: 14px;
white-space: nowrap;
transition:
background 0.15s,
box-shadow 0.15s,
opacity 0.15s;
}
.button:disabled {
opacity: 0.55;
cursor: default;
}
.button[aria-busy='true'] {
cursor: progress;
}
.primary {
background: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.primary:hover:not(:disabled) {
background: var(--accent2);
}
.secondary {
background: var(--surface3);
color: var(--text);
}
.secondary:hover:not(:disabled),
.ghost:hover:not(:disabled) {
background: var(--hover);
}
.ghost {
color: var(--text2);
}
.danger {
background: var(--danger-soft);
color: var(--danger);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.inputWrap {
position: relative;
display: flex;
}
.input {
width: 100%;
height: 48px;
padding: 0 14px;
border-radius: var(--radius-md);
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.inputWrap:has(.reveal) .input {
padding-right: 46px;
}
.mono {
font-family: var(--font-mono);
}
.invalid {
border-color: var(--danger);
}
.reveal {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 8px;
color: var(--text3);
}
.reveal:hover {
color: var(--text);
background: var(--hover);
}
.hint {
font-size: 12px;
color: var(--text3);
}
.error {
font-size: 12px;
color: var(--danger);
}
.formError {
padding: 10px 12px;
border-radius: 10px;
background: var(--danger-soft);
color: var(--danger);
font-size: 13px;
font-weight: 500;
}
.segmented {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: var(--surface2);
border: 1px solid var(--line);
}
.segmented button {
height: 32px;
padding: 0 14px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.segmented button[aria-pressed='true'] {
background: var(--accent);
color: #fff;
}
.overlay {
position: fixed;
inset: 0;
z-index: 50;
display: grid;
place-items: center;
padding: 16px;
background: var(--overlay);
animation: pipuIn 0.2s ease-out;
}
.dialog {
width: 440px;
max-width: 100%;
max-height: calc(100vh - 32px);
overflow: auto;
border-radius: var(--radius-xl);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
outline: none;
}
.dialogHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 22px 24px 0;
}
.dialogHead h2 {
margin: 0;
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
letter-spacing: -0.01em;
}
.dialogBody {
display: flex;
flex-direction: column;
gap: 14px;
padding: 18px 24px;
}
.dialogFoot {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 0 24px 22px;
}
.iconButton {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 10px;
color: var(--text2);
}
.iconButton:hover,
.iconButton[aria-expanded='true'] {
background: var(--hover);
color: var(--text);
}
.menuWrap {
position: relative;
}
.menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 40;
min-width: 220px;
padding: 6px;
border-radius: var(--radius-lg);
background: var(--elev);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
animation: pipuIn 0.15s ease-out;
}
.menuStart {
left: 0;
right: auto;
}
.menuItem {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
height: 38px;
padding: 0 10px;
border-radius: 8px;
font-size: 14px;
text-align: left;
color: var(--text);
outline: none;
}
.menuItem:hover,
.menuItem:focus-visible {
background: var(--hover);
}
.menuDanger {
color: var(--danger);
}
.avatar {
display: inline-grid;
place-items: center;
flex: none;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--violet));
color: #fff;
font-weight: 700;
}
+282
View File
@@ -0,0 +1,282 @@
import {
useEffect,
useId,
useRef,
useState,
type ButtonHTMLAttributes,
type InputHTMLAttributes,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from 'react';
import { useTranslation } from 'react-i18next';
import { closeIcon, visibilityIcon, visibilityOffIcon } from './icons';
import s from './ui.module.css';
const cx = (...c: (string | false | null | undefined)[]) => c.filter(Boolean).join(' ');
/** Иконка-маска: цвет берётся из currentColor. */
export function Icon({ src, size = 20, className }: { src: string; size?: number; className?: string }) {
return (
<span
aria-hidden
className={cx(s.icon, className)}
style={{ width: size, height: size, maskImage: `url("${src}")`, WebkitMaskImage: `url("${src}")` }}
/>
);
}
export function Logo({ size = 28 }: { size?: number }) {
const { t } = useTranslation();
return (
<span className={s.logo} style={{ fontSize: size * 0.68 }}>
<span className={s.logoDot} style={{ width: size, height: size }} aria-hidden />
{t('app.name')}
</span>
);
}
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
icon?: string;
busy?: boolean;
};
export function Button({ variant = 'secondary', icon, busy, className, children, disabled, ...rest }: ButtonProps) {
return (
<button
type="button"
{...rest}
disabled={disabled || busy}
aria-busy={busy || undefined}
className={cx(s.button, s[variant], className)}
>
{icon && <Icon src={icon} size={18} />}
{children}
</button>
);
}
type FieldProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> & {
label: string;
value: string;
onChange: (v: string) => void;
error?: string;
hint?: string;
mono?: boolean;
};
/** Поле ввода с подписью, ошибкой и (для паролей) кнопкой «показать». */
export function Field({ label, value, onChange, error, hint, mono, type = 'text', className, ...rest }: FieldProps) {
const { t } = useTranslation();
const id = useId();
const [shown, setShown] = useState(false);
const isPassword = type === 'password';
const note = error ?? hint;
return (
<div className={cx(s.field, className)}>
<label htmlFor={id} className={s.label}>
{label}
</label>
<div className={s.inputWrap}>
<input
id={id}
type={isPassword && shown ? 'text' : type}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-invalid={error ? true : undefined}
aria-describedby={note ? `${id}-note` : undefined}
className={cx(s.input, mono && s.mono, error && s.invalid)}
{...rest}
/>
{isPassword && (
<button
type="button"
className={s.reveal}
onClick={() => setShown((v) => !v)}
aria-label={shown ? t('form.hide_password') : t('form.show_password')}
>
<Icon src={shown ? visibilityOffIcon : visibilityIcon} size={18} />
</button>
)}
</div>
{note && (
<div id={`${id}-note`} className={error ? s.error : s.hint} role={error ? 'alert' : undefined}>
{note}
</div>
)}
</div>
);
}
/** Сегментированный переключатель. */
export function Segmented<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T;
options: { value: T; label: string }[];
onChange: (v: T) => void;
}) {
return (
<div className={s.segmented} role="group" aria-label={label}>
{options.map((o) => (
<button key={o.value} type="button" aria-pressed={value === o.value} onClick={() => onChange(o.value)}>
{o.label}
</button>
))}
</div>
);
}
export function FormError({ children }: { children?: ReactNode }) {
if (!children) return null;
return (
<div className={s.formError} role="alert">
{children}
</div>
);
}
/** Модальный диалог: стеклянная карточка поверх подложки, Esc и клик по подложке закрывают. */
export function Dialog({
title,
onClose,
children,
footer,
}: {
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
const { t } = useTranslation();
const ref = useRef<HTMLDivElement>(null);
const titleId = useId();
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();
};
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('keydown', onKey);
prev?.focus();
};
}, [onClose]);
return (
<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 className={s.dialogHead}>
<h2 id={titleId}>{title}</h2>
<button type="button" data-close className={s.iconButton} onClick={onClose} aria-label={t('form.close')}>
<Icon src={closeIcon} />
</button>
</div>
<div className={s.dialogBody}>{children}</div>
{footer && <div className={s.dialogFoot}>{footer}</div>}
</div>
</div>
);
}
export type MenuItem = { label: string; icon?: string; danger?: boolean; onSelect: () => void };
/** Выпадающее меню: открывается кнопкой-триггером, закрывается кликом снаружи и Esc. */
export function Menu({
label,
trigger,
items,
header,
align = 'end',
triggerClassName,
}: {
label: string;
trigger: ReactNode;
items: MenuItem[];
header?: ReactNode;
align?: 'start' | 'end';
triggerClassName?: string;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDown);
document.addEventListener('keydown', onKey);
ref.current?.querySelector<HTMLElement>('[role="menuitem"]')?.focus();
return () => {
document.removeEventListener('mousedown', onDown);
document.removeEventListener('keydown', onKey);
};
}, [open]);
const onMenuKey = (e: ReactKeyboardEvent) => {
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
e.preventDefault();
const els = [...(ref.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? [])];
const i = els.indexOf(document.activeElement as HTMLElement);
const next = e.key === 'ArrowDown' ? (i + 1) % els.length : (i - 1 + els.length) % els.length;
els[next]?.focus();
};
return (
<div className={s.menuWrap} ref={ref}>
<button
type="button"
className={triggerClassName ?? s.iconButton}
aria-label={label}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{trigger}
</button>
{open && (
<div className={cx(s.menu, align === 'start' && s.menuStart)} role="menu" aria-label={label} onKeyDown={onMenuKey}>
{header}
{items.map((it) => (
<button
key={it.label}
type="button"
role="menuitem"
className={cx(s.menuItem, it.danger && s.menuDanger)}
onClick={() => {
setOpen(false);
it.onSelect();
}}
>
{it.icon && <Icon src={it.icon} size={18} />}
{it.label}
</button>
))}
</div>
)}
</div>
);
}
export function Avatar({ name, size = 32 }: { name: string; size?: number }) {
const initials = name
.split(/[\s._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((p) => p[0]!.toUpperCase())
.join('');
return (
<span className={s.avatar} style={{ width: size, height: size, fontSize: size * 0.38 }} aria-hidden>
{initials || '?'}
</span>
);
}
export { cx };
+22 -110
View File
@@ -1,6 +1,8 @@
@import '@pipu/design-tokens/tokens.css';
@import '@fontsource/onest/400.css';
@import '@fontsource/onest/500.css';
@import '@fontsource/onest/600.css';
@import '@fontsource/onest/700.css';
@import '@fontsource/unbounded/600.css';
@import '@fontsource/jetbrains-mono/400.css';
@@ -23,6 +25,11 @@ body {
font-family: var(--font-body);
font-size: 15px;
-webkit-font-smoothing: antialiased;
color-scheme: dark;
}
:root[data-theme='light'] body {
color-scheme: light;
}
button {
@@ -34,118 +41,23 @@ button {
cursor: pointer;
}
.shell {
min-height: 100%;
display: flex;
flex-direction: column;
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
height: 60px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
@keyframes pipuIn {
from {
opacity: 0;
transform: translateY(6px);
}
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 600;
font-size: 19px;
letter-spacing: -0.02em;
}
.logo-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: radial-gradient(circle at 35% 30%, #9cc6ff, #2f80ff 55%, #3a1fa8);
box-shadow: 0 0 18px rgba(47, 128, 255, 0.55);
}
.langs {
display: flex;
gap: 2px;
padding: 3px;
border-radius: 10px;
background: var(--glass);
border: 1px solid var(--line);
}
.langs button {
height: 30px;
padding: 0 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
color: var(--text2);
}
.langs button[aria-pressed='true'] {
background: var(--accent-soft);
color: var(--text);
}
.main {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 16px;
}
.card {
width: 400px;
max-width: 100%;
display: flex;
flex-direction: column;
gap: 12px;
padding: 36px;
border-radius: var(--radius-xl);
background: var(--glass);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
}
.card h1 {
margin: 0;
font-family: var(--font-display);
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
}
.muted {
margin: 0;
color: var(--text2);
line-height: 1.5;
}
.status {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
border-radius: 10px;
font-size: 13px;
font-weight: 500;
}
.status[data-state='ok'] {
background: var(--ok-soft);
color: var(--ok);
}
.status[data-state='error'] {
background: var(--danger-soft);
color: var(--danger);
}
.status[data-state='pending'] {
background: var(--surface2);
color: var(--text2);
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
+218
View File
@@ -0,0 +1,218 @@
import type { components } from '@pipu/api-client';
type Me = components['schemas']['Me'];
type AdminUser = components['schemas']['AdminUser'];
type Role = components['schemas']['Role'];
export interface FakeUser extends AdminUser {
password: string;
}
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
});
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
let seq = 0;
const kino = { id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' };
const albumRef = { id: '00000000-0000-4000-8000-0000000000bb', title: 'Группа крови', year: 1988, cover_url: null, cover_colors: [] };
export const albumTracks = ['Группа крови', 'Закрой за мной дверь'].map((title, i) => ({
id: `00000000-0000-4000-8000-00000000010${i}`,
title,
duration_ms: 200000,
artists: [{ id: '00000000-0000-4000-8000-0000000000aa', name: 'Кино' }],
album: albumRef,
genres: [],
disc_no: 1,
track_no: i + 1,
available: true,
stream_url: `/api/v1/stream/00000000-0000-4000-8000-00000000010${i}?sig=x`,
reaction: null as 'like' | 'dislike' | null,
}));
const albumSummary = {
id: '00000000-0000-4000-8000-0000000000bb',
title: 'Группа крови',
year: 1988,
cover_url: null,
cover_colors: [],
artist: kino,
track_count: 11,
};
export function makeUser(username: string, role: Role, extra: Partial<FakeUser> = {}): FakeUser {
seq++;
return {
id: `00000000-0000-4000-8000-${String(seq).padStart(12, '0')}`,
username,
display_name: null,
role,
locale: 'ru',
is_blocked: false,
must_change_password: false,
must_change_credentials: false,
created_at: '2026-01-01T00:00:00Z',
last_login_at: null,
password: `${username}-password`,
...extra,
};
}
/**
* Мини-сервер API для компонентных тестов: вход, refresh по cookie, /me, админка пользователей.
* Проверки прав упрощены — точная матрица покрыта тестами Go.
*/
export function fakeApi(initial: FakeUser[]) {
const users = new Map(initial.map((u) => [u.id, { ...u }]));
const prefs = new Map<string, components['schemas']['UiPrefs']>();
let current: string | null = null; // id вошедшего (одна сессия на тест)
let token = 0;
const calls: { method: string; path: string; body: unknown }[] = [];
const reactions = new Map<string, 'like' | 'dislike'>();
const me = (u: FakeUser): Me => ({
id: u.id,
username: u.username,
display_name: u.display_name,
role: u.role,
locale: u.locale,
ui_prefs: prefs.get(u.id) ?? {},
must_change_password: u.must_change_password,
must_change_credentials: u.must_change_credentials,
created_at: u.created_at,
});
const tokens = (u: FakeUser) => json({ access_token: `T${++token}`, token_type: 'Bearer', expires_in: 900, user: me(u) });
const admin = (u: FakeUser): AdminUser => {
const { password, ...rest } = u;
void password;
return rest;
};
const fetchImpl = async (input: RequestInfo | URL): Promise<Response> => {
const req = input as Request;
const url = new URL(req.url);
const path = url.pathname;
const text = await req.text();
const body = text ? (JSON.parse(text) as Record<string, unknown>) : undefined;
calls.push({ method: req.method, path, body });
const route = `${req.method} ${path}`;
const user = current ? users.get(current) : undefined;
switch (route) {
case 'GET /api/v1/server-info':
return json({
name: 'pipu-music',
version: '0.2.0',
api_version: 'v1',
supported_locales: ['ru', 'en'],
min_android_version_code: 0,
bootstrap_required: [...users.values()].some((u) => u.must_change_credentials),
});
case 'POST /api/v1/auth/login': {
const u = [...users.values()].find((x) => x.username === body?.username && x.password === body?.password);
if (!u) return problem(401, 'auth.invalid_credentials');
current = u.id;
return tokens(u);
}
case 'POST /api/v1/auth/refresh':
return user ? tokens(user) : problem(401, 'auth.refresh_invalid');
}
if (!user || !req.headers.get('Authorization')) return problem(401, 'auth.unauthorized');
switch (route) {
case 'POST /api/v1/auth/logout':
case 'POST /api/v1/auth/logout-all':
current = null;
return new Response(null, { status: 204 });
case 'GET /api/v1/me':
return json(me(user));
case 'PATCH /api/v1/me': {
if (typeof body?.locale === 'string') user.locale = body.locale;
if (typeof body?.display_name === 'string') user.display_name = body.display_name || null;
if (body?.ui_prefs) prefs.set(user.id, { ...prefs.get(user.id), ...(body.ui_prefs as object) });
return json(me(user));
}
case 'POST /api/v1/me/bootstrap-credentials': {
const pw = String(body?.new_password ?? '');
if (pw.length < 12)
return problem(422, 'validation.password_too_short', {
params: { min: 12 },
errors: [{ field: 'new_password', code: 'validation.password_too_short' }],
});
Object.assign(user, { username: body?.new_username, password: pw, must_change_credentials: false });
return tokens(user);
}
case 'POST /api/v1/me/password':
Object.assign(user, { password: body?.new_password, must_change_password: false });
return tokens(user);
case 'GET /api/v1/me/sessions':
return json({ items: [] });
case 'GET /api/v1/admin/users': {
if (user.role === 'user') return problem(403, 'auth.forbidden');
const q = (url.searchParams.get('q') ?? '').toLowerCase();
const role = url.searchParams.get('role');
const items = [...users.values()]
.filter((u) => (!q || u.username.includes(q)) && (!role || u.role === role))
.sort((a, b) => a.username.localeCompare(b.username))
.map(admin);
return json({ items, next_cursor: null });
}
case 'POST /api/v1/admin/users': {
if (user.role === 'moderator' && body?.role !== 'user') return problem(403, 'auth.forbidden');
const u = makeUser(String(body?.username), body?.role as Role, {
display_name: (body?.display_name as string) ?? null,
must_change_password: true,
password: 'Temp-Pass-123',
});
users.set(u.id, u);
return json({ user: admin(u), temporary_password: u.password }, 201);
}
case 'GET /api/v1/admin/audit':
return json({ items: [], next_cursor: null });
case 'GET /api/v1/search/suggest':
case 'GET /api/v1/search': {
// «rbyj» — «кино» в английской раскладке: сервер находит и сообщает исправление.
const q = url.searchParams.get('q') ?? '';
const hit = q === 'кино' || q === 'rbyj';
return json({
artists: hit ? [kino] : [],
albums: hit ? [albumSummary] : [],
tracks: [],
totals: { artists: hit ? 1 : 0, albums: hit ? 1 : 0, tracks: 0 },
corrected_query: q === 'rbyj' ? 'кино' : null,
next_cursor: null,
});
}
case `GET /api/v1/albums/${albumSummary.id}`:
return json({
...albumRef,
artist: kino,
duration_ms: 400000,
tracks: albumTracks.map((t) => ({ ...t, reaction: reactions.get(t.id) ?? null })),
});
case 'POST /api/v1/events':
return json({ accepted: (body?.events as unknown[]).length, duplicates: 0, rejected: 0 });
case 'GET /api/v1/me/playback-state':
return json({ track_id: null, position_ms: 0, queue: [], queue_index: 0, context: null, shuffle: false,
repeat_mode: 'off', volume: 1, device_id: null, updated_at: null });
case 'PUT /api/v1/me/playback-state':
return json({ ...body, updated_at: new Date().toISOString() });
case `GET /api/v1/artists/${kino.id}`:
return json({ ...kino, album_count: 1, track_count: 0 });
case `GET /api/v1/artists/${kino.id}/albums`:
return json({ items: [albumSummary] });
}
const reactionPath = /^\/api\/v1\/me\/reactions\/tracks\/([^/]+)$/.exec(path);
if (reactionPath) {
if (req.method === 'PUT') reactions.set(reactionPath[1]!, body?.reaction as 'like' | 'dislike');
else reactions.delete(reactionPath[1]!);
return new Response(null, { status: 204 });
}
return problem(404, 'common.not_found');
};
return { fetch: fetchImpl as typeof fetch, calls, users, signOut: () => (current = null) };
}
+19
View File
@@ -0,0 +1,19 @@
import '@testing-library/jest-dom/vitest';
// jsdom не умеет играть звук: play/pause только переключают флаг и шлют события, как браузер.
Object.defineProperty(HTMLMediaElement.prototype, 'play', {
configurable: true,
value(this: HTMLMediaElement) {
Object.defineProperty(this, 'paused', { configurable: true, value: false });
this.dispatchEvent(new Event('playing'));
return Promise.resolve();
},
});
Object.defineProperty(HTMLMediaElement.prototype, 'pause', {
configurable: true,
value(this: HTMLMediaElement) {
Object.defineProperty(this, 'paused', { configurable: true, value: true });
this.dispatchEvent(new Event('pause'));
},
});
Object.defineProperty(HTMLMediaElement.prototype, 'load', { configurable: true, value() {} });
+182
View File
@@ -0,0 +1,182 @@
.shell {
display: grid;
grid-template-columns: 240px 1fr;
grid-template-rows: 60px 1fr 88px;
grid-template-areas:
'top top'
'side content'
'player player';
height: 100%;
}
.top {
grid-area: top;
display: flex;
align-items: center;
gap: 24px;
justify-content: space-between;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--bar-a);
backdrop-filter: blur(20px);
}
.logoLink {
width: 196px;
flex: none;
text-decoration: none;
}
@media (max-width: 1023px) {
.logoLink {
width: auto;
}
}
.topRight {
display: flex;
align-items: center;
gap: 12px;
}
.profileButton {
border-radius: 50%;
outline-offset: 3px;
}
.profileButton[aria-expanded='true'] {
box-shadow: 0 0 0 3px var(--accent-soft);
}
.profileHead {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 12px;
align-items: center;
padding: 10px 10px 12px;
margin-bottom: 6px;
border-bottom: 1px solid var(--line);
}
.profileHead > :last-child {
grid-column: 1 / -1;
margin-top: 10px;
justify-self: start;
}
.profileName {
font-weight: 600;
}
.profileMeta {
font-size: 12px;
color: var(--text3);
}
.side {
grid-area: side;
display: flex;
flex-direction: column;
gap: 2px;
padding: 16px 12px;
border-right: 1px solid var(--line);
overflow-y: auto;
}
.navSection {
margin: 18px 12px 6px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.navItem {
display: flex;
align-items: center;
gap: 12px;
height: 40px;
padding: 0 12px;
border-radius: 10px;
color: var(--text2);
font-weight: 500;
text-decoration: none;
}
.navItem:hover {
background: var(--hover);
color: var(--text);
}
.navItem[data-status='active'] {
background: var(--accent-soft);
color: var(--text);
}
.navIcon {
color: var(--text3);
}
.navItem[data-status='active'] .navIcon {
color: var(--accent2);
}
.content {
grid-area: content;
overflow-y: auto;
padding: 32px 36px;
animation: pipuIn 0.2s ease-out;
}
@media (max-width: 1023px) {
.shell {
grid-template-columns: 72px 1fr;
}
.navItem {
justify-content: center;
padding: 0;
font-size: 0;
}
.navSection {
margin: 14px 0 6px;
text-align: center;
font-size: 0;
border-top: 1px solid var(--line);
}
}
@media (max-width: 767px) {
.shell {
grid-template-columns: 1fr;
grid-template-rows: 60px 1fr 72px 64px;
grid-template-areas:
'top'
'content'
'player'
'side';
}
.side {
flex-direction: row;
justify-content: space-around;
padding: 6px 8px;
border-right: 0;
border-top: 1px solid var(--line);
}
.navSection {
display: none;
}
.navItem {
flex: 1;
}
.content {
padding: 20px 16px;
}
}
+132
View File
@@ -0,0 +1,132 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession, useUser } from '../app/session';
import {
auditFillIcon,
auditIcon,
groupFillIcon,
groupIcon,
homeFillIcon,
homeIcon,
libraryFillIcon,
libraryIcon,
logoutIcon,
historyFillIcon,
historyIcon,
likeFillIcon,
likeIcon,
dislikeFillIcon,
dislikeIcon,
settingsIcon,
storageFillIcon,
storageIcon,
} from '../shared/ui/icons';
import { Avatar, Icon, Logo, Menu } from '../shared/ui/ui';
import { LanguageSwitch } from './LanguageSwitch';
import { PlayerProvider } from '../player/context';
import { PlayerBar } from './PlayerBar';
import { SearchBox } from './SearchBox';
import s from './AppShell.module.css';
type NavPath =
| '/'
| '/admin/users'
| '/admin/audit'
| '/admin/sources'
| '/admin/library'
| '/collection/history'
| '/collection/likes'
| '/collection/dislikes';
function NavLink({ to, label, icon, activeIcon }: { to: NavPath; label: string; icon: string; activeIcon: string }) {
return (
<Link to={to} className={s.navItem} activeOptions={{ exact: to === '/' }}>
{({ isActive }) => (
<>
<Icon src={isActive ? activeIcon : icon} className={s.navIcon} />
{label}
</>
)}
</Link>
);
}
/** Между выходом и редиректом на /login каркас не рисуется: пользователя уже нет. */
export function AppShell() {
return useAuthState().status === 'signed_in' ? (
<PlayerProvider>
<Shell />
</PlayerProvider>
) : null;
}
function Shell() {
const { t } = useTranslation();
const user = useUser();
const session = useSession();
const navigate = useNavigate();
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
return (
<div className={s.shell}>
<header className={s.top}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
<SearchBox />
<div className={s.topRight}>
<Menu
label={t('profile.menu')}
triggerClassName={s.profileButton}
trigger={<Avatar name={name} />}
header={
<div className={s.profileHead}>
<Avatar name={name} size={40} />
<div>
<div className={s.profileName}>{name}</div>
<div className={s.profileMeta}>
@{user.username} · {t(`roles.${user.role}`)}
</div>
</div>
<LanguageSwitch />
</div>
}
items={[
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
]}
/>
</div>
</header>
<nav className={s.side} aria-label={t('nav.main')}>
<NavLink to="/" label={t('nav.home')} icon={homeIcon} activeIcon={homeFillIcon} />
<div className={s.navSection}>{t('nav.collection')}</div>
<NavLink to="/collection/history" label={t('nav.history')} icon={historyIcon} activeIcon={historyFillIcon} />
<NavLink to="/collection/likes" label={t('nav.likes')} icon={likeIcon} activeIcon={likeFillIcon} />
<NavLink to="/collection/dislikes" label={t('nav.dislikes')} icon={dislikeIcon} activeIcon={dislikeFillIcon} />
{staff && (
<>
<div className={s.navSection}>{t('nav.admin')}</div>
<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} />
{user.role === 'admin' && (
<NavLink to="/admin/audit" label={t('nav.audit')} icon={auditIcon} activeIcon={auditFillIcon} />
)}
</>
)}
</nav>
<main className={s.content}>
<Outlet />
</main>
<PlayerBar />
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
import { unwrap } from '@pipu/core';
import { SUPPORTED_LOCALES, type Locale } from '@pipu/i18n';
import { useTranslation } from 'react-i18next';
import { useAuthState, useSession } from '../app/session';
import { Segmented } from '../shared/ui/ui';
/** Переключатель языка. Для вошедшего пользователя язык сохраняется в профиле (PATCH /me). */
export function LanguageSwitch() {
const { t, i18n } = useTranslation();
const session = useSession();
const auth = useAuthState();
const change = async (l: Locale) => {
document.documentElement.lang = l;
await i18n.changeLanguage(l);
if (auth.status !== 'signed_in') return;
try {
session.setUser(unwrap(await session.client.PATCH('/api/v1/me', { body: { locale: l } })));
} catch {
// Язык уже переключён локально; сохранится при следующей смене.
}
};
return (
<Segmented
label={t('language.label')}
value={(SUPPORTED_LOCALES as readonly string[]).includes(i18n.language) ? (i18n.language as Locale) : 'en'}
options={SUPPORTED_LOCALES.map((l) => ({ value: l, label: l.toUpperCase() }))}
onChange={(l) => void change(l)}
/>
);
}
+194
View File
@@ -0,0 +1,194 @@
.bar {
grid-area: player;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.3fr) minmax(0, 1fr);
align-items: center;
gap: 20px;
padding: 0 20px;
border-top: 1px solid var(--line);
background: var(--bar-a);
backdrop-filter: blur(20px);
}
.empty {
display: flex;
align-items: center;
gap: 14px;
color: var(--text3);
font-size: 14px;
}
.placeholder {
display: grid;
place-items: center;
width: 52px;
height: 52px;
border-radius: 10px;
background: var(--surface2);
}
.now {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.meta {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
}
.title {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.artists {
font-size: 13px;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
display: grid;
place-items: center;
flex: none;
width: 36px;
height: 36px;
border-radius: 50%;
color: var(--text2);
}
.icon:hover {
color: var(--text);
background: var(--hover);
}
.accent {
color: var(--accent2);
}
.danger {
color: var(--danger);
}
.center {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 0;
}
.controls {
display: flex;
align-items: center;
gap: 10px;
}
.play {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 50%;
background: #fff;
color: #05070d;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3);
transition: transform 0.12s;
}
.play:hover {
transform: scale(1.06);
}
.play[aria-busy='true'] {
opacity: 0.7;
}
.progress {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
max-width: 560px;
}
.time {
font-size: 12px;
color: var(--text3);
font-variant-numeric: tabular-nums;
min-width: 38px;
text-align: center;
}
.range {
flex: 1;
height: 4px;
appearance: none;
border-radius: 2px;
background: linear-gradient(90deg, var(--accent2) var(--pct, 0%), var(--surface3) var(--pct, 0%));
cursor: pointer;
}
.range::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
border-radius: 50%;
background: #fff;
opacity: 0;
transition: opacity 0.15s;
}
.range:hover::-webkit-slider-thumb,
.range:focus-visible::-webkit-slider-thumb {
opacity: 1;
}
.range::-moz-range-thumb {
width: 12px;
height: 12px;
border: 0;
border-radius: 50%;
background: #fff;
}
.error {
font-size: 12px;
color: var(--danger);
}
.right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
}
.volume {
flex: 0 1 120px;
}
@media (max-width: 767px) {
.bar {
grid-template-columns: minmax(0, 1fr) auto;
padding: 0 12px;
gap: 10px;
}
.progress,
.right,
.controls > :first-child,
.controls > :last-child,
.now > button {
display: none;
}
}
+162
View File
@@ -0,0 +1,162 @@
import type { CSSProperties } from 'react';
import { useTranslation } from 'react-i18next';
import { ArtistLinks, Cover } from '../entities/catalog';
import { usePlayer, usePlayerState } from '../player/context';
import { formatDuration } from '../shared/lib/format';
import {
dislikeFillIcon,
dislikeIcon,
likeFillIcon,
likeIcon,
musicIcon,
nextIcon,
pauseIcon,
playIcon,
prevIcon,
repeatIcon,
repeatOneIcon,
shuffleIcon,
volumeIcon,
volumeOffIcon,
} from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './PlayerBar.module.css';
/** Плеер внизу экрана (вид `bar`, docs/design.md). */
export function PlayerBar() {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const track = st.track;
if (!track) {
return (
<footer className={s.bar} aria-label={t('player.label')}>
<span className={s.empty}>
<span className={s.placeholder}>
<Icon src={musicIcon} size={22} />
</span>
{t('player.empty')}
</span>
</footer>
);
}
const react = (r: 'like' | 'dislike') =>
void player.react(track, track.reaction === r ? null : r).catch(() => undefined);
const repeatLabel = t(`player.repeat_${st.repeat}`);
return (
<footer className={s.bar} aria-label={t('player.label')}>
<div className={s.now}>
<Cover album={track.album} size={52} />
<div className={s.meta}>
<span className={s.title}>{track.title}</span>
<span className={s.artists}>
<ArtistLinks artists={track.artists} />
</span>
</div>
<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={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')}
>
<Icon src={track.reaction === 'like' ? likeFillIcon : likeIcon} />
</button>
</div>
<div className={s.center}>
<div className={s.controls}>
<button
type="button"
className={cx(s.icon, st.shuffle && s.accent)}
aria-label={t('player.shuffle')}
aria-pressed={st.shuffle}
onClick={() => player.toggleShuffle()}
>
<Icon src={shuffleIcon} size={20} />
</button>
<button type="button" className={s.icon} aria-label={t('player.prev')} onClick={() => player.prev()}>
<Icon src={prevIcon} size={26} />
</button>
<button
type="button"
className={s.play}
aria-label={t(st.playing ? 'player.pause' : 'player.play')}
aria-busy={st.loading || undefined}
onClick={() => player.toggle()}
>
<Icon src={st.playing ? pauseIcon : playIcon} size={28} />
</button>
<button type="button" className={s.icon} aria-label={t('player.next')} onClick={() => player.next()}>
<Icon src={nextIcon} size={26} />
</button>
<button
type="button"
className={cx(s.icon, st.repeat !== 'off' && s.accent)}
aria-label={repeatLabel}
title={repeatLabel}
onClick={() => player.cycleRepeat()}
>
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
</div>
<div className={s.progress}>
<span className={s.time}>{formatDuration(st.positionMs)}</span>
<input
type="range"
className={s.range}
min={0}
max={st.durationMs || track.duration_ms}
step={1000}
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))}
style={{ '--pct': `${(st.positionMs / (st.durationMs || track.duration_ms)) * 100}%` } as CSSProperties}
/>
<span className={s.time}>{formatDuration(st.durationMs || track.duration_ms)}</span>
</div>
{st.error && (
<span className={s.error} role="status">
{t(st.error as 'player.error')}
</span>
)}
</div>
<div className={s.right}>
<button
type="button"
className={s.icon}
aria-label={t(st.muted ? 'player.unmute' : 'player.mute')}
onClick={() => player.toggleMute()}
>
<Icon src={st.muted || st.volume === 0 ? volumeOffIcon : volumeIcon} size={20} />
</button>
<input
type="range"
className={cx(s.range, s.volume)}
min={0}
max={1}
step={0.01}
value={st.muted ? 0 : st.volume}
aria-label={t('player.volume')}
onChange={(e) => player.setVolume(Number(e.target.value))}
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
</footer>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { useTranslation } from 'react-i18next';
import { Button } from '../shared/ui/ui';
import s from '../pages/page.module.css';
/** Непредвиденная ошибка рендера страницы: короткое сообщение и перезагрузка. */
export function RouteError() {
const { t } = useTranslation(['common', 'errors']);
return (
<div className={s.empty} role="alert">
<p>{t('errors:common.internal')}</p>
<Button onClick={() => window.location.reload()}>{t('route_error.reload')}</Button>
</div>
);
}
+146
View File
@@ -0,0 +1,146 @@
.box {
position: relative;
flex: 1;
max-width: 520px;
}
.icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: var(--text3);
pointer-events: none;
}
.input {
width: 100%;
height: 40px;
padding: 0 40px 0 42px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
}
.input:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.input::-webkit-search-cancel-button {
display: none;
}
.kbd {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
display: grid;
place-items: center;
min-width: 22px;
height: 22px;
border-radius: 6px;
border: 1px solid var(--line2);
color: var(--text3);
font: 12px var(--font-mono);
pointer-events: none;
}
.input:focus ~ .kbd {
display: none;
}
.popover {
position: absolute;
left: 0;
right: 0;
top: calc(100% + 8px);
z-index: 45;
max-height: min(70vh, 560px);
overflow-y: auto;
padding: 8px;
border-radius: var(--radius-lg);
background: var(--elev);
border: 1px solid var(--line2);
box-shadow: var(--shadow);
animation: pipuIn 0.15s ease-out;
}
.corrected {
margin: 4px 10px 8px;
font-size: 13px;
color: var(--warn);
}
.groupTitle {
margin: 8px 10px 4px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.option {
display: flex;
align-items: center;
gap: 12px;
padding: 6px 10px;
border-radius: 10px;
cursor: pointer;
}
.active {
background: var(--hover);
}
.optionText {
display: flex;
flex-direction: column;
min-width: 0;
}
.optionTitle {
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionSub {
font-size: 12px;
color: var(--text3);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nothing {
padding: 16px 10px;
color: var(--text3);
text-align: center;
}
.all {
width: 100%;
height: 38px;
margin-top: 6px;
border-radius: 10px;
color: var(--accent2);
font-weight: 600;
font-size: 14px;
}
.all:hover {
background: var(--hover);
}
@media (max-width: 767px) {
.kbd {
display: none;
}
}
+217
View File
@@ -0,0 +1,217 @@
import { unwrap } from '@pipu/core';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { useNavigate } from '@tanstack/react-router';
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { formatDuration } from '../shared/lib/format';
import { searchIcon } from '../shared/ui/icons';
import { cx, Icon } from '../shared/ui/ui';
import s from './SearchBox.module.css';
const DEBOUNCE_MS = 150;
type Item = {
key: string;
group: 'artists' | 'albums' | 'tracks';
title: string;
subtitle: string;
cover: { id: string; cover_url?: string | null; cover_colors?: string[] };
round?: boolean;
go: () => void;
};
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
export function SearchBox() {
const { t } = useTranslation();
const session = useSession();
const navigate = useNavigate();
const listId = useId();
const inputRef = useRef<HTMLInputElement>(null);
const [q, setQ] = useState('');
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
return () => clearTimeout(id);
}, [q]);
// «/» или Ctrl+K — фокус в поиск (если пользователь не печатает в другом поле).
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
const target = e.target as HTMLElement;
const typing = target.closest('input, textarea, select, [contenteditable="true"]');
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey))) {
e.preventDefault();
inputRef.current?.focus();
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, []);
const suggest = useQuery({
queryKey: ['suggest', debounced],
enabled: debounced !== '',
placeholderData: keepPreviousData,
// signal отменяет устаревший запрос, когда пользователь печатает дальше.
queryFn: async ({ signal }) =>
unwrap(await session.client.GET('/api/v1/search/suggest', { params: { query: { q: debounced } }, signal })),
staleTime: 30_000,
});
const data = debounced ? suggest.data : undefined;
const close = () => {
setOpen(false);
setActive(-1);
};
const goSearch = () => {
const query = q.trim();
if (!query) return;
close();
inputRef.current?.blur();
void navigate({ to: '/search', search: { q: query } });
};
const items: Item[] = [];
if (data) {
for (const a of data.artists)
items.push({
key: `ar-${a.id}`,
group: 'artists',
title: a.name,
subtitle: t('search.artist_kind'),
cover: { id: a.id },
round: true,
go: () => void navigate({ to: '/artist/$id', params: { id: a.id } }),
});
for (const al of data.albums)
items.push({
key: `al-${al.id}`,
group: 'albums',
title: al.title,
subtitle: [al.artist.name, al.year].filter(Boolean).join(' · '),
cover: al,
go: () => void navigate({ to: '/album/$id', params: { id: al.id } }),
});
for (const tr of data.tracks)
items.push({
key: `tr-${tr.id}`,
group: 'tracks',
title: tr.title,
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
cover: tr.album,
// Воспроизведение — M3; пока трек открывает свой альбом.
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
});
}
const choose = (i: number) => {
const it = items[i];
if (!it) return goSearch();
close();
setQ('');
inputRef.current?.blur();
it.go();
};
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
setOpen(true);
setActive((i) => Math.min(items.length - 1, i + 1));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive((i) => Math.max(-1, i - 1));
} else if (e.key === 'Enter') {
e.preventDefault();
choose(active);
} else if (e.key === 'Escape') {
close();
inputRef.current?.blur();
}
};
const showList = open && debounced !== '' && data !== undefined;
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
const optionId = (i: number) => `${listId}-${i}`;
return (
<div className={s.box}>
<Icon src={searchIcon} size={18} className={s.icon} />
<input
ref={inputRef}
type="search"
className={s.input}
value={q}
placeholder={t('search.placeholder')}
aria-label={t('search.label')}
role="combobox"
aria-expanded={showList}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={showList && active >= 0 ? optionId(active) : undefined}
onChange={(e) => {
setQ(e.target.value);
setOpen(true);
setActive(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(close, 120)}
onKeyDown={onKeyDown}
/>
<kbd className={s.kbd} aria-hidden>
/
</kbd>
{showList && (
<div className={s.popover}>
{data.corrected_query && (
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
)}
<div id={listId} role="listbox" aria-label={t('search.label')}>
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
{groups.map((g) => {
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
if (groupItems.length === 0) return null;
return (
<div key={g} role="group" aria-label={t(`search.${g}`)}>
<div className={s.groupTitle}>{t(`search.${g}`)}</div>
{groupItems.map(({ it, i }) => (
<div
key={it.key}
id={optionId(i)}
role="option"
aria-selected={i === active}
className={cx(s.option, i === active && s.active)}
onMouseDown={(e) => e.preventDefault()}
onMouseEnter={() => setActive(i)}
onClick={() => choose(i)}
>
<Cover album={it.cover} size={36} round={it.round} />
<span className={s.optionText}>
<span className={s.optionTitle}>{it.title}</span>
<span className={s.optionSub}>{it.subtitle}</span>
</span>
</div>
))}
</div>
);
})}
</div>
<button
type="button"
className={s.all}
onMouseDown={(e) => e.preventDefault()}
onClick={goSearch}
>
{t('search.show_all')}
</button>
</div>
)}
</div>
);
}
+1
View File
@@ -19,5 +19,6 @@ export default defineConfig({
},
test: {
environment: 'jsdom',
setupFiles: ['src/test/setup.ts'],
},
});
+10
View File
@@ -4,6 +4,9 @@
# PIPU_VERSION проставляет update.sh по git-тегу — вручную не менять
PIPU_VERSION=dev
PIPU_DATA=/srv/pipu
# Папка с музыкой на локальном диске сервера (источники type=local), например смонтированный диск.
# Пусто — $PIPU_DATA/library-host. NFS-хранилища сюда не относятся: их подключает админка.
LIBRARY_HOST=
# --- Домен и HTTPS (docs/install-guide.md) ---
# Вариант А: встроенный Caddy с Let's Encrypt — оставьте строку COMPOSE_FILE и заполните DOMAIN, ACME_EMAIL.
@@ -17,6 +20,13 @@ HTTP_PORT=80
POSTGRES_PASSWORD=
MEILI_MASTER_KEY=
INTERNAL_TOKEN=
# Ключ подписи access-токенов (Ed25519, base64 от 32 случайных байт). install.sh и update.sh
# создают его, если строки нет. Смена ключа разлогинит всех на ≤15 мин — старый можно оставить
# в JWT_PREVIOUS_KEYS (через запятую), пока не истекут выданные им токены.
JWT_PRIVATE_KEY=
# Подпись ссылок на обложки и аудио (base64 от 32 байт); создают install.sh / update.sh.
STREAM_SIGNING_KEY=
#JWT_PREVIOUS_KEYS=
# --- Первый администратор (необязательно; иначе пароль генерируется и выводится в лог api один раз) ---
#BOOTSTRAP_ADMIN_USERNAME=admin
+6
View File
@@ -45,6 +45,12 @@ services:
environment:
DATABASE_URL: postgres://pipu:pipu@postgres:5432/pipu?sslmode=disable
REDIS_URL: redis://redis:6379/0
MEILI_URL: http://meilisearch:7700
MEILI_MASTER_KEY: dev-master-key-change-me
INTERNAL_TOKEN: dev-internal-token
COOKIE_SECURE: "false"
X_ACCEL: "false"
CACHE_DIR: /tmp/pipu-cache
LOG_LEVEL: debug
ports: ["127.0.0.1:8080:8080"]
depends_on:
+13
View File
@@ -17,6 +17,15 @@ x-library-ro: &library-ro
read_only: true
bind: { propagation: rslave }
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
# Всем сервисам, включая storage-agent, — только чтение: импорт загрузок ещё не реализован,
# запись откроем вместе с ним (docs/library-and-storage.md).
x-library-host-ro: &library-host-ro
type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host
read_only: true
x-restart: &restart
restart: unless-stopped
@@ -31,6 +40,7 @@ services:
ports: ["${HTTP_PORT:-80}:80"]
volumes:
- *library-ro
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache:ro
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
depends_on: [api]
@@ -48,6 +58,7 @@ services:
DATABASE_URL: postgres://pipu:${POSTGRES_PASSWORD:?заполните POSTGRES_PASSWORD в .env (sudo ./install.sh)}@postgres:5432/pipu?sslmode=disable
volumes:
- *library-ro
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/cache:/cache
- ${PIPU_DATA:-/srv/pipu}/updates:/updates
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads
@@ -75,6 +86,7 @@ services:
source: ${PIPU_DATA:-/srv/pipu}/library
target: /library
bind: { propagation: rshared }
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender:
@@ -102,6 +114,7 @@ services:
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- *library-ro
- *library-host-ro
- ${PIPU_DATA:-/srv/pipu}/models:/models
deploy: { resources: { limits: { cpus: "4" } } }
depends_on:
Regular → Executable
+30 -1
View File
@@ -17,6 +17,16 @@ mkdir -p "$DATA"/{library,library-host,cache/transcode,cache/covers,postgres,mei
chown 65532:65532 "$DATA"/{cache,cache/transcode,cache/covers,updates,uploads}
chown 10001:10001 "$DATA/models"
# Папка с локальной музыкой: sudo LIBRARY_HOST=/mnt/music ./install.sh (или LIBRARY_HOST в .env)
LIBRARY_HOST="${LIBRARY_HOST:-$( [ -f .env ] && sed -n 's/^LIBRARY_HOST=//p' .env || true )}"
if [ -n "$LIBRARY_HOST" ]; then
if [ ! -d "$LIBRARY_HOST" ]; then
echo "LIBRARY_HOST=$LIBRARY_HOST: каталога нет — смонтируйте диск с музыкой (docs/install-guide.md, шаг 4)" >&2
exit 1
fi
findmnt -n --target "$LIBRARY_HOST" -o TARGET,SOURCE | sed 's/^/→ музыка: /'
fi
# storage-agent монтирует NFS внутрь $DATA/library; чтобы точки монтирования видели другие контейнеры,
# каталог должен быть отдельной точкой монтирования с shared propagation.
if ! findmnt -n "$DATA/library" >/dev/null 2>&1; then
@@ -50,10 +60,29 @@ secret() { head -c 32 /dev/urandom | base64 | tr -d '/+=' | head -c 40; }
if [ ! -f .env ]; then
echo "→ .env из .env.example"
cp .env.example .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|" .env
sed -i "s|^POSTGRES_PASSWORD=.*|POSTGRES_PASSWORD=$(secret)|; s|^MEILI_MASTER_KEY=.*|MEILI_MASTER_KEY=$(secret)|; s|^INTERNAL_TOKEN=.*|INTERNAL_TOKEN=$(secret)|; s|^PIPU_DATA=.*|PIPU_DATA=$DATA|; s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
chmod 600 .env
chown "$OWNER" .env
echo " Заполните в .env: DOMAIN и ACME_EMAIL (или настройте свой прокси — docs/install-guide.md)"
fi
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
# в том числе в .env, сделанном до их появления.
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
if ! grep -q "^$key=." .env; then
sed -i "/^$key=\$/d" .env
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
echo "→ в .env добавлен $key"
fi
done
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
if [ -n "$LIBRARY_HOST" ]; then
if grep -q '^LIBRARY_HOST=' .env; then
sed -i "s|^LIBRARY_HOST=.*|LIBRARY_HOST=$LIBRARY_HOST|" .env
else
echo "LIBRARY_HOST=$LIBRARY_HOST" >> .env
fi
fi
echo "Готово. Сборка и запуск последнего релиза: ./update.sh"
+1
View File
@@ -48,6 +48,7 @@ server {
# Аудио и обложки отдаёт nginx по заголовку X-Accel-Redirect от api (docs/playback.md).
location /_internal/library/ { internal; alias /library/; sendfile on; aio threads; }
location /_internal/library-host/ { internal; alias /library-host/; sendfile on; aio threads; }
location /_internal/cache/ { internal; alias /cache/transcode/; sendfile on; }
location /_internal/covers/ { internal; alias /cache/covers/; expires 30d; }
location /_internal/uploads/ { internal; alias /uploads/; sendfile on; }
Regular → Executable
+12 -1
View File
@@ -16,6 +16,16 @@ cd "$PIPU_DEPLOY_DIR"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
# Ключи подписи (токены — M1, ссылки на обложки и аудио — M2): создаются один раз,
# в том числе в .env, сделанном до их появления.
for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
if ! grep -q "^$key=." .env; then
sed -i "/^$key=\$/d" .env
echo "$key=$(head -c 32 /dev/urandom | base64)" >> .env
echo "→ в .env добавлен $key"
fi
done
REF="${1:-}"
git fetch --tags --prune --quiet origin
@@ -62,7 +72,8 @@ docker image prune -f >/dev/null
echo "→ ждём готовности api"
for _ in $(seq 1 60); do
if docker compose exec -T nginx wget -qO- http://localhost/readyz >/dev/null 2>&1; then
# 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
echo "pipu $VERSION запущен"
exit 0
fi
+1 -1
View File
@@ -114,7 +114,7 @@ pipu-music/
├── tests/
│ ├── e2e/ # Playwright
│ └── fixtures/music/ # генерируемые тестовые аудиофайлы
├── .gitea/workflows/ # ci.yml, release.yml
├── .gitea/workflows/ # ci-*.yml, docker.yml (по областям), release.yml
├── docs/
├── turbo.json pnpm-workspace.yaml package.json
└── README.md
+9 -3
View File
@@ -55,7 +55,7 @@
- argon2id: `m=64 MiB, t=3, p=2`, соль 16 байт; параметры в хэше (PHC), при изменении — rehash при входе.
- Политика: ≥ 8 символов для `user`, ≥ 12 для `moderator`/`admin`; не совпадает с логином; проверка по
встроенному списку 10k самых частых паролей.
встроенному списку частых паролей (`internal/auth/common_passwords.txt`; пока ~150 записей, цель — 10k).
- Временный пароль при создании пользователя и при сбросе генерирует сервер: 12 символов, показывается **один раз**;
у пользователя ставится `must_change_password = true`.
- Защита от перебора: после 5 неудачных попыток — `locked_until = now() + 5 мин` (растёт экспоненциально до 1 ч),
@@ -73,10 +73,16 @@
| Android | в памяти | `expo-secure-store` |
- **Ротация**: каждый `/auth/refresh` помечает старый токен `used_at` и выдаёт новый в той же `family_id`.
Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи.
Повторное предъявление уже использованного токена ⇒ компрометация ⇒ отзыв всей семьи
(`401 auth.refresh_reused`). Исключение — первые 10 с после ротации: так бывает, когда две вкладки
обновляют токен одновременно; ответ `401 auth.refresh_race` без отзыва, клиент повторяет запрос
с уже новой cookie.
- **Мгновенный отзыв** (блокировка, смена роли, смена пароля): у пользователя инкрементируется `token_version`
(в Redis-кэше), middleware сравнивает с `ver` в access-токене.
- Ключи подписи: `JWT_PRIVATE_KEY` из env / файла; поддержка `kid` для ротации ключей.
- Ключи подписи: `JWT_PRIVATE_KEY` (base64 seed Ed25519) из env; `kid` = первые 8 байт SHA-256 публичного ключа,
прежние ключи для проверки — `JWT_PREVIOUS_KEYS`.
- Смена пароля и учётных данных отзывает все сессии и сразу выдаёт новую пару текущему устройству —
пользователь остаётся в системе, остальные устройства разлогиниваются.
- CSRF: refresh-cookie `SameSite=Strict` и путь только `/api/v1/auth`; остальные запросы — Bearer-заголовок,
поэтому CSRF к ним неприменим.
+86 -32
View File
@@ -17,28 +17,43 @@ C++-сборка React Native). APK собирается только под `ar
(тесты — `pnpm test:scripts`).
- Changelog — `git-cliff` из Conventional Commits.
## `ci.yml` — на каждый push и PR
## CI — на каждый push в `main` и PR
```yaml
on:
push: { branches: [main] }
pull_request:
Проверки разбиты на workflow по областям, у каждого фильтр путей (`on.push.paths`): запускается только то,
что затронуто коммитом. Коммиты только в `docs/` и `*.md` CI не запускают вовсе.
jobs:
changes: # фильтр путей: api / recommender / web / mobile / packages / docs
contract: # spectral lint openapi.yaml; регенерация Go + TS клиентов; git diff --exit-code
api: # go vet, golangci-lint, go test -race ./... (testcontainers), покрытие
recommender: # uv sync; ruff; mypy --strict; pytest (вкл. оффлайн-оценку качества)
ts: # pnpm install --frozen-lockfile; turbo lint typecheck test; i18n:check
web-build: # vite build, проверка размера бандла
docker: # сборка всех Docker-образов без push (сервер собирает их сам — ADR-013)
mobile-check: # tsc, jest, expo prebuild + ./gradlew assembleDebug (только если менялся mobile/)
e2e: # needs: api, web-build; docker compose -f deploy/compose.test.yaml up; playwright test
```
| Workflow | Job | Что делает | Когда |
|---|---|---|---|
| `ci-api.yml` | `api` | gofmt, go vet, `go test -race`, сборка | `services/api/**`, `api/openapi.yaml` |
| `ci-contract.yml` | `contract` | регенерация Go-сервера, TS-клиента и токенов; всё ли закоммичено | `openapi.yaml`, `gen/`, `packages/api-client`, `packages/design-tokens`, общие файлы JS |
| `ci-recommender.yml` | `recommender` | ruff, mypy, pytest | `services/recommender/**` |
| `ci-ts.yml` | `ts` | i18n, тесты скриптов, typecheck (вкл. mobile), vitest, сборка веба | `apps/**`, `packages/**`, `scripts/**`, общие файлы JS |
| `ci-mobile.yml` | `mobile` | `expo-doctor`, Metro-бандл Android | `apps/mobile/**`, `packages/**`, общие файлы JS |
| `docker.yml` | `docker` | сборка всех 4 образов без push | `Dockerfile`, `.dockerignore`, `deploy/**`, манифесты зависимостей |
- Кэши: Go modules / build cache, pnpm store, uv cache, Gradle, сгенерированные тестовые аудио.
- Ветка `main` защищена: обязательны `contract`, `api`, `recommender`, `ts`, `e2e`.
- Ночью (`schedule`) — Maestro E2E на эмуляторе и полный прогон без фильтров путей.
Общие файлы JS: `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json`, `tsconfig.base.json`.
Изменение самого workflow запускает его. У каждого workflow своя группа `concurrency`: новый push в ту же
ветку отменяет устаревший прогон.
`docker` собирает образы обычным `docker build` через Docker хоста раннера, а не в buildx-контейнере:
кэш слоёв переживает job, и повторная сборка занимает секунды. Кэш растёт — на хосте раннера раз в сутки:
`docker builder prune -af --filter until=72h` (см. [настройку раннера](#настройка-раннера)).
### Кэши
| Что | Где кэшируется | Чем |
|---|---|---|
| pnpm store | все job с `pnpm install` | `setup-node` `cache: pnpm` (кэш actions раннера) |
| Go-модули и build cache | `api`, `contract` | `setup-go` |
| uv | `recommender` | `setup-uv` `enable-cache` |
| Результаты turbo (typecheck, test, build) | `ts` | `actions/cache` для `.turbo/cache` |
| `npx expo-doctor`, трансформации Metro | `mobile` | `actions/cache` для `~/.npm/_npx` и `/tmp/metro-cache` |
| Слои Docker и `--mount=type=cache` | `docker`, сборка на сервере | Docker хоста; манифесты копируются раньше кода |
| Gradle (дистрибутив, зависимости, build cache), Android SDK и NDK | `android` в `release.yml` | тома `/opt/ci-cache/*` |
| Node, Go, Java, скачанные setup-action'ами | все job | том `act-toolcache` раннера |
Кэш actions работает, только если он включён на раннере ([настройка](#настройка-раннера)); без него в логах
`Cache action is only supported on GHES…`, и всё скачивается заново.
## `release.yml` — по тегу
@@ -48,30 +63,69 @@ on:
tags: ['v*.*.*']
jobs:
verify: # все проверки ci.yml (reusable workflow)
android-release: # needs: verify
# setup-java 17, Android SDK, pnpm install
# VERSION / VERSION_CODE из тега → app.config.ts
# expo prebuild --platform android --clean
# ./gradlew assembleRelease (signingConfig из секретов)
# apksigner verify; sha256sum > pipu-music-${VERSION}.apk.sha256
# changelog из Conventional Commits между тегами
# создать Gitea Release через API (POST /repos/{owner}/{repo}/releases), prerelease по суффиксу
# загрузить ассеты: APK и .sha256
release: # версия из тега (scripts/version.mjs) и сразу Gitea Release (scripts/release.mjs create)
android: # needs: release — собирает APK и докладывает его в релиз
```
Тесты в релизе не повторяются: тег ставится на коммит, который CI уже проверил в `main`. **Ставьте тег
после зелёного CI** — релиз сам этого не проверяет.
Релиз появляется в первую же минуту, не дожидаясь APK. Текст строит `scripts/release.mjs`:
- **статус Android** вверху: «⏳ собирается» со ссылкой на ход сборки → «✅ собран» (имя файла, размер,
versionCode, SHA-256; предупреждение, если подпись отладочная) или «❌ не удалась» со ссылкой на лог;
- **изменения** — коммиты (Conventional Commits) с прошлого тега по разделам «Новое», «Исправления»,
«Сборка и CI» и т. д., отдельно — несовместимые (`feat!:`); для финального релиза отсчёт от прошлого
финального, а не от последнего `-rc`; ссылка на полный diff;
- **обновление сервера** — бэкап, `./update.sh vX.Y.Z`, проверка версии; **установка APK** и проверка SHA-256.
Job `android`: Android SDK, `expo prebuild`, `./gradlew assembleRelease --build-cache` (только arm64-v8a), подпись `apksigner`,
затем `release.mjs android success <apk>` прикладывает APK и `.sha256` и меняет статус. При любом сбое —
`release.mjs android failure`. Перезапуск job безопасен: статус снова «собирается», файлы заменяются.
Перезапуск всего workflow обновляет текст существующего релиза, а не создаёт второй.
Docker-образы **не публикуются**: сервер собирает их сам из клонированного репозитория (ADR-013).
Чтобы сборка на сервере не ломалась, job `docker` в `ci.yml` собирает все образы без push на каждый PR.
## Настройка раннера
Раннер — `act_runner` на отдельной VM. Что ускоряет CI/CD (`config.yaml` раннера, затем перезапуск сервиса):
```yaml
runner:
capacity: 2 # два job параллельно; только если у VM ≥ 8 ГБ RAM (иначе APK-сборка упирается в OOM)
cache:
enabled: true # кэш actions/cache: pnpm store, Go-модули, uv
host: 10.10.1.x # IP хоста раннера, доступный из job-контейнеров (не 127.0.0.1)
port: 8088 # фиксированный порт; открыть его для docker-сетей
container:
valid_volumes:
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
```
Без `valid_volumes` раннер пропускает тома из `release.yml`, и каждый релиз заново качает Gradle
и NDK (~4 мин). Признак выключенного кэша actions в логах — `Cache action is only supported on GHES…`.
Обслуживание хоста раннера (cron, раз в сутки):
```
0 4 * * * docker image prune -af --filter "until=72h"; docker builder prune -af --filter "until=72h"
```
Кэши в `/opt/ci-cache` занимают ~5–6 ГБ (Gradle, SDK, NDK); если место кончается — их можно удалить целиком,
следующий релиз скачает всё заново.
## Секреты
| Секрет | Назначение |
|---|---|
| `RELEASE_TOKEN` | Создание релиза и загрузка ассетов |
| `RELEASE_TOKEN` | Необязательно. Создание и редактирование релиза, загрузка ассетов (scope `write:repository`). Без него используется встроенный токен job (`GITHUB_TOKEN`), у которого есть права на запись в этот репозиторий |
| `ANDROID_KEYSTORE_BASE64`, `ANDROID_KEYSTORE_PASSWORD`, `ANDROID_KEY_ALIAS`, `ANDROID_KEY_PASSWORD` | Подпись APK. **Ключ один на всю жизнь приложения** — хранить резервную копию вне Gitea. Без них финальный релиз падает; `-rc` собирается с отладочной подписью. |
Переменная репозитория `PUBLIC_URL` (Settings → Actions → Variables) — внешний адрес Gitea для ссылок
в тексте релиза, по умолчанию `https://gitea.hsrv.site`. Нужна потому, что job обращается к API по внутреннему
адресу, и ссылки из ответа API ведут туда же.
## Деплой
Автодеплой не обязателен. На сервере в клоне репозитория: `deploy/update.sh [vX.Y.Z]` — checkout тега,
+4 -2
View File
@@ -38,6 +38,7 @@ CREATE TABLE refresh_tokens (
id uuid PRIMARY KEY,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
family_id uuid NOT NULL, -- цепочка ротаций одного входа
family_started_at timestamptz NOT NULL, -- время входа: абсолютный срок семьи (180 дней)
token_hash bytea NOT NULL UNIQUE, -- SHA-256 от токена
device_name text, -- "Chrome / Windows", "Pixel 8"
client_type text NOT NULL, -- 'web' | 'android'
@@ -196,7 +197,7 @@ CREATE TABLE albums (
id uuid PRIMARY KEY,
title text NOT NULL,
title_norm text NOT NULL,
album_artist_id uuid REFERENCES artists(id),
album_artist_id uuid NOT NULL REFERENCES artists(id), -- без тегов — исполнитель трека или «Unknown artist»
year int,
cover_path text, -- путь в кэше обложек (/cache/covers/<hash>.webp)
cover_colors text[], -- палитра обложки (3 цвета: доминирующий, второй, акцент) — для ambient-фона,
@@ -207,7 +208,8 @@ CREATE TABLE albums (
CREATE TABLE tracks (
id uuid PRIMARY KEY,
title text NOT NULL,
album_id uuid REFERENCES albums(id),
title_norm text NOT NULL, -- ключ поиска дублей: (album_id, title_norm, duration ±2 с)
album_id uuid NOT NULL REFERENCES albums(id),
disc_no int, track_no int,
year int,
duration_ms int NOT NULL,
+9 -2
View File
@@ -15,7 +15,7 @@
```
/srv/pipu/
├── library/ # точки монтирования источников (storage-agent, rshared)
├── library-host/ # опционально: заранее смонтированные папки для type=local
├── library-host/ # папка для type=local по умолчанию; обычно вместо неё LIBRARY_HOST=/mnt/music
├── cache/
│ ├── transcode/
│ └── covers/
@@ -29,6 +29,10 @@
└── caddy/ # сертификаты Let's Encrypt (если используется compose.tls.yaml)
```
Музыка на локальном диске сервера подключается через `LIBRARY_HOST` в `.env` (например, `/mnt/music`):
каталог хоста монтируется во все сервисы, включая `storage-agent`, только на чтение (запись появится вместе
с импортом одобренных загрузок), внутри контейнеров он виден как `/library-host`.
Перед первым запуском: `mkdir -p /srv/pipu/library && mount --bind /srv/pipu/library /srv/pipu/library && mount --make-rshared /srv/pipu/library`
(делает `deploy/install.sh`, плюс systemd-unit для повторения после перезагрузки).
@@ -63,7 +67,10 @@
| `DATABASE_URL` | `postgres://pipu:…@postgres:5432/pipu` | |
| `REDIS_URL` | `redis://redis:6379/0` | |
| `MEILI_URL`, `MEILI_MASTER_KEY` | | |
| `JWT_PRIVATE_KEY_FILE` | `/run/secrets/jwt_ed25519.pem` | Генерируется `install.sh` |
| `JWT_PRIVATE_KEY` | base64 от 32 байт (seed Ed25519) | Подпись access-токенов. Создают `install.sh` / `update.sh`, если его нет; пусто — случайный ключ до перезапуска |
| `JWT_PREVIOUS_KEYS` | пусто | Прежние ключи через запятую — токены, подписанные ими, ещё принимаются (ротация) |
| `ACCESS_TOKEN_TTL` | `15m` | Срок access-токена |
| `COOKIE_SECURE` | `true` | Флаг `Secure` у refresh-cookie; `false` — только для разработки по http не на localhost |
| `STREAM_SIGNING_KEY` | 32 байта base64 | HMAC подписанных ссылок |
| `INTERNAL_TOKEN` | случайный | Межсервисная аутентификация |
| `BOOTSTRAP_ADMIN_USERNAME` | `admin` | |
+76 -17
View File
@@ -6,8 +6,9 @@
В примерах:
- домен — `music.example.com`, замените на свой;
- репозиторий — `gitea.hsrv.site/mrleo1nid/pipu-music`;
- каталог установки — `/opt/pipu-music`.
- репозиторий — `gitea.hsrv.site/mrleo1nid/pipu-music`, клонируется в `/opt/pipu-music`;
- диск с музыкой смонтирован в `/mnt/music`;
- данные сервиса (БД, кэши, сертификаты) — в `/srv/pipu`.
Готовые Docker-образы не публикуются: сервер клонирует репозиторий и собирает образы сам
(`docker compose build`). Релизы в Gitea содержат только APK для Android.
@@ -22,11 +23,11 @@
| Что | Требование |
|---|---|
| Сервер | Linux x86_64 (Debian 12 / Ubuntu 22.04+), 4+ ядра, 8+ ГБ RAM, 30+ ГБ на диске (система, образы, кэш сборки, данные) |
| Сервер | Linux x86_64 (Debian 12 / Ubuntu 22.04+), 4+ ядра, 8+ ГБ RAM, 30+ ГБ на системном диске (система, образы, кэш сборки, данные) |
| Доступ | root или пользователь с `sudo` |
| Домен | Поддомен, которым вы управляете, например `music.example.com` |
| Сеть | Входящие **80/tcp** и **443/tcp** (+ **443/udp** для HTTP/3) из интернета на сервер |
| Музыка | NFS-сервер с библиотекой (подключается позже из админки) |
| Музыка | Отдельный диск с библиотекой, подключённый к серверу (шаг 4). NFS-хранилища можно добавить позже из админки |
| Интернет на сервере | Нужен при сборке: скачиваются базовые образы и зависимости Go / npm / Python |
| Gitea | Доступ к репозиторию на чтение (если он приватный — токен или SSH-ключ) |
@@ -50,7 +51,7 @@ dig +short music.example.com
```
> Сертификат Let's Encrypt выдаётся, только когда домен уже указывает на сервер и порт 80 доступен из интернета.
> Не запускайте шаг 6, пока эта проверка не проходит.
> Не запускайте шаг 7, пока эта проверка не проходит.
---
@@ -86,7 +87,51 @@ sudo ufw allow 80/tcp && sudo ufw allow 443/tcp && sudo ufw allow 443/udp
---
## 4. Клонирование репозитория
## 4. Диск с музыкой
Музыка лежит на отдельном диске, смонтированном в `/mnt/music`. Если диск уже смонтирован — проверьте его
командой из конца шага и переходите к шагу 5.
Найдите диск и его UUID (раздел с музыкой — например, `/dev/sdb1`):
```bash
lsblk -f
```
Создайте точку монтирования:
```bash
sudo mkdir -p /mnt/music
```
Добавьте в `/etc/fstab` строку (подставьте свой UUID и файловую систему из вывода `lsblk -f`):
```ini
UUID=1234abcd-0000-0000-0000-000000000000 /mnt/music ext4 defaults,nofail,noatime 0 2
```
`nofail` нужен, чтобы сервер загружался, даже если диск не подключён. Смонтируйте и проверьте:
```bash
sudo mount -a && findmnt /mnt/music && ls /mnt/music | head
```
Сервисы Pipu работают в контейнерах не от root, поэтому музыка должна быть доступна на чтение всем:
файлы — `644`, каталоги — `755`. Если раньше права были закрытыми:
```bash
sudo chmod -R a+rX /mnt/music
```
> Для дисков с NTFS / exFAT права задаются опциями монтирования, а не `chmod`: добавьте в fstab
> `uid=0,gid=0,umask=022` вместо `defaults`.
Pipu только читает этот диск. Записывать в него будет лишь импорт одобренных треков из очереди на добавление,
и только если в админке разрешить это для хранилища ([uploads.md](uploads.md)).
---
## 5. Клонирование репозитория
```bash
sudo mkdir -p /opt/pipu-music && sudo chown "$USER" /opt/pipu-music
@@ -104,16 +149,18 @@ git clone https://gitea.hsrv.site/mrleo1nid/pipu-music.git /opt/pipu-music && cd
---
## 5. Подготовка сервера
## 6. Подготовка сервера
```bash
sudo ./install.sh
sudo LIBRARY_HOST=/mnt/music ./install.sh
```
Скрипт:
- создаёт каталоги данных в `/srv/pipu` (БД, кэши, загрузки, сертификаты);
- делает `/srv/pipu/library` точкой монтирования с shared propagation, чтобы NFS-хранилища, которые потом
подключит админ, были видны всем контейнерам, и ставит systemd-юнит, повторяющий это после перезагрузки;
- проверяет, что `/mnt/music` существует, и записывает его в `.env` как `LIBRARY_HOST` — эту папку получат
контейнеры `api`, `nginx` и `analyzer` (внутри они видят её как `/library-host`);
- создаёт `.env` со случайными паролями БД, ключом поиска и межсервисным токеном.
Заполните в `/opt/pipu-music/deploy/.env`:
@@ -124,6 +171,14 @@ DOMAIN=music.example.com
ACME_EMAIL=you@example.com
```
Проверьте, что там же стоит путь к музыке (его вписал `install.sh`):
```ini
LIBRARY_HOST=/mnt/music
```
Если диск смонтирован в другое место — поправьте путь здесь и перезапустите сервисы (`./update.sh`).
`ACME_EMAIL` — адрес, на который Let's Encrypt пишет, если с сертификатом что-то не так.
Остальные значения менять не нужно; `PIPU_VERSION` проставляет `update.sh`.
@@ -131,7 +186,7 @@ ACME_EMAIL=you@example.com
---
## 6. Сборка и запуск
## 7. Сборка и запуск
```bash
./update.sh
@@ -161,7 +216,7 @@ curl -s https://music.example.com/readyz
---
## 7. Веб-клиент
## 8. Веб-клиент
Откройте `https://music.example.com` в браузере.
@@ -176,12 +231,14 @@ curl -s https://music.example.com/readyz
Сразу после входа приложение попросит сменить логин и пароль администратора
([auth-and-roles.md](auth-and-roles.md#первый-запуск-bootstrap)). Чтобы задать пароль заранее, впишите
`BOOTSTRAP_ADMIN_PASSWORD` в `.env` **до** первого запуска.
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → адрес NFS-сервера и путь экспорта
→ «Проверить» → шаблоны путей → «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
- **С версии 0.3 (M2):** Админка → Хранилища → «Добавить хранилище» → тип «Локальная папка» → выберите
подпапку на диске `/mnt/music` (в списке она видна как `/library-host/...`) → «Проверить» → шаблоны путей
→ «Сохранить и пересканировать» ([library-and-storage.md](library-and-storage.md)).
Там же можно добавить NFS-хранилище: адрес сервера и путь экспорта.
---
## 8. Android-приложение
## 9. Android-приложение
1. На телефоне откройте страницу релизов
`https://gitea.hsrv.site/mrleo1nid/pipu-music/releases` и скачайте `pipu-music-<версия>.apk`.
@@ -201,7 +258,7 @@ curl -s https://music.example.com/readyz
---
## 9. Вариант: свой reverse proxy вместо Caddy
## 10. Вариант: свой reverse proxy вместо Caddy
Если на сервере уже есть Traefik, Nginx Proxy Manager или свой nginx с сертификатами:
@@ -238,14 +295,14 @@ curl -s https://music.example.com/readyz
}
```
3. Запуск и проверки — как в шагах 6–8.
3. Запуск и проверки — как в шагах 7–9.
Если прокси работает на другой машине, укажите в `HTTP_PORT` внутренний адрес сервера (`192.168.1.10:8081`) и
закройте этот порт от интернета файрволом.
---
## 10. Обновление, бэкапы, удаление
## 11. Обновление, бэкапы, удаление
Обновление до последнего релиза:
@@ -271,7 +328,7 @@ cd /opt/pipu-music/deploy && docker compose down
---
## 11. Если что-то не работает
## 12. Если что-то не работает
| Симптом | Что проверить |
|---|---|
@@ -282,6 +339,8 @@ cd /opt/pipu-music/deploy && docker compose down
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` |
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) |
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
| Android: «Сервер не отвечает» | Тот же адрес открывается в браузере телефона; в адресе нет опечатки; телефон в сети с доступом к серверу |
| Android: «Эта версия приложения слишком старая» | Установите APK из последнего релиза |
+1 -1
View File
@@ -7,7 +7,7 @@
| Тип | Как подключается |
|---|---|
| `nfs` | Админ указывает `server`, `export`, опции. `storage-agent` выполняет `mount -t nfs -o <opts> server:export /library/<id>`. |
| `local` | Папка, уже смонтированная на хосте в `/srv/pipu/library-host` (например, через fstab). Админ выбирает подпапку. Нужен как запасной путь и для dev. |
| `local` | Папка на диске сервера, заранее смонтированная на хосте (например, диск `/mnt/music` через fstab) и указанная в `LIBRARY_HOST`. В контейнерах видна как `/library-host`; админ выбирает подпапку. |
### Монтирование (storage-agent)
+3 -3
View File
@@ -10,10 +10,10 @@
| Серверное состояние | TanStack Query (кэш, инвалидация, бесконечные списки) |
| Клиентское состояние | Zustand (плеер, очередь, UI) |
| Стили | CSS Modules + CSS-переменные из `packages/design-tokens`; тёмная и светлая темы ([design.md](design.md#2-токены)) |
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono, сабсет Material Symbols Rounded — всё встроено в сборку |
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска) |
| Шрифты и иконки | `@fontsource` Onest / Unbounded / JetBrains Mono; иконки — SVG Material Symbols Rounded (`@material-symbols/svg-400`), импортируются поштучно в `shared/ui/icons.ts` и рисуются CSS-маской (`currentColor`), поэтому в сборку попадают только используемые — всё встроено в сборку |
| Компоненты-примитивы | Radix UI (доступные меню, диалоги, слайдеры, popover поиска); в M1 — собственные `Menu` / `Dialog` в `shared/ui`, переход на Radix вместе с поиском (M2) |
| Анимация | Motion (переходы UI), Canvas 2D для кнопки волны (порт `wave.js` из прототипа) |
| Формы | React Hook Form + Zod |
| Формы | React Hook Form + Zod (в M1 формы простые — контролируемые поля, валидация на сервере с ошибками полей) |
| i18n | i18next + react-i18next, словари из `packages/i18n` |
| API | `packages/api-client` (сгенерирован из OpenAPI, `openapi-typescript` + `openapi-fetch`) |
+1 -1
View File
@@ -13,7 +13,7 @@
"generate": "turbo run generate",
"i18n:check": "pnpm --filter @pipu/i18n check",
"version:info": "node scripts/version.mjs",
"test:scripts": "node --test scripts/version.test.mjs"
"test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs"
},
"devDependencies": {
"turbo": "2.5.4",
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
import { describe, expect, it, vi } from 'vitest';
import { ApiError, AuthSession, type AuthTokens, type RefreshStore } from './auth';
const user = {
id: '00000000-0000-0000-0000-000000000001',
username: 'alice',
display_name: null,
role: 'user' as const,
locale: 'ru',
ui_prefs: {},
must_change_password: false,
must_change_credentials: false,
created_at: '2026-01-01T00:00:00Z',
};
const tokens = (access: string, refresh?: string): AuthTokens => ({
access_token: access,
token_type: 'Bearer',
expires_in: 900,
refresh_token: refresh,
user,
});
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': status >= 400 ? 'application/problem+json' : 'application/json' },
});
const problem = (status: number, code: string, extra: object = {}) => json({ status, code, ...extra }, status);
type Handler = (req: Request) => Response | Promise<Response>;
/** Фейковый сервер: маршруты по «METHOD path», журнал запросов. */
function server(routes: Record<string, Handler>) {
const log: { route: string; auth: string | null; body: string }[] = [];
const fetchImpl = async (input: RequestInfo | URL) => {
const req = input as Request;
const route = `${req.method} ${new URL(req.url).pathname}`;
log.push({ route, auth: req.headers.get('Authorization'), body: await req.clone().text() });
const h = routes[route];
if (!h) return problem(404, 'common.not_found');
return h(req);
};
return { fetch: fetchImpl as typeof fetch, log };
}
const memoryStore = (initial: string | null = null): RefreshStore & { value: string | null } => ({
value: initial,
async get() {
return this.value;
},
async set(v) {
this.value = v;
},
async clear() {
this.value = null;
},
});
describe('AuthSession', () => {
it('logs in and sends the bearer token', async () => {
const s = server({
'POST /api/v1/auth/login': () => json(tokens('A1')),
'GET /api/v1/me': (req) =>
req.headers.get('Authorization') === 'Bearer A1' ? json(user) : problem(401, 'auth.unauthorized'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web', deviceName: 'Test' });
const states: string[] = [];
auth.subscribe((st) => states.push(st.status));
await auth.login('alice', 'secret');
expect(JSON.parse(s.log[0]!.body)).toEqual({
username: 'alice',
password: 'secret',
client_type: 'web',
device_name: 'Test',
});
const me = await auth.client.GET('/api/v1/me');
expect(me.data?.username).toBe('alice');
expect(states).toEqual(['signed_in']);
});
it('turns problem+json into ApiError with field errors', async () => {
const s = server({
'POST /api/v1/auth/login': () => problem(401, 'auth.invalid_credentials'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
const err = await auth.login('alice', 'bad').catch((e: unknown) => e);
expect(err).toBeInstanceOf(ApiError);
expect((err as ApiError).code).toBe('auth.invalid_credentials');
expect(auth.state.status).toBe('unknown');
const e2 = new ApiError(422, 'validation.password_too_short', { min: 8 }, [
{ field: 'new_password', code: 'validation.password_too_short' },
]);
expect(e2.fieldCode('new_password')).toBe('validation.password_too_short');
expect(e2.fieldCode('other')).toBeUndefined();
});
it('refreshes once for parallel expired requests and retries them', async () => {
let refreshes = 0;
const s = server({
'POST /api/v1/auth/login': () => json(tokens('old')),
'POST /api/v1/auth/refresh': async () => {
refreshes++;
await new Promise((r) => setTimeout(r, 10));
return json(tokens('new'));
},
'GET /api/v1/me': (req) =>
req.headers.get('Authorization') === 'Bearer new' ? json(user) : problem(401, 'auth.token_expired'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
await auth.login('alice', 'secret');
const results = await Promise.all([1, 2, 3].map(() => auth.client.GET('/api/v1/me')));
expect(results.every((r) => r.data?.username === 'alice')).toBe(true);
expect(refreshes).toBe(1);
});
it('does not refresh on other 401 codes and signs out a blocked user', async () => {
const s = server({
'POST /api/v1/auth/login': () => json(tokens('A')),
'GET /api/v1/me': () => problem(401, 'auth.user_blocked'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
await auth.login('alice', 'secret');
const res = await auth.client.GET('/api/v1/me');
expect(res.response.status).toBe(401);
expect(s.log.some((l) => l.route.includes('refresh'))).toBe(false);
expect(auth.state).toEqual({ status: 'signed_out', reason: 'auth.user_blocked' });
});
it('signs out when refresh is rejected', async () => {
const s = server({
'POST /api/v1/auth/refresh': () => problem(401, 'auth.refresh_reused'),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
expect(await auth.restore()).toEqual({ status: 'signed_out', reason: 'auth.refresh_reused' });
});
it('retries a refresh race once', async () => {
let n = 0;
const s = server({
'POST /api/v1/auth/refresh': () => (++n === 1 ? problem(401, 'auth.refresh_race') : json(tokens('A'))),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
expect((await auth.restore()).status).toBe('signed_in');
expect(n).toBe(2);
});
it('keeps the session on network errors during refresh', async () => {
const auth = new AuthSession({
baseUrl: 'http://x',
fetch: async () => {
throw new TypeError('Failed to fetch');
},
clientType: 'web',
});
expect(await auth.refresh()).toBe(false);
expect(auth.state.status).toBe('unknown');
});
it('android keeps the refresh token in the store', async () => {
const store = memoryStore('R0');
const s = server({
'POST /api/v1/auth/refresh': async (req) => {
const body = (await req.json()) as { refresh_token: string };
return body.refresh_token === 'R0' ? json(tokens('A1', 'R1')) : problem(401, 'auth.refresh_invalid');
},
'POST /api/v1/auth/logout': () => new Response(null, { status: 204 }),
});
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'android', refreshStore: store });
expect((await auth.restore()).status).toBe('signed_in');
expect(store.value).toBe('R1');
await auth.logout();
expect(store.value).toBeNull();
expect(auth.state.status).toBe('signed_out');
expect(s.log.at(-1)).toMatchObject({ route: 'POST /api/v1/auth/logout', auth: 'Bearer A1' });
expect(await auth.refresh()).toBe(false);
});
it('requires a refresh store for android', () => {
expect(() => new AuthSession({ clientType: 'android' })).toThrow(/refreshStore/);
});
it('notifies subscribers about profile updates', async () => {
const s = server({ 'POST /api/v1/auth/login': () => json(tokens('A')) });
const auth = new AuthSession({ baseUrl: 'http://x', fetch: s.fetch, clientType: 'web' });
const fn = vi.fn();
auth.subscribe(fn);
await auth.login('alice', 'x');
auth.setUser({ ...user, display_name: 'Alice' });
expect(fn).toHaveBeenLastCalledWith({ status: 'signed_in', user: { ...user, display_name: 'Alice' } });
});
});
+219
View File
@@ -0,0 +1,219 @@
import { createApiClient, type ApiClient, type components } from '@pipu/api-client';
export type AuthUser = components['schemas']['Me'];
export type AuthTokens = components['schemas']['AuthTokens'];
export type ClientType = components['schemas']['ClientType'];
/** Ошибка API с машинным кодом (problem+json). Текст для пользователя — перевод `errors:<code>`. */
export class ApiError extends Error {
readonly status: number;
readonly code: string;
readonly params: Record<string, unknown>;
readonly fields: { field: string; code: string }[];
constructor(status: number, code: string, params: Record<string, unknown> = {}, fields: ApiError['fields'] = []) {
super(code);
this.name = 'ApiError';
this.status = status;
this.code = code;
this.params = params;
this.fields = fields;
}
/** Код ошибки конкретного поля формы, если сервер его указал. */
fieldCode(field: string): string | undefined {
return this.fields.find((f) => f.field === field)?.code;
}
}
/** Превращает ответ openapi-fetch с ошибкой в ApiError. Сетевые сбои — код `common.network`. */
export function toApiError(error: unknown, response?: Response): ApiError {
if (error instanceof ApiError) return error;
const p = (error ?? {}) as Partial<components['schemas']['Problem']>;
if (typeof p.code === 'string') {
return new ApiError(p.status ?? response?.status ?? 0, p.code, p.params ?? {}, p.errors ?? []);
}
if (response) return new ApiError(response.status, response.status >= 500 ? 'common.internal' : 'common.bad_request');
return new ApiError(0, 'common.network');
}
/** Достаёт данные из результата openapi-fetch или бросает ApiError. */
export function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): T {
if (res.error !== undefined || !res.response.ok) throw toApiError(res.error, res.response);
return res.data as T;
}
/** Хранилище refresh-токена для клиентов без cookie (Android: expo-secure-store). */
export interface RefreshStore {
get(): Promise<string | null>;
set(token: string): Promise<void>;
clear(): Promise<void>;
}
export type AuthState =
| { status: 'unknown' }
| { status: 'signed_out'; reason?: string }
| { status: 'signed_in'; user: AuthUser };
export interface AuthSessionOptions {
/** Адрес сервера; пусто — тот же origin (веб). */
baseUrl?: string;
fetch?: typeof globalThis.fetch;
clientType: ClientType;
deviceName?: string;
/** Обязателен для android: веб хранит refresh в HttpOnly-cookie. */
refreshStore?: RefreshStore;
}
/** Коды 401, после которых имеет смысл обновить access-токен и повторить запрос. */
const RETRYABLE = new Set(['auth.token_expired', 'auth.token_invalid', 'auth.token_revoked']);
const RACE_RETRY_MS = 300;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
/**
* Сессия пользователя: access-токен в памяти, refresh — в cookie (веб) или RefreshStore (Android).
* `client` сам подставляет Bearer и при истёкшем токене делает **один** общий refresh на все
* параллельные запросы (single-flight), после чего повторяет их.
*/
export class AuthSession {
readonly client: ApiClient;
private readonly raw: ApiClient;
private readonly opts: AuthSessionOptions;
private readonly baseFetch: typeof globalThis.fetch;
private access: string | null = null;
private refreshing: Promise<boolean> | null = null;
private current: AuthState = { status: 'unknown' };
private readonly listeners = new Set<(s: AuthState) => void>();
constructor(opts: AuthSessionOptions) {
if (opts.clientType === 'android' && !opts.refreshStore) {
throw new Error('AuthSession: android client needs a refreshStore');
}
this.opts = opts;
this.baseFetch = opts.fetch ?? ((...args) => globalThis.fetch(...args));
this.raw = createApiClient({ baseUrl: opts.baseUrl, fetch: this.baseFetch });
this.client = createApiClient({ baseUrl: opts.baseUrl, fetch: (req) => this.authFetch(req as Request) });
}
get state(): AuthState {
return this.current;
}
/** Подписка на смену состояния; возвращает функцию отписки. */
subscribe(fn: (s: AuthState) => void): () => void {
this.listeners.add(fn);
return () => this.listeners.delete(fn);
}
private setState(s: AuthState) {
this.current = s;
for (const fn of this.listeners) fn(s);
}
/** Восстанавливает сессию после перезагрузки по refresh-токену. */
async restore(): Promise<AuthState> {
if (!(await this.refresh())) {
if (this.current.status !== 'signed_out') this.setState({ status: 'signed_out' });
}
return this.current;
}
async login(username: string, password: string): Promise<AuthUser> {
const res = await this.raw.POST('/api/v1/auth/login', {
body: { username, password, client_type: this.opts.clientType, device_name: this.opts.deviceName },
});
return this.apply(unwrap(res));
}
/** Принимает новую пару токенов (вход, смена пароля, смена учётных данных). */
async apply(tokens: AuthTokens): Promise<AuthUser> {
this.access = tokens.access_token;
if (tokens.refresh_token && this.opts.refreshStore) await this.opts.refreshStore.set(tokens.refresh_token);
this.setState({ status: 'signed_in', user: tokens.user });
return tokens.user;
}
/** Обновляет профиль в состоянии (после PATCH /me). */
setUser(user: AuthUser) {
if (this.current.status === 'signed_in') this.setState({ status: 'signed_in', user });
}
async logout(all = false): Promise<void> {
try {
if (this.access) await this.client.POST(all ? '/api/v1/auth/logout-all' : '/api/v1/auth/logout');
} finally {
await this.signOut();
}
}
private async signOut(reason?: string) {
this.access = null;
await this.opts.refreshStore?.clear();
this.setState({ status: 'signed_out', reason });
}
/** Single-flight: параллельные вызовы ждут один и тот же запрос. */
refresh(): Promise<boolean> {
this.refreshing ??= this.doRefresh().finally(() => {
this.refreshing = null;
});
return this.refreshing;
}
private async doRefresh(attempt = 0): Promise<boolean> {
let body: { refresh_token?: string } | undefined;
if (this.opts.refreshStore) {
const token = await this.opts.refreshStore.get();
if (!token) return false;
body = { refresh_token: token };
}
let res;
try {
res = await this.raw.POST('/api/v1/auth/refresh', { body });
} catch {
return false; // сеть недоступна: сессию не трогаем, следующий запрос попробует снова
}
if (res.data) {
await this.apply(res.data);
return true;
}
const err = toApiError(res.error, res.response);
if (err.code === 'auth.refresh_race' && attempt === 0) {
// Другая вкладка только что обновила cookie — повторяем уже с новой.
await sleep(RACE_RETRY_MS);
return this.doRefresh(attempt + 1);
}
if (res.response.status === 401 || res.response.status === 403) await this.signOut(err.code);
return false;
}
private async authFetch(request: Request): Promise<Response> {
const retry = request.clone();
const sent = this.access;
const res = await this.baseFetch(this.withAuth(request));
if (res.status !== 401) return res;
const code = await res
.clone()
.json()
.then((p: { code?: string }) => p.code)
.catch(() => undefined);
if (!code || !RETRYABLE.has(code)) {
if (code === 'auth.user_blocked') await this.signOut(code);
return res;
}
// Пока запрос был в пути, токен уже обновил кто-то другой — повторяем без новой ротации.
const fresh = this.access !== null && this.access !== sent;
if (!fresh && !(await this.refresh())) return res;
return this.baseFetch(this.withAuth(retry));
}
private withAuth(request: Request): Request {
if (!this.access) return request;
const headers = new Headers(request.headers);
headers.set('Authorization', `Bearer ${this.access}`);
return new Request(request, { headers });
}
}
+23
View File
@@ -1 +1,24 @@
export {
ApiError,
AuthSession,
toApiError,
unwrap,
type AuthSessionOptions,
type AuthState,
type AuthTokens,
type AuthUser,
type ClientType,
type RefreshStore,
} from './auth';
export {
EventBuffer,
PlayAttempt,
PlayQueue,
uuidv7,
type EventSink,
type PlayContext,
type PlayEvent,
type RepeatMode,
type Track,
} from './player';
export { checkServer, normalizeServerUrl, SERVER_CHECK_TIMEOUT_MS, type ServerCheck } from './server';
+110
View File
@@ -0,0 +1,110 @@
import { describe, expect, it, vi } from 'vitest';
import { EventBuffer, PlayAttempt, PlayQueue, uuidv7, type Track } from './player';
const track = (id: string, duration_ms = 200_000): Track => ({
id,
title: id,
duration_ms,
artists: [],
album: { id: 'a', title: 'A', year: null, cover_url: null, cover_colors: [] },
genres: [],
disc_no: null,
track_no: null,
available: true,
stream_url: `/s/${id}`,
reaction: null,
});
const ids = (q: PlayQueue) => q.order.map((i) => q.tracks[i]!.id);
describe('PlayQueue', () => {
it('walks forward and back, stops at the end', () => {
const q = new PlayQueue();
q.set([track('a'), track('b'), track('c')], 1);
expect(q.current?.id).toBe('b');
expect(q.next()?.id).toBe('c');
expect(q.peekNext()).toBeNull();
expect(q.next()).toBeNull();
expect(q.prev()?.id).toBe('b');
});
it('repeats one track only when it ends by itself', () => {
const q = new PlayQueue();
q.set([track('a'), track('b')]);
q.repeat = 'one';
expect(q.next(true)?.id).toBe('a');
expect(q.next()?.id).toBe('b');
});
it('wraps around with repeat all', () => {
const q = new PlayQueue();
q.set([track('a'), track('b')], 1);
q.repeat = 'all';
expect(q.peekNext()?.id).toBe('a');
expect(q.next()?.id).toBe('a');
expect(q.prev()?.id).toBe('b');
});
it('shuffles keeping the current track first and restores order', () => {
let seed = 0.3;
const q = new PlayQueue(() => (seed = (seed * 9301 + 0.49297) % 1));
q.set(['a', 'b', 'c', 'd', 'e'].map((id) => track(id)), 2);
q.setShuffle(true);
expect(q.current?.id).toBe('c');
expect(ids(q)[0]).toBe('c');
expect([...ids(q)].sort()).toEqual(['a', 'b', 'c', 'd', 'e']);
q.next();
const cur = q.current!.id;
q.setShuffle(false);
expect(q.current?.id).toBe(cur);
expect(ids(q)).toEqual(['a', 'b', 'c', 'd', 'e']);
});
});
describe('PlayAttempt', () => {
it('counts only time actually played', () => {
const a = new PlayAttempt(track('a'));
for (let t = 250; t <= 10_000; t += 250) a.tick(t);
expect(a.playedMs).toBe(10_000);
expect(a.tick(60_000)).toBe(true); // перемотка вперёд
a.tick(60_250);
expect(a.playedMs).toBe(10_250);
a.seek(5000);
a.tick(5250);
expect(a.playedMs).toBe(10_500);
});
it('treats the last seconds as finished', () => {
const a = new PlayAttempt(track('a', 200_000));
expect(a.outcome(196_000)).toBe('play_end');
expect(a.outcome(120_000)).toBe('skip');
});
});
describe('EventBuffer', () => {
it('sends in batches and retries with backoff', async () => {
vi.useFakeTimers();
const send = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(undefined);
const buf = new EventBuffer({ send }, () => Date.UTC(2026, 0, 1));
buf.push({ type: 'play_start', track_id: 't1' });
buf.push({ type: 'skip', track_id: 't1', played_ms: 1000 });
expect(await buf.flush()).toBe(false);
expect(buf.pending).toHaveLength(2);
await vi.advanceTimersByTimeAsync(2000);
expect(send).toHaveBeenCalledTimes(2);
expect(buf.pending).toHaveLength(0);
const sent = send.mock.calls[1]![0];
expect(sent[0].occurred_at).toBe('2026-01-01T00:00:00.000Z');
expect(sent[0].id).not.toBe(sent[1].id);
vi.useRealTimers();
});
});
describe('uuidv7', () => {
it('encodes time and version', () => {
const id = uuidv7(0x0190_0000_0000, () => new Uint8Array(16).fill(0xff));
expect(id).toMatch(/^01900000-0000-7fff-bfff-ffffffffffff$/);
});
});
+200
View File
@@ -0,0 +1,200 @@
import type { components } from '@pipu/api-client';
export type Track = components['schemas']['Track'];
export type PlayEvent = components['schemas']['PlayEvent'];
export type RepeatMode = 'off' | 'one' | 'all';
export type PlayContext = { type: NonNullable<PlayEvent['context_type']>; id?: string; label?: string };
/** UUIDv7: время в первых 48 битах — сервер дедуплицирует события по id, сортировка по времени. */
export function uuidv7(now = Date.now(), rnd: (n: number) => Uint8Array = randomBytes): string {
const b = rnd(16);
const ts = BigInt(now);
for (let i = 0; i < 6; i++) b[i] = Number((ts >> BigInt(8 * (5 - i))) & 0xffn);
b[6] = (b[6]! & 0x0f) | 0x70;
b[8] = (b[8]! & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
function randomBytes(n: number): Uint8Array {
const b = new Uint8Array(n);
globalThis.crypto.getRandomValues(b);
return b;
}
/**
* Очередь воспроизведения. Перемешивание хранится порядком индексов: текущий трек остаётся текущим,
* а выключение возвращает исходный порядок.
*/
export class PlayQueue {
tracks: Track[] = [];
/** Позиция в order. */
pos = 0;
order: number[] = [];
shuffle = false;
repeat: RepeatMode = 'off';
constructor(private readonly random: () => number = Math.random) {}
get current(): Track | undefined {
return this.tracks[this.order[this.pos] ?? -1];
}
/** Индекс текущего трека в исходном списке. */
get index(): number {
return this.order[this.pos] ?? 0;
}
set(tracks: Track[], index = 0) {
this.tracks = tracks;
this.order = tracks.map((_, i) => i);
this.pos = Math.min(Math.max(index, 0), Math.max(tracks.length - 1, 0));
if (this.shuffle) this.reshuffle();
}
setShuffle(on: boolean) {
const cur = this.index;
this.shuffle = on;
this.order = this.tracks.map((_, i) => i);
this.pos = cur;
if (on) this.reshuffle();
}
private reshuffle() {
const cur = this.order[this.pos] ?? 0;
const rest = this.order.filter((i) => i !== cur);
for (let i = rest.length - 1; i > 0; i--) {
const j = Math.floor(this.random() * (i + 1));
[rest[i], rest[j]] = [rest[j]!, rest[i]!];
}
this.order = this.tracks.length ? [cur, ...rest] : [];
this.pos = 0;
}
/** Следующий трек. auto — трек доиграл сам (repeat one повторяет его). null — очередь кончилась. */
next(auto = false): Track | null {
if (!this.tracks.length) return null;
if (auto && this.repeat === 'one') return this.current ?? null;
if (this.pos + 1 < this.order.length) {
this.pos++;
return this.current ?? null;
}
if (this.repeat === 'all') {
this.pos = 0;
return this.current ?? null;
}
return null;
}
/** Предыдущий трек (с начала очереди при repeat all — последний). */
prev(): Track | null {
if (!this.tracks.length) return null;
if (this.pos > 0) this.pos--;
else if (this.repeat === 'all') this.pos = this.order.length - 1;
return this.current ?? null;
}
/** Что сыграет после текущего — для предзагрузки. */
peekNext(): Track | null {
if (this.repeat === 'one') return this.current ?? null;
const i = this.pos + 1 < this.order.length ? this.pos + 1 : this.repeat === 'all' ? 0 : -1;
return i < 0 ? null : (this.tracks[this.order[i]!] ?? null);
}
jump(index: number) {
const p = this.order.indexOf(index);
if (p >= 0) this.pos = p;
}
}
/** Скачок позиции больше этого — перемотка, а не воспроизведение. */
const SEEK_GAP_MS = 2000;
/** Осталось меньше этого — трек доигран (play_end, а не skip). */
const END_SLACK_MS = 5000;
/**
* Учёт попытки прослушивания одного трека: played_ms — реально прослушанное время,
* перемотки вперёд не засчитываются.
*/
export class PlayAttempt {
playedMs = 0;
private last: number;
constructor(
readonly track: Track,
startMs = 0,
) {
this.last = startMs;
}
/** Новая позиция воспроизведения (timeupdate). Возвращает true, если это была перемотка. */
tick(positionMs: number): boolean {
const delta = positionMs - this.last;
this.last = positionMs;
if (delta > 0 && delta <= SEEK_GAP_MS) {
this.playedMs += delta;
return false;
}
return delta !== 0;
}
/** Явная перемотка: позиция меняется без засчитывания. */
seek(positionMs: number) {
this.last = positionMs;
}
/** Тип завершения попытки по позиции. */
outcome(positionMs: number): 'play_end' | 'skip' {
return this.track.duration_ms - positionMs <= END_SLACK_MS ? 'play_end' : 'skip';
}
}
export interface EventSink {
send(events: PlayEvent[]): Promise<void>;
}
/**
* Буфер событий: отправка пачками (по таймеру, при смене трека, при уходе в фон),
* при ошибке — повтор с экспоненциальной задержкой. Сервер дедуплицирует по id.
*/
export class EventBuffer {
private queue: PlayEvent[] = [];
private sending = false;
private retryMs = 0;
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(
private readonly sink: EventSink,
private readonly now: () => number = Date.now,
private readonly maxRetryMs = 5 * 60_000,
) {}
get pending(): readonly PlayEvent[] {
return this.queue;
}
push(e: Omit<PlayEvent, 'id' | 'occurred_at'> & Partial<Pick<PlayEvent, 'id' | 'occurred_at'>>) {
const t = this.now();
this.queue.push({ id: e.id ?? uuidv7(t), occurred_at: e.occurred_at ?? new Date(t).toISOString(), ...e } as PlayEvent);
if (this.queue.length >= 500) void this.flush();
}
async flush(): Promise<boolean> {
if (this.sending || this.queue.length === 0) return true;
this.sending = true;
const batch = this.queue.slice(0, 500);
try {
await this.sink.send(batch);
this.queue = this.queue.slice(batch.length);
this.retryMs = 0;
return true;
} catch {
this.retryMs = Math.min(this.retryMs ? this.retryMs * 2 : 2000, this.maxRetryMs);
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => void this.flush(), this.retryMs);
return false;
} finally {
this.sending = false;
}
}
}
+153
View File
@@ -0,0 +1,153 @@
{
"users": {
"title": "Users",
"subtitle": "Create users and moderators, manage roles, reset passwords, block accounts",
"create": "Create user",
"search": "Name or username",
"all": "All",
"col_user": "User",
"col_role": "Role",
"col_status": "Status",
"col_last_login": "Last seen",
"status_active": "Active",
"status_blocked": "Blocked",
"status_must_change": "Must change password",
"never": "Never signed in",
"empty": "Nobody found",
"more": "Show more",
"actions": "Actions for {{username}}",
"reset_password": "Reset password",
"block": "Block",
"unblock": "Unblock",
"make_role": "Make {{role}}",
"delete": "Delete",
"delete_confirm": "Delete {{username}}? Their history and likes will be lost.",
"block_confirm": "Block {{username}}? All their sessions will end.",
"reset_confirm": "Reset {{username}}'s password? All their sessions will end.",
"confirm": "Confirm",
"you": "you"
},
"create": {
"title": "New user",
"username": "Username",
"username_hint": "Latin letters, digits, \".\", \"_\", \"-\"",
"display_name": "Name",
"role": "Role",
"password_note": "The server will generate a password and show it once.",
"submit": "Create"
},
"temp_password": {
"title": "Temporary password",
"text": "Give this password to {{username}}. It is shown only now; they will be asked to change it on sign-in.",
"done": "Done"
},
"audit": {
"title": "Audit log",
"subtitle": "Actions of administrators and moderators",
"col_when": "When",
"col_who": "Who",
"col_action": "Action",
"col_details": "Details",
"system": "System",
"empty": "No entries yet",
"more": "Show more",
"actions": {
"user.bootstrap_create": "Initial administrator created",
"user.bootstrap_credentials": "Administrator credentials set",
"user.create": "User created",
"user.rename": "Name changed",
"user.role_change": "Role changed",
"user.block": "User blocked",
"user.unblock": "User unblocked",
"user.password_reset": "Password reset",
"user.delete": "User deleted"
}
},
"sources": {
"title": "Storage",
"subtitle": "Where the server takes music from: a folder on the server or NFS",
"add": "Add storage",
"empty": "No storage yet. Connect a music folder to get started.",
"type_local": "Server folder",
"type_nfs": "NFS",
"status_pending": "Connecting",
"status_mounted": "Connected",
"status_error": "Error",
"status_disabled": "Disabled",
"last_scan": "Last scan: {{date}}",
"never_scanned": "Not scanned yet",
"scan": "Scan",
"full_scan": "Full scan",
"test": "Test",
"patterns": "Path patterns",
"edit": "Edit",
"delete": "Delete",
"disable": "Disable",
"enable": "Enable",
"delete_confirm": "Delete “{{name}}”? Tracks that exist only in this storage will disappear from the library along with their likes.",
"scanning": "Scanning: {{processed}} of {{total}}",
"scan_walking": "Walking folders…",
"scan_done": "Found {{found}} · new {{added}} · updated {{updated}} · moved {{moved}} · missing {{missing}} · errors {{errors}}",
"scan_failed": "Scan stopped: {{error}}",
"test_ok": "Reachable, audio files found: {{count}}",
"test_fail": "Not reachable: {{message}}",
"form_title_new": "New storage",
"form_title_edit": "Storage",
"name": "Name",
"type": "Type",
"folder": "Folder",
"folder_root": "Whole folder",
"folder_hint": "A subfolder of LIBRARY_HOST on the server",
"up": "Up",
"nfs_server": "Server",
"nfs_server_hint": "Host name or IP, e.g. nas.lan",
"nfs_export": "Export",
"nfs_export_hint": "Path on the server, e.g. /export/music",
"nfs_options": "Mount options",
"nfs_options_hint": "Read-only is added automatically",
"tag_priority": "Prefer",
"tags_first": "File tags",
"path_first": "Path patterns",
"interval": "Auto scan",
"interval_off": "Manual",
"interval_hours": "Every {{count}} h",
"save": "Save"
},
"patterns": {
"title": "Path patterns: {{name}}",
"intro": "RE2 regular expressions with named groups are matched against the file path relative to the storage root. The first match wins.",
"groups": "Groups: artist, albumartist, album, year, disc, track, title, genre; skip ignores the file.",
"add": "Add pattern",
"regex": "Pattern {{n}}",
"enabled": "Enabled",
"up": "Up",
"down": "Down",
"remove": "Remove",
"preview": "Preview",
"save": "Save",
"saved": "Saved. Run a full scan to apply it to files already found.",
"col_path": "Path",
"col_pattern": "Pattern",
"col_result": "Result",
"no_match": "no match",
"skipped": "skipped",
"unmatched": "No pattern matched: {{count}} of {{total}}",
"from_tags": "from tags",
"error_at": "Error in pattern {{n}}: {{reason}}",
"examples": "Examples"
},
"library": {
"title": "Library",
"subtitle": "Catalog and scan status",
"artists": "Artists",
"albums": "Albums",
"tracks": "Tracks",
"files": "Files",
"missing": "Missing files",
"errors": "Unreadable",
"sources": "Storage",
"error_files": "Files with errors",
"reindex": "Rebuild search index",
"reindex_started": "Rebuild started"
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"login": {
"title": "Sign in",
"subtitle": "Your music from your own server",
"username": "Username",
"password": "Password",
"submit": "Sign in",
"bootstrap_hint": "First start: sign in as the administrator. The password is set in BOOTSTRAP_ADMIN_PASSWORD or printed in the api service log."
},
"setup": {
"admin_badge": "First administrator sign-in",
"admin_title": "Change the username and password",
"admin_text": "The server stays locked until you do: the initial administrator credentials are visible in logs and settings.",
"password_badge": "Password reset",
"password_title": "Set a new password",
"password_text": "You signed in with a temporary password. Choose your own — the temporary one will stop working.",
"new_username": "New username",
"new_password": "New password",
"repeat_password": "Repeat the password",
"min_length": "At least {{min}} characters",
"submit": "Save and continue",
"logout": "Sign out"
}
}
+138
View File
@@ -17,5 +17,143 @@
"label": "Language",
"ru": "Русский",
"en": "English"
},
"nav": {
"main": "Navigation",
"home": "Home",
"admin": "Admin",
"users": "Users",
"audit": "Audit log",
"library": "Library",
"sources": "Storage",
"collection": "Collection",
"history": "History",
"likes": "Likes",
"dislikes": "Dislikes"
},
"profile": {
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out"
},
"roles": {
"admin": "Administrator",
"moderator": "Moderator",
"user": "User"
},
"home": {
"greeting": "Hi, {{name}}!",
"wave": "My Wave",
"wave_soon": "An endless stream tuned to your taste will appear once the server has a library."
},
"player": {
"empty": "Nothing is playing",
"label": "Player",
"play": "Play",
"pause": "Pause",
"next": "Next",
"prev": "Previous",
"like": "Like",
"unlike": "Remove from likes",
"dislike": "Dislike",
"undislike": "Remove dislike",
"volume": "Volume",
"mute": "Mute",
"unmute": "Unmute",
"shuffle": "Shuffle",
"repeat_off": "Repeat off",
"repeat_all": "Repeat queue",
"repeat_one": "Repeat track",
"progress": "Position",
"unavailable": "Track is unavailable — skipping",
"error": "Could not play the track"
},
"settings": {
"title": "Settings",
"account": "Account",
"display_name": "Display name",
"save": "Save",
"saved": "Saved",
"appearance": "Appearance",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"theme_system": "System",
"password": "Password",
"current_password": "Current password",
"new_password": "New password",
"repeat_password": "Repeat the password",
"change_password": "Change password",
"password_changed": "Password changed. Other devices have been signed out.",
"sessions": "Devices",
"session_current": "This device",
"session_last_used": "Active: {{date}}",
"session_end": "Sign out",
"logout_all": "Sign out everywhere"
},
"form": {
"show_password": "Show password",
"hide_password": "Hide password",
"passwords_mismatch": "Passwords don't match",
"required": "Fill in this field",
"cancel": "Cancel",
"close": "Close",
"copy": "Copy",
"copied": "Copied"
},
"loading": "Loading…",
"route_error": {
"reload": "Reload the page"
},
"search": {
"placeholder": "Artists, albums, tracks",
"label": "Search",
"artists": "Artists",
"albums": "Albums",
"tracks": "Tracks",
"all": "All",
"nothing": "Nothing found",
"corrected": "Showing results for “{{query}}”",
"show_all": "All results",
"title": "Search: {{query}}",
"more": "Show more",
"artist_kind": "Artist",
"hint": "Press / to search"
},
"catalog": {
"albums": "Albums",
"appears_on": "Appears on",
"album_count_one": "{{count}} album",
"album_count_other": "{{count}} albums",
"album_count_few": "{{count}} albums",
"album_count_many": "{{count}} albums",
"track_count_one": "{{count}} track",
"track_count_other": "{{count}} tracks",
"track_count_few": "{{count}} tracks",
"track_count_many": "{{count}} tracks",
"unavailable": "Unavailable: storage is offline or the file is gone",
"not_found": "Nothing here — it may have been removed by the last scan",
"disc": "Disc {{n}}",
"col_title": "Title",
"col_album": "Album",
"col_duration": "Time",
"back_home": "Back home",
"artist": "Artist",
"album": "Album"
},
"collection": {
"title": "Collection",
"history": "History",
"likes": "Likes",
"dislikes": "Dislikes",
"clear_history": "Clear history",
"clear_confirm": "Clear your listening history? Recommendations will still take it into account.",
"today": "Today",
"yesterday": "Yesterday",
"empty_history": "What you listen to will show up here",
"empty_likes": "Tap the heart on tracks — they will gather here",
"empty_dislikes": "Disliked tracks stay out of the wave. None so far",
"play_all": "Play all",
"more": "Show more"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"common": {
"internal": "Something went wrong on the server. Please try again",
"bad_request": "Invalid request",
"not_found": "Not found",
"method_not_allowed": "This action is not supported",
"network": "Cannot reach the server. Check your network"
},
"auth": {
"invalid_credentials": "Wrong username or password",
"too_many_attempts": "Too many sign-in attempts. Please wait a bit",
"user_blocked": "This account is blocked. Contact your administrator",
"unauthorized": "Sign in to continue",
"token_expired": "Your session has expired, please sign in again",
"token_invalid": "Your session is invalid, please sign in again",
"token_revoked": "Your session has ended, please sign in again",
"refresh_invalid": "Your session has ended, please sign in again",
"refresh_reused": "Your session was ended for security reasons, please sign in again",
"refresh_race": "Your session is being refreshed, please retry",
"credentials_change_required": "Change the administrator username and password first",
"password_change_required": "Change your temporary password first",
"current_password_required": "Enter your current password",
"current_password_invalid": "The current password is wrong",
"bootstrap_not_required": "The administrator credentials have already been changed",
"session_not_found": "Session not found",
"forbidden": "You don't have permission to do this"
},
"validation": {
"password_too_short": "The password must be at least {{min}} characters",
"password_too_long": "The password must be at most {{max}} characters",
"password_same_as_username": "The password must not match the username",
"password_too_common": "This password is too common — choose another one",
"password_unchanged": "The new password must differ from the current one",
"username_invalid": "Username: {{min}} to {{max}} characters — Latin letters, digits, \".\", \"_\", \"-\"",
"username_taken": "This username is already taken",
"locale_unsupported": "This language is not supported",
"display_name_too_long": "The name must be at most {{max}} characters",
"ui_prefs_invalid": "Invalid setting value",
"client_type_invalid": "Unknown app type",
"role_invalid": "Unknown role",
"cursor_invalid": "The list is outdated, please reload",
"pattern_invalid": "Pattern error: {{reason}}",
"nfs_invalid": "Invalid value",
"local_path_invalid": "Folder not found",
"name_invalid": "The name must be 1 to {{max}} characters",
"scan_interval_invalid": "The interval must be at least {{min}} minutes",
"source_type_invalid": "Unknown storage type",
"query_empty": "Enter a query"
},
"users": {
"not_found": "User not found",
"last_admin": "The last administrator cannot be demoted, blocked or deleted"
},
"library": {
"source_not_found": "Storage not found",
"scan_running": "A scan is already running",
"source_offline": "The storage is not connected"
},
"catalog": {
"not_found": "Not found"
},
"search": {
"unavailable": "Search is temporarily unavailable"
},
"playback": {
"link_invalid": "The link has expired — reload the page",
"track_unavailable": "Track is unavailable",
"transcode_failed": "Could not prepare the track"
}
}
+153
View File
@@ -0,0 +1,153 @@
{
"users": {
"title": "Пользователи",
"subtitle": "Создание пользователей и модераторов, роли, сброс пароля, блокировка",
"create": "Создать пользователя",
"search": "Имя или логин",
"all": "Все",
"col_user": "Пользователь",
"col_role": "Роль",
"col_status": "Статус",
"col_last_login": "Был в сети",
"status_active": "Активен",
"status_blocked": "Заблокирован",
"status_must_change": "Ждёт смены пароля",
"never": "Ещё не входил",
"empty": "Никого не нашли",
"more": "Показать ещё",
"actions": "Действия с {{username}}",
"reset_password": "Сбросить пароль",
"block": "Заблокировать",
"unblock": "Разблокировать",
"make_role": "Сделать: {{role}}",
"delete": "Удалить",
"delete_confirm": "Удалить {{username}}? Его история и лайки пропадут.",
"block_confirm": "Заблокировать {{username}}? Все его сессии завершатся.",
"reset_confirm": "Сбросить пароль {{username}}? Все его сессии завершатся.",
"confirm": "Подтвердить",
"you": "вы"
},
"create": {
"title": "Новый пользователь",
"username": "Логин",
"username_hint": "Латинские буквы, цифры, «.», «_», «-»",
"display_name": "Имя",
"role": "Роль",
"password_note": "Пароль сгенерирует сервер и покажет один раз.",
"submit": "Создать"
},
"temp_password": {
"title": "Временный пароль",
"text": "Передайте пароль пользователю {{username}}. Он виден только сейчас; при входе его попросят сменить.",
"done": "Готово"
},
"audit": {
"title": "Журнал",
"subtitle": "Действия администраторов и модераторов",
"col_when": "Когда",
"col_who": "Кто",
"col_action": "Действие",
"col_details": "Подробности",
"system": "Система",
"empty": "Записей пока нет",
"more": "Показать ещё",
"actions": {
"user.bootstrap_create": "Создан стартовый администратор",
"user.bootstrap_credentials": "Заданы учётные данные администратора",
"user.create": "Создан пользователь",
"user.rename": "Изменено имя",
"user.role_change": "Изменена роль",
"user.block": "Пользователь заблокирован",
"user.unblock": "Пользователь разблокирован",
"user.password_reset": "Сброшен пароль",
"user.delete": "Пользователь удалён"
}
},
"sources": {
"title": "Хранилища",
"subtitle": "Откуда сервер берёт музыку: папка на диске сервера или NFS",
"add": "Подключить хранилище",
"empty": "Хранилищ пока нет. Подключите папку с музыкой, чтобы начать.",
"type_local": "Папка на сервере",
"type_nfs": "NFS",
"status_pending": "Подключается",
"status_mounted": "Подключено",
"status_error": "Ошибка",
"status_disabled": "Отключено",
"last_scan": "Последний скан: {{date}}",
"never_scanned": "Ещё не сканировалось",
"scan": "Сканировать",
"full_scan": "Полный скан",
"test": "Проверить",
"patterns": "Шаблоны путей",
"edit": "Изменить",
"delete": "Удалить",
"disable": "Отключить",
"enable": "Подключить",
"delete_confirm": "Удалить «{{name}}»? Треки, которые есть только в этом хранилище, пропадут из библиотеки вместе с лайками.",
"scanning": "Сканирование: {{processed}} из {{total}}",
"scan_walking": "Обход папок…",
"scan_done": "Найдено {{found}} · новых {{added}} · обновлено {{updated}} · перемещено {{moved}} · пропало {{missing}} · ошибок {{errors}}",
"scan_failed": "Скан прервался: {{error}}",
"test_ok": "Доступно, найдено аудиофайлов: {{count}}",
"test_fail": "Нет доступа: {{message}}",
"form_title_new": "Новое хранилище",
"form_title_edit": "Хранилище",
"name": "Название",
"type": "Тип",
"folder": "Папка",
"folder_root": "Весь каталог",
"folder_hint": "Подпапка каталога LIBRARY_HOST на сервере",
"up": "Вверх",
"nfs_server": "Сервер",
"nfs_server_hint": "Имя или IP, например nas.lan",
"nfs_export": "Экспорт",
"nfs_export_hint": "Путь на сервере, например /export/music",
"nfs_options": "Опции монтирования",
"nfs_options_hint": "Только чтение добавляется автоматически",
"tag_priority": "Что важнее",
"tags_first": "Теги файлов",
"path_first": "Шаблоны путей",
"interval": "Автоскан",
"interval_off": "Вручную",
"interval_hours": "Каждые {{count}} ч",
"save": "Сохранить"
},
"patterns": {
"title": "Шаблоны путей: {{name}}",
"intro": "Регулярные выражения RE2 с именованными группами применяются к пути файла относительно корня хранилища. Побеждает первое совпавшее.",
"groups": "Группы: artist, albumartist, album, year, disc, track, title, genre; skip — игнорировать файл.",
"add": "Добавить шаблон",
"regex": "Шаблон {{n}}",
"enabled": "Включён",
"up": "Выше",
"down": "Ниже",
"remove": "Удалить",
"preview": "Предпросмотр",
"save": "Сохранить",
"saved": "Сохранено. Запустите полный скан, чтобы применить к уже найденным файлам.",
"col_path": "Путь",
"col_pattern": "Шаблон",
"col_result": "Результат",
"no_match": "не совпал",
"skipped": "пропускается",
"unmatched": "Не совпал ни один шаблон: {{count}} из {{total}}",
"from_tags": "из тегов",
"error_at": "Ошибка в шаблоне {{n}}: {{reason}}",
"examples": "Примеры"
},
"library": {
"title": "Библиотека",
"subtitle": "Состояние каталога и сканирования",
"artists": "Исполнителей",
"albums": "Альбомов",
"tracks": "Треков",
"files": "Файлов",
"missing": "Пропавших файлов",
"errors": "Не прочитано",
"sources": "Хранилища",
"error_files": "Файлы с ошибками",
"reindex": "Переиндексировать поиск",
"reindex_started": "Переиндексация запущена"
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"login": {
"title": "Вход",
"subtitle": "Ваша музыка с вашего сервера",
"username": "Логин",
"password": "Пароль",
"submit": "Войти",
"bootstrap_hint": "Первый запуск: войдите как администратор. Пароль задан в BOOTSTRAP_ADMIN_PASSWORD или напечатан в логе сервиса api."
},
"setup": {
"admin_badge": "Первый вход администратора",
"admin_title": "Смените логин и пароль",
"admin_text": "Без этого шага доступ к серверу закрыт: стартовые данные администратора видны в логах и настройках.",
"password_badge": "Пароль сброшен",
"password_title": "Задайте новый пароль",
"password_text": "Вы вошли по временному паролю. Придумайте свой — временный больше не подойдёт.",
"new_username": "Новый логин",
"new_password": "Новый пароль",
"repeat_password": "Повторите пароль",
"min_length": "Не короче {{min}} символов",
"submit": "Сохранить и продолжить",
"logout": "Выйти"
}
}
+138
View File
@@ -17,5 +17,143 @@
"label": "Язык",
"ru": "Русский",
"en": "English"
},
"nav": {
"main": "Навигация",
"home": "Главная",
"admin": "Админка",
"users": "Пользователи",
"audit": "Журнал",
"library": "Библиотека",
"sources": "Хранилища",
"collection": "Коллекция",
"history": "История",
"likes": "Лайки",
"dislikes": "Дизлайки"
},
"profile": {
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти"
},
"roles": {
"admin": "Администратор",
"moderator": "Модератор",
"user": "Пользователь"
},
"home": {
"greeting": "Привет, {{name}}!",
"wave": "Моя волна",
"wave_soon": "Бесконечная лента под ваш вкус появится, когда сервер подключит библиотеку."
},
"player": {
"empty": "Ничего не играет",
"label": "Плеер",
"play": "Слушать",
"pause": "Пауза",
"next": "Следующий",
"prev": "Предыдущий",
"like": "Нравится",
"unlike": "Убрать из лайков",
"dislike": "Не нравится",
"undislike": "Убрать дизлайк",
"volume": "Громкость",
"mute": "Выключить звук",
"unmute": "Включить звук",
"shuffle": "Перемешать",
"repeat_off": "Повтор выключен",
"repeat_all": "Повторять очередь",
"repeat_one": "Повторять трек",
"progress": "Позиция",
"unavailable": "Трек недоступен — пропускаем",
"error": "Не удалось воспроизвести трек"
},
"settings": {
"title": "Настройки",
"account": "Аккаунт",
"display_name": "Отображаемое имя",
"save": "Сохранить",
"saved": "Сохранено",
"appearance": "Внешний вид",
"theme": "Тема",
"theme_dark": "Тёмная",
"theme_light": "Светлая",
"theme_system": "Как в системе",
"password": "Пароль",
"current_password": "Текущий пароль",
"new_password": "Новый пароль",
"repeat_password": "Повторите пароль",
"change_password": "Сменить пароль",
"password_changed": "Пароль изменён. Другие устройства вышли из аккаунта.",
"sessions": "Устройства",
"session_current": "Это устройство",
"session_last_used": "Активность: {{date}}",
"session_end": "Завершить",
"logout_all": "Выйти на всех устройствах"
},
"form": {
"show_password": "Показать пароль",
"hide_password": "Скрыть пароль",
"passwords_mismatch": "Пароли не совпадают",
"required": "Заполните поле",
"cancel": "Отмена",
"close": "Закрыть",
"copy": "Копировать",
"copied": "Скопировано"
},
"loading": "Загрузка…",
"route_error": {
"reload": "Обновить страницу"
},
"search": {
"placeholder": "Исполнители, альбомы, треки",
"label": "Поиск",
"artists": "Исполнители",
"albums": "Альбомы",
"tracks": "Треки",
"all": "Всё",
"nothing": "Ничего не нашли",
"corrected": "Показаны результаты для «{{query}}»",
"show_all": "Все результаты",
"title": "Поиск: {{query}}",
"more": "Показать ещё",
"artist_kind": "Исполнитель",
"hint": "Нажмите / для поиска"
},
"catalog": {
"albums": "Альбомы",
"appears_on": "Встречается на",
"album_count_one": "{{count}} альбом",
"album_count_few": "{{count}} альбома",
"album_count_many": "{{count}} альбомов",
"album_count_other": "{{count}} альбома",
"track_count_one": "{{count}} трек",
"track_count_few": "{{count}} трека",
"track_count_many": "{{count}} треков",
"track_count_other": "{{count}} трека",
"unavailable": "Недоступен: хранилище отключено или файл пропал",
"not_found": "Ничего не найдено — возможно, удалено при последнем скане",
"disc": "Диск {{n}}",
"col_title": "Название",
"col_album": "Альбом",
"col_duration": "Время",
"back_home": "На главную",
"artist": "Исполнитель",
"album": "Альбом"
},
"collection": {
"title": "Коллекция",
"history": "История",
"likes": "Лайки",
"dislikes": "Дизлайки",
"clear_history": "Очистить историю",
"clear_confirm": "Очистить историю прослушиваний? Рекомендации её всё равно учтут.",
"today": "Сегодня",
"yesterday": "Вчера",
"empty_history": "Здесь появится то, что вы слушали",
"empty_likes": "Отмечайте треки сердечком — они соберутся здесь",
"empty_dislikes": "Треки с дизлайком не попадут в волну. Пока таких нет",
"play_all": "Слушать всё",
"more": "Показать ещё"
}
}
+70
View File
@@ -0,0 +1,70 @@
{
"common": {
"internal": "Что-то пошло не так на сервере. Попробуйте ещё раз",
"bad_request": "Некорректный запрос",
"not_found": "Не найдено",
"method_not_allowed": "Действие не поддерживается",
"network": "Нет связи с сервером. Проверьте сеть"
},
"auth": {
"invalid_credentials": "Неверный логин или пароль",
"too_many_attempts": "Слишком много попыток входа. Подождите немного",
"user_blocked": "Учётная запись заблокирована. Обратитесь к администратору",
"unauthorized": "Войдите, чтобы продолжить",
"token_expired": "Сессия истекла, войдите снова",
"token_invalid": "Сессия недействительна, войдите снова",
"token_revoked": "Сессия завершена, войдите снова",
"refresh_invalid": "Сессия завершена, войдите снова",
"refresh_reused": "Сессия завершена из соображений безопасности, войдите снова",
"refresh_race": "Сессия обновляется, повторите действие",
"credentials_change_required": "Сначала смените логин и пароль администратора",
"password_change_required": "Сначала смените временный пароль",
"current_password_required": "Введите текущий пароль",
"current_password_invalid": "Текущий пароль указан неверно",
"bootstrap_not_required": "Учётные данные администратора уже изменены",
"session_not_found": "Сессия не найдена",
"forbidden": "Недостаточно прав для этого действия"
},
"validation": {
"password_too_short": "Пароль должен быть не короче {{min}} символов",
"password_too_long": "Пароль должен быть не длиннее {{max}} символов",
"password_same_as_username": "Пароль не должен совпадать с логином",
"password_too_common": "Этот пароль слишком распространён — придумайте другой",
"password_unchanged": "Новый пароль должен отличаться от текущего",
"username_invalid": "Логин: от {{min}} до {{max}} символов — латинские буквы, цифры, «.», «_», «-»",
"username_taken": "Этот логин уже занят",
"locale_unsupported": "Этот язык не поддерживается",
"display_name_too_long": "Имя должно быть не длиннее {{max}} символов",
"ui_prefs_invalid": "Недопустимое значение настройки",
"client_type_invalid": "Неизвестный тип приложения",
"role_invalid": "Неизвестная роль",
"cursor_invalid": "Список устарел, обновите страницу",
"pattern_invalid": "Ошибка в шаблоне: {{reason}}",
"nfs_invalid": "Недопустимое значение",
"local_path_invalid": "Папка не найдена",
"name_invalid": "Название — от 1 до {{max}} символов",
"scan_interval_invalid": "Интервал — не меньше {{min}} минут",
"source_type_invalid": "Неизвестный тип хранилища",
"query_empty": "Введите запрос"
},
"users": {
"not_found": "Пользователь не найден",
"last_admin": "Нельзя понизить, заблокировать или удалить последнего администратора"
},
"library": {
"source_not_found": "Хранилище не найдено",
"scan_running": "Скан уже идёт",
"source_offline": "Хранилище не подключено"
},
"catalog": {
"not_found": "Не найдено"
},
"search": {
"unavailable": "Поиск временно недоступен"
},
"playback": {
"link_invalid": "Ссылка устарела — обновите страницу",
"track_unavailable": "Трек недоступен",
"transcode_failed": "Не удалось подготовить трек"
}
}
+9 -3
View File
@@ -1,7 +1,13 @@
import i18next, { type i18n as I18n } from 'i18next';
import enAdmin from '../locales/en/admin.json';
import enAuth from '../locales/en/auth.json';
import enCommon from '../locales/en/common.json';
import enErrors from '../locales/en/errors.json';
import ruAdmin from '../locales/ru/admin.json';
import ruAuth from '../locales/ru/auth.json';
import ruCommon from '../locales/ru/common.json';
import ruErrors from '../locales/ru/errors.json';
/** Поддерживаемые языки. Новый язык = папка в locales/ + строка здесь. */
export const SUPPORTED_LOCALES = ['ru', 'en'] as const;
@@ -9,8 +15,8 @@ export type Locale = (typeof SUPPORTED_LOCALES)[number];
export const DEFAULT_LOCALE: Locale = 'en';
export const resources = {
ru: { common: ruCommon },
en: { common: enCommon },
ru: { common: ruCommon, auth: ruAuth, admin: ruAdmin, errors: ruErrors },
en: { common: enCommon, auth: enAuth, admin: enAdmin, errors: enErrors },
} as const;
declare module 'i18next' {
@@ -38,7 +44,7 @@ export async function createI18n(locale: Locale): Promise<I18n> {
fallbackLng: DEFAULT_LOCALE,
supportedLngs: [...SUPPORTED_LOCALES],
defaultNS: 'common',
ns: ['common'],
ns: ['common', 'auth', 'admin', 'errors'],
resources,
interpolation: { escapeValue: false },
returnNull: false,
+163 -8
View File
@@ -69,6 +69,9 @@ importers:
'@fontsource/unbounded':
specifier: ^5.2.6
version: 5.3.0
'@material-symbols/svg-400':
specifier: ^0.31.9
version: 0.31.9
'@pipu/api-client':
specifier: workspace:*
version: link:../../packages/api-client
@@ -84,6 +87,9 @@ importers:
'@tanstack/react-query':
specifier: ^5.80.7
version: 5.104.4(@types/react@19.3.0)(react@19.3.1)
'@tanstack/react-router':
specifier: ^1.120.20
version: 1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
react:
specifier: ^19.1.0
version: 19.3.1
@@ -97,9 +103,15 @@ importers:
'@testing-library/dom':
specifier: ^10.4.0
version: 10.4.2
'@testing-library/jest-dom':
specifier: ^6.9.1
version: 6.9.1
'@testing-library/react':
specifier: ^16.3.0
version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.2)
'@types/react':
specifier: ^19.1.8
version: 19.3.0
@@ -111,13 +123,13 @@ importers:
version: 4.7.0(vite@6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2))
jsdom:
specifier: ^26.1.0
version: 26.1.0
version: 26.1.0(supports-color@10.2.2)
vite:
specifier: ^6.3.5
version: 6.4.4(@types/node@26.6.5)(lightningcss@1.27.0)(terser@5.51.2)
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
packages/api-client:
dependencies:
@@ -137,7 +149,7 @@ importers:
devDependencies:
vitest:
specifier: ^3.2.4
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
version: 3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2)
packages/design-tokens: {}
@@ -157,6 +169,9 @@ packages:
graphql:
optional: true
'@adobe/css-tools@4.5.0':
resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==}
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
@@ -985,6 +1000,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@material-symbols/svg-400@0.31.9':
resolution: {integrity: sha512-LeQrY/qYdRdIlV6ZR+hUQDXjtkDYgP1LGnMQh/Grm6FsXz+6GR2Q+DrotEJjzDnmao/oHygHFGLnkKAR/PM1hQ==}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
engines: {node: ^22.20 || ^24.12 || >=25}
@@ -1215,6 +1233,10 @@ packages:
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
'@tanstack/history@1.120.17':
resolution: {integrity: sha512-k07LFI4Qo074IIaWzT/XjD0KlkGx2w1V3fnNtclKx0oAl8z4O9kCh6za+FPEIRe98xLgNFEiddDbJeAYGSlPtw==}
engines: {node: '>=12'}
'@tanstack/query-core@5.104.4':
resolution: {integrity: sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==}
@@ -1227,10 +1249,34 @@ packages:
'@types/react':
optional: true
'@tanstack/react-router@1.120.20':
resolution: {integrity: sha512-+zNruUE9NsfGm9cHd22Xs7FRtBrBhDZe94pB69BEIjqjrEPZct6f5VhTV9WQ+bDZ6fRz8tUuxNFAgm/3Lm4AIg==}
engines: {node: '>=12'}
peerDependencies:
react: '>=18.0.0 || >=19.0.0'
react-dom: '>=18.0.0 || >=19.0.0'
'@tanstack/react-store@0.7.7':
resolution: {integrity: sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/router-core@1.120.19':
resolution: {integrity: sha512-5JUVgkxnIM3NxMwzKt0tfz2UopZVxwq6Kl7Rp33zlFJaPjpiRs46VuRjVeAvkpJd6samo1gcH1rWqmnPUmtGcw==}
engines: {node: '>=12'}
'@tanstack/store@0.7.7':
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
'@testing-library/dom@10.4.2':
resolution: {integrity: sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==}
engines: {node: '>=18'}
'@testing-library/jest-dom@6.9.1':
resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
'@testing-library/react@16.3.3':
resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==}
engines: {node: '>=18'}
@@ -1246,6 +1292,12 @@ packages:
'@types/react-dom':
optional: true
'@testing-library/user-event@14.6.1':
resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
engines: {node: '>=12', npm: '>=6'}
peerDependencies:
'@testing-library/dom': '>=7.21.4'
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1705,6 +1757,9 @@ packages:
resolution: {integrity: sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA==}
engines: {node: '>=8'}
css.escape@1.5.1:
resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
@@ -1783,6 +1838,9 @@ packages:
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
dom-accessibility-api@0.6.3:
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
dotenv-expand@11.0.7:
resolution: {integrity: sha512-zIHwmZPRshsCdpMDyVsqGmgyP0yT8GAgXUnkdAoJisxvf33k7yO6OuoKmcTGuXPWSsm8Oh88nZicRLA9Y0rUeA==}
engines: {node: '>=12'}
@@ -2121,6 +2179,10 @@ packages:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
engines: {node: '>=0.8.19'}
indent-string@4.0.0:
resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
engines: {node: '>=8'}
index-to-position@1.2.0:
resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==}
engines: {node: '>=18'}
@@ -2487,6 +2549,10 @@ packages:
resolution: {integrity: sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==}
engines: {node: '>=4'}
min-indent@1.0.1:
resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
engines: {node: '>=4'}
minimatch@3.1.5:
resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==}
@@ -2838,6 +2904,10 @@ packages:
resolution: {integrity: sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==}
engines: {node: '>=0.10.0'}
redent@3.0.0:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
regenerate-unicode-properties@10.3.0:
resolution: {integrity: sha512-9ns8odR8e9q3otKeHj3DE80jZYKg6zVeZKE+zQmRKXFoVUcuiTGeO6iusDhPRoIQQPVPRvH+SfBhrTud4eczuw==}
engines: {node: '>=4'}
@@ -3058,6 +3128,10 @@ packages:
resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==}
engines: {node: '>=12'}
strip-indent@3.0.0:
resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
engines: {node: '>=8'}
strip-json-comments@2.0.1:
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
engines: {node: '>=0.10.0'}
@@ -3131,6 +3205,12 @@ packages:
throat@5.0.0:
resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
@@ -3277,6 +3357,11 @@ packages:
uri-js-replace@1.0.1:
resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==}
use-sync-external-store@1.7.0:
resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
@@ -3528,6 +3613,8 @@ snapshots:
'@0no-co/graphql.web@1.3.4': {}
'@adobe/css-tools@4.5.0': {}
'@asamuzakjp/css-color@3.2.0':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
@@ -4590,6 +4677,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
'@material-symbols/svg-400@0.31.9': {}
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
optional: true
@@ -4826,6 +4915,8 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
'@tanstack/history@1.120.17': {}
'@tanstack/query-core@5.104.4': {}
'@tanstack/react-query@5.104.4(@types/react@19.3.0)(react@19.3.1)':
@@ -4835,6 +4926,32 @@ snapshots:
optionalDependencies:
'@types/react': 19.3.0
'@tanstack/react-router@1.120.20(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@tanstack/history': 1.120.17
'@tanstack/react-store': 0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)
'@tanstack/router-core': 1.120.19
jsesc: 3.1.0
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
tiny-invariant: 1.3.3
tiny-warning: 1.0.3
'@tanstack/react-store@0.7.7(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@tanstack/store': 0.7.7
react: 19.3.1
react-dom: 19.3.1(react@19.3.1)
use-sync-external-store: 1.7.0(react@19.3.1)
'@tanstack/router-core@1.120.19':
dependencies:
'@tanstack/history': 1.120.17
'@tanstack/store': 0.7.7
tiny-invariant: 1.3.3
'@tanstack/store@0.7.7': {}
'@testing-library/dom@10.4.2':
dependencies:
'@babel/code-frame': 7.29.7
@@ -4846,6 +4963,15 @@ snapshots:
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/jest-dom@6.9.1':
dependencies:
'@adobe/css-tools': 4.5.0
aria-query: 5.3.0
css.escape: 1.5.1
dom-accessibility-api: 0.6.3
picocolors: 1.1.1
redent: 3.0.0
'@testing-library/react@16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.1(react@19.3.1))(react@19.3.1)':
dependencies:
'@babel/runtime': 7.29.10
@@ -4856,6 +4982,10 @@ snapshots:
'@types/react': 19.3.0
'@types/react-dom': 19.3.0(@types/react@19.3.0)
'@testing-library/user-event@14.6.1(@testing-library/dom@10.4.2)':
dependencies:
'@testing-library/dom': 10.4.2
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
@@ -5395,6 +5525,8 @@ snapshots:
crypto-random-string@2.0.0: {}
css.escape@1.5.1: {}
cssstyle@4.6.0:
dependencies:
'@asamuzakjp/css-color': 3.2.0
@@ -5445,6 +5577,8 @@ snapshots:
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
dotenv-expand@11.0.7:
dependencies:
dotenv: 16.4.7
@@ -5753,7 +5887,7 @@ snapshots:
statuses: 2.0.2
toidentifier: 1.0.1
http-proxy-agent@7.0.2:
http-proxy-agent@7.0.2(supports-color@10.2.2):
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@10.2.2)
@@ -5792,6 +5926,8 @@ snapshots:
imurmurhash@0.1.4: {}
indent-string@4.0.0: {}
index-to-position@1.2.0: {}
inflight@1.0.6:
@@ -5938,13 +6074,13 @@ snapshots:
jsc-safe-url@0.2.4: {}
jsdom@26.1.0:
jsdom@26.1.0(supports-color@10.2.2):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
http-proxy-agent: 7.0.2
http-proxy-agent: 7.0.2(supports-color@10.2.2)
https-proxy-agent: 7.0.6(supports-color@10.2.2)
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.28
@@ -6269,6 +6405,8 @@ snapshots:
mimic-fn@1.2.0: {}
min-indent@1.0.1: {}
minimatch@3.1.5:
dependencies:
brace-expansion: 1.1.21
@@ -6625,6 +6763,11 @@ snapshots:
react@19.3.1: {}
redent@3.0.0:
dependencies:
indent-string: 4.0.0
strip-indent: 3.0.0
regenerate-unicode-properties@10.3.0:
dependencies:
regenerate: 1.5.0
@@ -6852,6 +6995,10 @@ snapshots:
dependencies:
ansi-regex: 6.4.0
strip-indent@3.0.0:
dependencies:
min-indent: 1.0.1
strip-json-comments@2.0.1: {}
strip-literal@3.1.0:
@@ -6931,6 +7078,10 @@ snapshots:
throat@5.0.0: {}
tiny-invariant@1.3.3: {}
tiny-warning@1.0.3: {}
tinybench@2.9.0: {}
tinyexec@0.3.2: {}
@@ -7036,6 +7187,10 @@ snapshots:
uri-js-replace@1.0.1: {}
use-sync-external-store@1.7.0(react@19.3.1):
dependencies:
react: 19.3.1
utils-merge@1.0.1: {}
uuid@7.0.3: {}
@@ -7079,7 +7234,7 @@ snapshots:
lightningcss: 1.27.0
terser: 5.51.2
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0)(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
vitest@3.2.7(@types/node@26.6.5)(jsdom@26.1.0(supports-color@10.2.2))(lightningcss@1.27.0)(supports-color@10.2.2)(terser@5.51.2):
dependencies:
'@types/chai': 5.2.3
'@vitest/expect': 3.2.7
@@ -7106,7 +7261,7 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 26.6.5
jsdom: 26.1.0
jsdom: 26.1.0(supports-color@10.2.2)
transitivePeerDependencies:
- jiti
- less
+7
View File
@@ -5,3 +5,10 @@ packages:
# Пакеты, которым разрешено выполнять install-скрипты
allowBuilds:
esbuild: true
# Изолированная раскладка node_modules (pnpm 11). Expo вычисляет react-native.config.js пакета expo
# по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking')
# находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт
# expo.core.ExpoModulesPackage и release-сборка APK падает. Прямая зависимость не подходит: её запрещает expo-doctor.
publicHoistPattern:
- expo-modules-autolinking
+265
View File
@@ -0,0 +1,265 @@
#!/usr/bin/env node
// Релиз в Gitea (docs/ci-cd.md#релиз): сначала создаётся релиз с заметками, потом сборка Android
// докладывает в него APK и переписывает блок статуса в тексте.
// node scripts/release.mjs create — создать релиз по тегу (при перезапуске — обновить)
// node scripts/release.mjs android building|failure|cancelled — статус сборки APK в тексте релиза
// node scripts/release.mjs android success <file.apk> — приложить APK и .sha256, статус «собран»
// Окружение: GITEA_API (<server>/api/v1/repos/<owner>/<repo>), GITEA_TOKEN, TAG, VERSION, VERSION_CODE,
// PRERELEASE (true|false), GITHUB_RUN_ID; для success — SIGNING (release|debug).
// PUBLIC_URL — внешний адрес Gitea для ссылок в тексте (job ходит в API по внутреннему адресу, и html_url
// из ответа указывает на него же).
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { basename, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const SECTIONS = [
['feat', 'Новое'],
['fix', 'Исправления'],
['perf', 'Производительность'],
['refactor', 'Рефакторинг'],
['docs', 'Документация'],
['build', 'Сборка и CI'],
['ci', 'Сборка и CI'],
];
const OTHER = 'Прочее';
const COMMIT_RE = /^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$/;
export const STATUS_START = '<!-- android-status -->';
export const STATUS_END = '<!-- /android-status -->';
/** Раскладывает коммиты (Conventional Commits) по разделам заметок. */
export function groupCommits(commits) {
const breaking = [];
const byTitle = new Map();
for (const { sha, subject } of commits) {
const m = COMMIT_RE.exec(subject);
const item = m ? { sha, scope: m[2] ?? null, text: m[4] } : { sha, scope: null, text: subject };
if (m?.[3]) breaking.push(item);
const title = (m && SECTIONS.find(([t]) => t === m[1])?.[1]) ?? OTHER;
if (!byTitle.has(title)) byTitle.set(title, []);
byTitle.get(title).push(item);
}
const order = [...new Set(SECTIONS.map(([, t]) => t)), OTHER];
return { breaking, sections: order.filter((t) => byTitle.has(t)).map((t) => [t, byTitle.get(t)]) };
}
const formatSize = (bytes) => `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} МБ`;
/** Блок статуса сборки Android в тексте релиза. */
export function renderAndroidStatus({ status, runUrl, versionCode, apk, signing }) {
const lines =
status === 'success'
? [
`✅ **Android:** APK собран — \`${apk.name}\` (${formatSize(apk.size)}), versionCode \`${versionCode}\`.`,
`SHA-256: \`${apk.sha256}\``,
...(signing === 'debug'
? ['⚠️ Подписан отладочным ключом: не установится поверх версии с релизной подписью.']
: []),
]
: status === 'cancelled'
? [
`⏹ **Android:** сборка APK отменена — [лог сборки](${runUrl}).`,
'APK появится здесь после перезапуска сборки.',
]
: status === 'failure'
? [
`❌ **Android:** сборка APK не удалась — [лог сборки](${runUrl}).`,
'Серверная часть релиза от этого не зависит; APK появится здесь после перезапуска сборки.',
]
: [
`⏳ **Android:** APK собирается — [ход сборки](${runUrl}).`,
'Файл появится в этом релизе автоматически, а этот текст обновится.',
];
return [STATUS_START, ...lines.map((l) => `> ${l}`), STATUS_END].join('\n');
}
/** Заменяет блок статуса в тексте релиза (или дописывает его в начало, если блока нет). */
export function replaceAndroidStatus(body, block) {
const start = body.indexOf(STATUS_START);
const end = body.indexOf(STATUS_END);
if (start === -1 || end === -1 || end < start) return `${block}\n\n${body}`;
return body.slice(0, start) + block + body.slice(end + STATUS_END.length);
}
/** Текст релиза: статус Android, изменения, обновление сервера, установка APK. */
export function renderNotes({ version, tag, prevTag, prerelease, repoUrl, groups, androidStatus }) {
const link = (it) => `([${it.sha.slice(0, 7)}](${repoUrl}/commit/${it.sha}))`;
const item = (it) => `- ${it.scope ? `**${it.scope}:** ` : ''}${it.text} ${link(it)}`;
const out = [];
if (prerelease) {
out.push('> 🧪 **Предварительная версия** — для проверки перед релизом, не для основной установки.', '');
}
out.push(androidStatus, '', '## Что изменилось', '');
if (groups.breaking.length) {
out.push('### ⚠️ Несовместимые изменения', '', ...groups.breaking.map(item), '');
}
if (groups.sections.length) {
for (const [title, items] of groups.sections) out.push(`### ${title}`, '', ...items.map(item), '');
} else {
out.push('Изменений с прошлой версии нет.', '');
}
out.push(
prevTag
? `Все изменения: [${prevTag}...${tag}](${repoUrl}/compare/${prevTag}...${tag})`
: `Все изменения: [история ${tag}](${repoUrl}/commits/tag/${tag})`,
'',
'## Обновление сервера',
'',
'Перед обновлением сделайте бэкап базы:',
'',
'```bash',
'cd /opt/pipu-music/deploy && docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump',
'```',
'',
'Обновление:',
'',
'```bash',
`cd /opt/pipu-music/deploy && ./update.sh ${tag}`,
'```',
'',
`Скрипт переключит репозиторий на \`${tag}\`, пересоберёт образы и перезапустит сервисы; миграции базы`,
'применяются при старте API. Проверка — в ответе должна быть новая версия:',
'',
'```bash',
'curl -s https://<ваш-домен>/api/v1/server-info',
'```',
'',
`Ожидается \`"version":"${version}"\`.`,
'',
'## Android',
'',
`- **Установка или обновление:** скачайте \`pipu-music-${version}.apk\` ниже и откройте его на телефоне;`,
' Android попросит разрешить установку из этого источника. Обновление ставится поверх, данные сохраняются.',
`- **Проверка файла:** \`sha256sum pipu-music-${version}.apk\` должен совпасть с \`pipu-music-${version}.apk.sha256\`.`,
'',
`Первая установка сервера — [инструкция](${repoUrl}/src/tag/${tag}/docs/install-guide.md).`,
'',
);
return out.join('\n');
}
// ---------------------------------------------------------------------------------------------------------
// CLI
function env(name) {
const v = process.env[name];
if (!v) throw new Error(`не задана переменная окружения ${name}`);
return v;
}
async function api(method, path, { json, form } = {}) {
const res = await fetch(`${env('GITEA_API')}${path}`, {
method,
headers: {
Authorization: `token ${env('GITEA_TOKEN')}`,
...(json ? { 'Content-Type': 'application/json' } : {}),
},
body: json ? JSON.stringify(json) : form,
});
if (res.status === 404 && method === 'GET') return null;
if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`);
return res.status === 204 ? null : res.json();
}
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
function previousTag(tag, prerelease) {
// для финального релиза — изменения с прошлого финального, а не с последнего rc
const exclude = prerelease ? [] : ['--exclude', '*-rc*'];
try {
return git('describe', '--tags', '--abbrev=0', ...exclude, `${tag}^`);
} catch {
return null;
}
}
function commitsBetween(prevTag, tag) {
const out = git('log', '--no-merges', '--format=%H%x09%s', prevTag ? `${prevTag}..${tag}` : tag);
return out
? out.split('\n').map((l) => {
const [sha, ...rest] = l.split('\t');
return { sha, subject: rest.join('\t') };
})
: [];
}
async function context() {
const repo = await api('GET', '');
const publicUrl = process.env.PUBLIC_URL?.replace(/\/+$/, '');
const repoUrl = publicUrl ? `${publicUrl}/${repo.full_name}` : repo.html_url;
const tag = env('TAG');
const release = await api('GET', `/releases/tags/${encodeURIComponent(tag)}`);
return { repoUrl, tag, release, runUrl: `${repoUrl}/actions/runs/${env('GITHUB_RUN_ID')}` };
}
async function create() {
const { repoUrl, tag, release, runUrl } = await context();
const prerelease = env('PRERELEASE') === 'true';
const prevTag = previousTag(tag, prerelease);
const body = renderNotes({
version: env('VERSION'),
tag,
prevTag,
prerelease,
repoUrl,
groups: groupCommits(commitsBetween(prevTag, tag)),
androidStatus: renderAndroidStatus({ status: 'building', runUrl }),
});
const fields = { name: `Pipu Music ${env('VERSION')}`, body, prerelease, draft: false };
const saved = release
? await api('PATCH', `/releases/${release.id}`, { json: fields })
: await api('POST', '/releases', { json: { tag_name: tag, ...fields } });
console.log(`${release ? 'обновлён' : 'создан'} релиз ${saved.html_url}`);
}
async function upload(release, file) {
const name = basename(file);
for (const old of release.assets ?? []) {
if (old.name === name) await api('DELETE', `/releases/${release.id}/assets/${old.id}`);
}
const form = new FormData();
form.append('attachment', new Blob([readFileSync(file)]), name);
await api('POST', `/releases/${release.id}/assets?name=${encodeURIComponent(name)}`, { form });
console.log(`приложен ${name}`);
}
async function android(status, apkPath) {
const { release, runUrl } = await context();
if (!release) throw new Error(`релиз для ${env('TAG')} не найден — сначала node scripts/release.mjs create`);
let apk;
if (status === 'success') {
if (!apkPath) throw new Error('android success: укажите путь к APK');
const data = readFileSync(apkPath);
apk = { name: basename(apkPath), size: data.length, sha256: createHash('sha256').update(data).digest('hex') };
for (const f of [apkPath, `${apkPath}.sha256`]) if (existsSync(f)) await upload(release, f);
}
const block = renderAndroidStatus({
status,
runUrl,
apk,
versionCode: process.env.VERSION_CODE,
signing: process.env.SIGNING,
});
await api('PATCH', `/releases/${release.id}`, { json: { body: replaceAndroidStatus(release.body ?? '', block) } });
console.log(`статус Android: ${status}`);
}
async function main() {
const [cmd, ...args] = process.argv.slice(2);
if (cmd === 'create') return create();
if (cmd === 'android' && ['building', 'success', 'failure', 'cancelled'].includes(args[0])) {
return android(args[0], args[1]);
}
throw new Error('использование: release.mjs create | android building|failure|cancelled | android success <file.apk>');
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
main().catch((e) => {
process.stderr.write(`${e.message}\n`);
process.exit(1);
});
}
+112
View File
@@ -0,0 +1,112 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
groupCommits,
renderAndroidStatus,
renderNotes,
replaceAndroidStatus,
STATUS_END,
STATUS_START,
} from './release.mjs';
const sha = (n) => String(n).repeat(40).slice(0, 40);
const repoUrl = 'https://git.example/owner/pipu-music';
test('groups conventional commits by section in a fixed order', () => {
const groups = groupCommits([
{ sha: sha(1), subject: 'fix(api): handle empty token' },
{ sha: sha(2), subject: 'feat: add wave' },
{ sha: sha(3), subject: 'ci(release): build arm64 only' },
{ sha: sha(4), subject: 'build(deploy): pin images' },
{ sha: sha(5), subject: 'Update README' },
{ sha: sha(6), subject: 'chore: bump deps' },
]);
assert.deepEqual(
groups.sections.map(([title, items]) => [title, items.map((i) => i.text)]),
[
['Новое', ['add wave']],
['Исправления', ['handle empty token']],
['Сборка и CI', ['build arm64 only', 'pin images']],
['Прочее', ['Update README', 'bump deps']],
],
);
assert.equal(groups.sections[1][1][0].scope, 'api');
assert.deepEqual(groups.breaking, []);
});
test('marks breaking changes', () => {
const groups = groupCommits([{ sha: sha(1), subject: 'feat(api)!: drop v0 endpoints' }]);
assert.equal(groups.breaking.length, 1);
assert.equal(groups.sections[0][0], 'Новое');
});
test('android status blocks', () => {
const building = renderAndroidStatus({ status: 'building', runUrl: 'https://run' });
assert.ok(building.startsWith(STATUS_START) && building.endsWith(STATUS_END));
assert.match(building, /собирается — \[ход сборки\]\(https:\/\/run\)/);
const ok = renderAndroidStatus({
status: 'success',
versionCode: '10299',
signing: 'release',
apk: { name: 'pipu-music-0.1.2.apk', size: 25 * 1024 * 1024 + 300_000, sha256: 'ab'.repeat(32) },
});
assert.match(ok, /`pipu-music-0\.1\.2\.apk` \(25,3 МБ\), versionCode `10299`/);
assert.doesNotMatch(ok, /отладочным/);
const debug = renderAndroidStatus({ status: 'success', signing: 'debug', apk: { name: 'a.apk', size: 1, sha256: 'x' } });
assert.match(debug, /отладочным ключом/);
assert.match(renderAndroidStatus({ status: 'failure', runUrl: 'https://run' }), /не удалась — \[лог сборки\]/);
assert.match(renderAndroidStatus({ status: 'cancelled', runUrl: 'https://run' }), /отменена — \[лог сборки\]/);
});
test('replaces only the status block and is idempotent', () => {
const body = `intro\n\n${renderAndroidStatus({ status: 'building', runUrl: 'u' })}\n\n## Что изменилось\n`;
const failed = renderAndroidStatus({ status: 'failure', runUrl: 'u' });
const once = replaceAndroidStatus(body, failed);
assert.equal(replaceAndroidStatus(once, failed), once);
assert.ok(once.startsWith('intro\n\n') && once.endsWith('\n\n## Что изменилось\n'));
assert.doesNotMatch(once, /собирается/);
assert.equal(once.split(STATUS_START).length, 2);
});
test('adds a status block when the body has none', () => {
const block = renderAndroidStatus({ status: 'failure', runUrl: 'u' });
assert.ok(replaceAndroidStatus('hand-written notes', block).startsWith(block));
});
test('release notes', () => {
const notes = renderNotes({
version: '0.2.0',
tag: 'v0.2.0',
prevTag: 'v0.1.7',
prerelease: false,
repoUrl,
groups: groupCommits([{ sha: sha(1), subject: 'feat(web): library screen' }]),
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'https://run' }),
});
assert.match(notes, /### Новое\n\n- \*\*web:\*\* library screen \(\[1111111\]\(https:\/\/git\.example\/owner\/pipu-music\/commit\/1{40}\)\)/);
assert.match(notes, /\[v0\.1\.7\.\.\.v0\.2\.0\]\(https:\/\/git\.example\/owner\/pipu-music\/compare\/v0\.1\.7\.\.\.v0\.2\.0\)/);
assert.match(notes, /\.\/update\.sh v0\.2\.0/);
assert.match(notes, /"version":"0\.2\.0"/);
assert.match(notes, /pipu-music-0\.2\.0\.apk/);
assert.match(notes, /src\/tag\/v0\.2\.0\/docs\/install-guide\.md/);
assert.doesNotMatch(notes, /Предварительная версия/);
});
test('release notes for a first prerelease without changes', () => {
const notes = renderNotes({
version: '0.1.0-rc.1',
tag: 'v0.1.0-rc.1',
prevTag: null,
prerelease: true,
repoUrl,
groups: groupCommits([]),
androidStatus: renderAndroidStatus({ status: 'building', runUrl: 'u' }),
});
assert.match(notes, /Предварительная версия/);
assert.match(notes, /Изменений с прошлой версии нет/);
assert.match(notes, /commits\/tag\/v0\.1\.0-rc\.1/);
});
+4 -1
View File
@@ -14,8 +14,11 @@ RUN --mount=type=cache,target=/go/pkg/mod --mount=type=cache,target=/root/.cache
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/api ./cmd/api && \
go build -trimpath -ldflags "-s -w -X pipu/api/internal/buildinfo.Version=${VERSION}" -o /out/storage-agent ./cmd/storage-agent
FROM gcr.io/distroless/static-debian12:nonroot AS api
# api вызывает ffprobe (сканер) и ffmpeg (транскодинг, M3) — нужен не distroless, а alpine с ffmpeg.
FROM alpine:3.22 AS api
RUN apk add --no-cache ffmpeg ca-certificates tzdata
COPY --from=build /out/api /usr/local/bin/api
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/api"]
+115 -1
View File
@@ -3,17 +3,29 @@ package main
import (
"context"
"crypto/ed25519"
"fmt"
"log/slog"
"os"
"os/signal"
"path/filepath"
"runtime"
"syscall"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/redis/go-redis/v9"
"pipu/api/internal/auth"
"pipu/api/internal/buildinfo"
"pipu/api/internal/library"
"pipu/api/internal/platform/config"
"pipu/api/internal/platform/db"
"pipu/api/internal/platform/httpx"
"pipu/api/internal/platform/sign"
"pipu/api/internal/playback"
"pipu/api/internal/search"
"pipu/api/internal/server"
"pipu/api/internal/system"
)
@@ -56,16 +68,118 @@ func run() error {
rdb := redis.NewClient(redisOpts)
defer func() { _ = rdb.Close() }()
authSvc, err := newAuthService(cfg, pool, rdb, log)
if err != nil {
return err
}
if err := authSvc.Bootstrap(ctx); err != nil {
return err
}
sys := system.NewHandler(system.Info{
Version: buildinfo.Version,
SupportedLocales: cfg.SupportedLocales,
MinAndroidVersionCode: cfg.MinAndroidVersionCode,
BootstrapRequired: authSvc.BootstrapRequired,
}, log, map[string]system.Checker{
"postgres": system.CheckerFunc(pool.Ping),
"redis": system.CheckerFunc(func(ctx context.Context) error { return rdb.Ping(ctx).Err() }),
"search": system.CheckerFunc(search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey).Healthy),
})
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{Log: log, System: sys}))
libStore := library.NewStore(pool)
prober := library.FFprobe{Bin: cfg.FFprobePath, Timeout: time.Minute}
board := library.NewProgressBoard()
scanner := library.NewScanner(libStore, prober, board, log)
libSvc := library.NewService(libStore, scanner, board, library.NewHTTPAgent(cfg.StorageAgentURL, cfg.InternalToken),
prober, library.Config{LibraryRoot: cfg.LibraryRoot, HostRoot: cfg.LibraryHostRoot}, log)
meili := search.NewMeili(cfg.MeiliURL, cfg.MeiliMasterKey)
indexer := search.NewIndexer(pool, meili, log)
signer, err := sign.New(cfg.SigningKey)
if err != nil {
return err
}
if cfg.SigningKey == "" {
log.Warn("STREAM_SIGNING_KEY is not set: cover and stream links will break on restart")
}
covers := library.NewCovers(libStore, libSvc, cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "covers"), log)
scanner.AfterScan = func(ctx context.Context) {
covers.Run(ctx)
indexer.Trigger()
}
go covers.Run(ctx) // альбомы, оставшиеся без обложки с прошлого запуска
concurrency := cfg.TranscodeConcurrency
if concurrency <= 0 {
concurrency = max(1, runtime.NumCPU()/2)
}
transcoder := playback.NewTranscoder(cfg.FFmpegPath, filepath.Join(cfg.CacheDir, "transcode"), concurrency,
cfg.TranscodeCacheLimitGB, log)
go transcoder.RunCleanup(ctx)
rootFor := func(ctx context.Context, sourceID uuid.UUID) (string, error) {
src, err := libStore.GetSource(ctx, sourceID)
if err != nil {
return "", err
}
return libSvc.Root(src)
}
var dto library.DTO
pb := playback.NewHandler(playback.NewStore(pool), libStore, func() library.DTO { return dto }, signer, transcoder,
rootFor, cfg.XAccel, log)
dto = library.DTO{Signer: signer, Personal: pb}
libSvc.OnCatalogChanged = indexer.Trigger
indexer.Trigger() // индекс мог отстать (новая версия, сбой Meilisearch) — пересобираем при старте
go libSvc.Restore(ctx)
go libSvc.RunSchedule(ctx)
srv := httpx.NewServer(cfg.HTTPAddr, server.NewRouter(server.Deps{
Log: log,
System: sys,
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc,
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
Search: search.NewHandler(meili, libStore, indexer, dto, log),
Playback: pb,
}))
log.Info("starting", "version", buildinfo.Version)
return httpx.Serve(ctx, log, srv, cfg.ShutdownTimeout)
}
func newAuthService(cfg config.API, pool *pgxpool.Pool, rdb *redis.Client, log *slog.Logger) (*auth.Service, error) {
var (
key ed25519.PrivateKey
err error
)
if cfg.JWTPrivateKey == "" {
log.Warn("JWT_PRIVATE_KEY is not set: using a random key, access tokens will not survive a restart")
key, err = auth.GenerateKey()
} else {
key, err = auth.ParsePrivateKey(cfg.JWTPrivateKey)
}
if err != nil {
return nil, err
}
var previous []ed25519.PrivateKey
for _, s := range cfg.JWTPreviousKeys {
if s == "" {
continue
}
k, err := auth.ParsePrivateKey(s)
if err != nil {
return nil, fmt.Errorf("JWT_PREVIOUS_KEYS: %w", err)
}
previous = append(previous, k)
}
clock := auth.SystemClock{}
acfg := auth.DefaultConfig()
acfg.BootstrapUsername = cfg.BootstrapAdminUsername
acfg.BootstrapPassword = cfg.BootstrapAdminPassword
acfg.SupportedLocales = cfg.SupportedLocales
return auth.NewService(
auth.NewPgStore(pool),
auth.NewHasher(auth.DefaultArgon2Params),
auth.NewTokenIssuer(key, previous, cfg.AccessTokenTTL, clock.Now),
auth.NewRedisLimiter(rdb),
clock, auth.UUIDv7{}, log, acfg,
)
}
+5 -1
View File
@@ -8,14 +8,18 @@ require (
github.com/caarlos0/env/v11 v11.3.1
github.com/getkin/kin-openapi v0.127.0
github.com/go-chi/chi/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.5
github.com/oapi-codegen/runtime v1.1.1
github.com/pressly/goose/v3 v3.24.3
github.com/redis/go-redis/v9 v9.10.0
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
@@ -37,9 +41,9 @@ require (
github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect
github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
+8
View File
@@ -1,3 +1,7 @@
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
@@ -33,6 +37,8 @@ github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
@@ -65,6 +71,7 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -120,6 +127,7 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah
github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas=
github.com/speakeasy-api/openapi-overlay v0.9.0 h1:Wrz6NO02cNlLzx1fB093lBlYxSI54VRhy1aSutx0PQg=
github.com/speakeasy-api/openapi-overlay v0.9.0/go.mod h1:f5FloQrHA7MsxYg9djzMD5h6dxrHjVVByWKh7an8TRc=
github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff. Show more