Compare commits

...
25 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 66f5749fc1 fix(deploy): take the Gitea address for Android updates from git remote
ci / api / api (push) Successful in 22s
ci / db / db (push) Successful in 2m4s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m11s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m33s
The server kept the git.example.lan placeholder from .env.example, so the
/android page and in-app updates had no release. install.sh and update.sh now
fill GITEA_URL and GITEA_REPO from the clone's origin (https, ssh and scp
forms, credentials stripped) when they are empty or still the placeholder,
and the API treats the placeholder as not configured.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:23:28 +03:00
mrleo1nidandClaude Opus 5.5 823d1eb841 fix(web): suggestions stay open when you return to the search quickly
ci / ts / ts (packages + web) (push) Successful in 1m1s
release / android (APK в релиз) (push) Successful in 4m10s
release / release (версия и заметки) (push) Successful in 6s
Leaving the field scheduled a close 120 ms later; coming back and typing
within that window let the old timer hide the fresh suggestions. Focus now
cancels the pending close. This also made the search box test flaky.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:13:08 +03:00
mrleo1nidandClaude Opus 5.5 860072e542 feat(web): choose where the search sits, and controls no longer select text
ci / api / api (push) Successful in 36s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 48s
ci / ts / ts (packages + web) (push) Canceled after 12s
ci / db / db (push) Successful in 2m43s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 59s
- settings get a search position (left, center by default, right); the top bar
  is three zones and the suggestions open towards the free space
- buttons, chips, tabs, menu items and the home screen controls are not
  text-selectable, so clicks and double clicks no longer highlight labels

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:11:06 +03:00
mrleo1nidandClaude Opus 5.5 d2f17d8d62 ci(db): fail early with a clear message when the runner disk is full
ci / db / db (push) Successful in 45s
release / release (версия и заметки) (push) Successful in 6s
release / android (APK в релиз) (push) Successful in 3m47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 00:03:32 +03:00
mrleo1nidandClaude Opus 5.5 4763339335 ci(db): wait for the service containers before the tests
ci / db / db (push) Successful in 53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:59:28 +03:00
mrleo1nidandClaude Opus 5.5 fda6ed48ab feat: Android download page, analysis retry and a collapsible search
ci / api / api (push) Successful in 37s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 46s
ci / db / db (push) Failing after 14s
ci / recommender / recommender (push) Successful in 51s
ci / ts / ts (packages + web) (push) Successful in 57s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m2s
- /android is a public page with the latest APK, the server address to copy
  and three install steps; linked from the sign-in screen and the profile menu
- failed audio analysis can be retried from the library page, which also shows
  the most common error; the analyzer no longer burns attempts on files that
  are missing because the disk is not mounted
- the top bar search folds into a round button and slides open on click, "/"
  or Ctrl+K

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:55:51 +03:00
mrleo1nidandClaude Opus 5.5 2d49cb3f19 fix(web): header dropdowns sit above the page again
ci / ts / ts (packages + web) (push) Successful in 50s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m45s
Every shell child got the same z-index, so the page painted over the search
suggestions and the profile menu and swallowed their clicks: suggestions could
not be picked, and the language switch and settings item did nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:47:18 +03:00
mrleo1nidandClaude Opus 5.5 3da82e902a fix(library): survive a music disk that is not mounted
ci / api / api (push) Successful in 29s
ci / db / db (push) Successful in 47s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 52s
release / release (версия и заметки) (push) Successful in 4s
release / android (APK в релиз) (push) Successful in 3m20s
On the test server the host music folder was not mounted, so every stream and
fingerprint failed with no such file, and a scan of the empty folder would have
marked the whole library missing and purged it after 30 days.

- a scan of an empty source folder with known files aborts with a clear error
- fingerprinting skips such a source with one warning instead of one per file
- LIBRARY_HOST mounts use rslave, so a disk mounted after start shows up
- update.sh refuses to deploy with an empty LIBRARY_HOST

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:34:52 +03:00
mrleo1nidandClaude Opus 5.5 421b1a3eb9 ci: allow manual runs of the db and mobile checks
ci / db / db (push) Successful in 54s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 56s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m24s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:18:11 +03:00
mrleo1nidandClaude Opus 5.5 b8243c3072 docs(roadmap): M6 and M7 status
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:59 +03:00
mrleo1nidandClaude Opus 5.5 30f063456e fix(mobile): white text sits on the contrast-safe accent fill (M7)
ci / mobile / mobile (Metro-бандл Android) (push) Failing after 7s
ci / ts / ts (packages + web) (push) Successful in 49s
Primary buttons, selected segments and context chips use accent-fill, as on
the web, so their labels meet WCAG AA contrast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:38 +03:00
mrleo1nidandClaude Opus 5.5 de4e59d9f8 fix(web): accessibility pass to WCAG 2.1 AA (M7)
ci / mobile / mobile (Metro-бандл Android) (push) Canceled after 31s
ci / ts / ts (packages + web) (push) Canceled after 0s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 44s
An axe-core audit of every screen in both themes found low contrast on white
text over the accent and on caption text, track rows using row roles without
cells, and unlabelled progress bars. Accent fills now use a darker token,
caption text meets 4.5:1, track lists are lists and progress bars are labelled.
The queue drawer slides in from the right as designed, takes focus, closes on
Escape and notes that the wave never ends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:15:03 +03:00
mrleo1nidandClaude Opus 5.5 c0e50bf88e docs: administrator guide (M7)
Day-to-day operation after install: users and roles, storages and scans, the
upload queue, wave metrics and training, updates, backups and restore,
monitoring and common situations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:02:54 +03:00
mrleo1nidandClaude Opus 5.5 86c3e4e477 test(load): k6 smoke for 100 wave listeners and 20 rps suggest (M7)
Three minutes of wave sessions with stream starts, play events and next batches
alongside a steady suggest load; thresholds follow the documented p95 targets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 23:01:10 +03:00
mrleo1nidandClaude Opus 5.5 aacf55679a feat(deploy): nightly database backups with verified restore (M7)
ci / db / db (push) Failing after 52s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m3s
A backup container dumps the database every night, keeps 14 daily and 8 weekly
copies and once a week restores a copy into a scratch database to check tables
and row counts. update.sh takes a copy before updating, restore.sh brings one
back and keeps the old database aside. CI runs the dump, verify and restore
cycle on the database the end-to-end journey filled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:59:27 +03:00
mrleo1nidandClaude Opus 5.5 d91ff89c64 feat(admin): wave metrics split by model version (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 51s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m5s
ci / ts / ts (packages + web) (push) Successful in 1m3s
ci / api / api (push) Successful in 29s
ci / db / db (push) Failing after 12s
The library page compares listen-through, early-skip and like rates per
impression model version, so v1 and the trained ranker can be compared online.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:57:11 +03:00
mrleo1nidandClaude Opus 5.5 fbbe5d7528 feat(search): personalize suggestions by listening history (M6)
ci / api / api (push) Successful in 18s
ci / db / db (push) Successful in 30s
Suggest fetches three times as many candidates and reranks them in the API:
what the user plays, finishes and likes moves up a few positions and what they
dislike moves down, with Meilisearch relevance still the base order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:55:07 +03:00
mrleo1nidandClaude Opus 5.5 e250837de9 fix(search): reindex on a fresh Meilisearch
ci / db / db (push) Successful in 34s
release / release (версия и заметки) (push) Successful in 5s
ci / api / api (push) Successful in 19s
release / android (APK в релиз) (push) Successful in 3m17s
Deleting a missing <uid>_next index is accepted with 202 and fails inside the
task with index_not_found. That error was not treated as not-found, so the first
reindex aborted and search stayed unavailable until indexes existed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:44:59 +03:00
mrleo1nidandClaude Opus 5.5 58bd14ef38 feat(recommender): LightGBM lambdarank ranker with impression features (M6)
ci / db / db (push) Failing after 1m15s
ci / api / api (push) Successful in 17s
ci / recommender / recommender (push) Successful in 32s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m41s
The wave logs each served candidate's feature vector to rec_impression_features.
Nightly training joins them with impression outcomes, validates on the last week
and activates the model only when it beats the v1 order and the active model.
The service loads the active ranker and uses its order in place of the v1 score.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:41:26 +03:00
mrleo1nidandClaude Opus 5.5 fb07f962eb feat: taste onboarding for new listeners (M6)
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m2s
ci / db / db (push) Failing after 1m29s
ci / ts / ts (packages + web) (push) Successful in 49s
ci / api / api (push) Successful in 1m25s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 48s
ci / recommender / recommender (push) Successful in 21s
- GET /onboarding offers the library's top artists (with album covers)
  and genres and says whether to show it (not done and < 10 plays);
  PUT /me/onboarding stores the choice or a skip (migration 00009).
- The recommender seeds the taste from it while the user has fewer than
  30 scored tracks: chosen artists' tracks +1, chosen genres +0.5.
- Web: onboarding replaces the wave on the home page until done or
  skipped; artist grid with covers, genre chips, at least 3 artists.
- e2e journey and the recommender DB test cover it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:35:43 +03:00
mrleo1nidandClaude Opus 5.5 ff0aafdb1e feat(recommender): Thompson-sampling bandit over candidate sources (M6)
ci / recommender / recommender (push) Failing after 4s
ci / db / db (push) Failing after 2m21s
Nightly Beta(α, β) per user and source from wave impressions (reward:
complete or like, ×0.99 daily decay) stored in user_profiles_rec.bandit;
the wave adds 0.2·(θ − 0.5) per candidate source, sources without data
stay neutral. The user's exploration share now follows how well `fresh`
does against the other sources.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:31:24 +03:00
mrleo1nidandClaude Opus 5.5 f7722e28ad ci(release): rebalance APK build memory so it does not thrash and get OOM-killed
release / android (APK в релиз) (push) Successful in 3m19s
release / release (версия и заметки) (push) Successful in 8s
v0.7.3 crawled for 35 minutes at the 6 GB cap and was killed without a
log. The container now gets 7 GB, the Gradle heap is 2 GB, the Metro
node process is capped at 1.5 GB and the job times out after 60 minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:12:54 +03:00
mrleo1nidandClaude Opus 5.5 ed0302d90f docs(uploads): import writes only the final track file
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:11:56 +03:00
mrleo1nidandClaude Opus 5.5 85fceaa686 fix(storage-agent): write nothing but the music files to the mounted storage
ci / api / api (push) Successful in 50s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Failing after 10s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Failing after 31m43s
ci / db / db (push) Failing after 19s
- Imported tracks are created directly under their final name with
  O_EXCL (never overwriting; " (2)" on conflict), fsync'ed and checked
  against SHA-256 — no hidden .pipu-tmp file next to them any more.
- On any failure the agent removes its own partial file and the empty
  directories it created for it.
- The write check uses access(W_OK) and the read-only mount flag instead
  of creating and deleting .pipu-write-test in the storage root.
- Tests assert the storage tree holds only audio files after successful
  and failed writes and after the write check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:22:49 +03:00
mrleo1nidandClaude Opus 5.5 6ad49fe0e3 fix(wave): switch to the new context right away when activity or mood changes
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m38s
ci / ts / ts (packages + web) (push) Successful in 1m47s
release / release (версия и заметки) (push) Successful in 12s
release / android (APK в релиз) (push) Failing after 34m58s
Changing the activity or mood used to keep the current track playing
and only replace the upcoming ones, so nothing seemed to happen. Now the
first track of the new batch starts immediately (web and Android). The
interrupted track is not reported to the wave as a skip and is recorded
in history only if it played for at least 30 s, so a context change does
not teach the recommender a false dislike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:18:26 +03:00
103 changed files with 3727 additions and 443 deletions

No files matched your search

+43
View File
@@ -4,16 +4,19 @@
name: ci / db name: ci / db
on: on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push: push:
branches: [main] branches: [main]
paths: paths:
- 'services/api/**' - 'services/api/**'
- 'services/recommender/**' - 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml' - '.gitea/workflows/ci-db.yml'
pull_request: pull_request:
paths: paths:
- 'services/api/**' - 'services/api/**'
- 'services/recommender/**' - 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml' - '.gitea/workflows/ci-db.yml'
concurrency: concurrency:
@@ -51,6 +54,23 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum } with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
run: |
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
free=$(df -Pm / | awk 'NR==2 {print $4}')
echo "свободно на раннере: ${free} МБ"
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
for svc in postgres:5432 redis:6379 meilisearch:7700; do
host=${svc%:*}; port=${svc#*:}
for i in $(seq 1 60); do
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
echo "$svc готов"; break
fi
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
sleep 2
done
done
- name: Go — миграции и запросы store - name: Go — миграции и запросы store
working-directory: services/api working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/ run: go test -tags integration -count=1 -v ./internal/dbtest/
@@ -64,3 +84,26 @@ jobs:
run: | run: |
uv sync --frozen uv sync --frozen
uv run pytest -q tests/test_db_integration.py uv run pytest -q tests/test_db_integration.py
# База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление.
- name: Бэкап, проверка и восстановление (deploy/backup.sh)
env:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: pipu
PGDATABASE: pipu
BACKUP_DIR: /tmp/backups
VERIFY_EXACT: "1"
run: |
SUDO=$(command -v sudo || true)
$SUDO install -d /usr/share/postgresql-common/pgdg
$SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null
export PATH=/usr/lib/postgresql/17/bin:$PATH
before=$(psql -Atc "SELECT count(*) FROM tracks")
file=$(bash deploy/backup.sh once)
bash deploy/backup.sh verify "$file"
bash deploy/backup.sh restore "$file"
after=$(psql -Atc "SELECT count(*) FROM tracks")
echo "tracks: $before → $after"
test "$before" = "$after"
+1
View File
@@ -2,6 +2,7 @@
name: ci / mobile name: ci / mobile
on: on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push: push:
branches: [main] branches: [main]
paths: paths:
+6 -2
View File
@@ -48,6 +48,8 @@ jobs:
android: android:
name: android (APK в релиз) name: android (APK в релиз)
needs: release needs: release
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
timeout-minutes: 60
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK # Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома # переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
@@ -60,7 +62,7 @@ jobs:
image: docker.gitea.com/runner-images:ubuntu-latest image: docker.gitea.com/runner-images:ubuntu-latest
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора). # Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост. # Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
options: --cpus=3 --memory=6g --memory-swap=6g options: --cpus=3 --memory=7g --memory-swap=7g
volumes: volumes:
- /opt/ci-cache/gradle:/root/.gradle - /opt/ci-cache/gradle:/root/.gradle
- /opt/ci-cache/android:/root/.android - /opt/ci-cache/android:/root/.android
@@ -106,6 +108,8 @@ jobs:
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем. # CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
CMAKE_BUILD_PARALLEL_LEVEL: "2" CMAKE_BUILD_PARALLEL_LEVEL: "2"
NINJAFLAGS: "-j2" NINJAFLAGS: "-j2"
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
NODE_OPTIONS: "--max-old-space-size=1536"
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI # Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+. # вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer. # Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
@@ -120,7 +124,7 @@ jobs:
cd android cd android
# nice / ionice: сборка уступает CPU и диск всему остальному на машине # nice / ionice: сборка уступает CPU и диск всему остальному на машине
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m") -Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m")
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен. # Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
if grep -q '^newArchEnabled=true' gradle.properties; then if grep -q '^newArchEnabled=true' gradle.properties; then
"${gradle[@]}" generateCodegenArtifactsFromSchema "${gradle[@]}" generateCodegenArtifactsFromSchema
+137 -2
View File
@@ -1006,6 +1006,24 @@ paths:
$ref: "#/components/schemas/SearchResults" $ref: "#/components/schemas/SearchResults"
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/admin/library/analysis/retry:
post:
operationId: retryAnalysis
tags: [library]
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
responses:
"200":
description: Сколько задач снова в очереди
content:
application/json:
schema:
type: object
required: [requeued]
properties:
requeued:
type: integer
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/search/reindex: /api/v1/admin/search/reindex:
post: post:
operationId: reindexSearch operationId: reindexSearch
@@ -1256,6 +1274,38 @@ paths:
description: Снято description: Снято
default: default:
$ref: "#/components/responses/Problem" $ref: "#/components/responses/Problem"
/api/v1/onboarding:
get:
operationId: getOnboarding
tags: [wave]
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
description: |
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
responses:
"200":
description: Варианты и состояние
content:
application/json:
schema:
$ref: "#/components/schemas/Onboarding"
default:
$ref: "#/components/responses/Problem"
/api/v1/me/onboarding:
put:
operationId: saveOnboarding
tags: [wave]
summary: Сохранить выбор онбординга (или пропуск — пустые списки)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/OnboardingChoice"
responses:
"204":
description: Сохранено
default:
$ref: "#/components/responses/Problem"
/api/v1/wave/options: /api/v1/wave/options:
get: get:
operationId: getWaveOptions operationId: getWaveOptions
@@ -1347,6 +1397,7 @@ paths:
get: get:
operationId: getAndroidLatest operationId: getAndroidLatest
tags: [app] tags: [app]
security: [] # публично: страница /android раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер) summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: | description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1364,6 +1415,7 @@ paths:
get: get:
operationId: downloadAndroidApk operationId: downloadAndroidApk
tags: [app] tags: [app]
security: []
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки) summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
parameters: parameters:
- name: version - name: version
@@ -1442,6 +1494,59 @@ components:
schema: schema:
$ref: "#/components/schemas/Problem" $ref: "#/components/schemas/Problem"
schemas: schemas:
Onboarding:
type: object
required: [required, artists, genres]
properties:
required:
type: boolean
artists:
type: array
items:
type: object
required: [id, name, track_count, cover_url, cover_colors]
properties:
id:
type: string
format: uuid
name:
type: string
track_count:
type: integer
cover_url:
type: string
nullable: true
description: Обложка самого большого альбома исполнителя
cover_colors:
type: array
items:
type: string
genres:
type: array
items:
type: object
required: [name, track_count]
properties:
name:
type: string
track_count:
type: integer
OnboardingChoice:
type: object
required: [artist_ids, genres]
properties:
artist_ids:
type: array
maxItems: 50
items:
type: string
format: uuid
genres:
type: array
maxItems: 30
items:
type: string
maxLength: 100
UserRef: UserRef:
type: object type: object
required: [id, username] required: [id, username]
@@ -1909,6 +2014,10 @@ components:
web_player: web_player:
type: string type: string
enum: [bar, island] enum: [bar, island]
search_position:
type: string
enum: [center, left, right]
description: Где в топбаре веба поиск (по умолчанию center)
mobile_player: mobile_player:
type: string type: string
enum: [cover, wave] enum: [cover, wave]
@@ -2996,7 +3105,7 @@ components:
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители) description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
AnalysisStatus: AnalysisStatus:
type: object type: object
required: [queued, running, done, failed, analyzer_ver] required: [queued, running, done, failed, analyzer_ver, top_error]
properties: properties:
queued: queued:
type: integer type: integer
@@ -3009,9 +3118,13 @@ components:
analyzer_ver: analyzer_ver:
type: string type: string
nullable: true nullable: true
top_error:
type: string
nullable: true
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
WaveMetrics: WaveMetrics:
type: object type: object
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions] required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
description: Метрики волны за 7 дней description: Метрики волны за 7 дней
properties: properties:
served: served:
@@ -3028,6 +3141,28 @@ components:
description: Лайков на 100 треков description: Лайков на 100 треков
sessions: sessions:
type: integer type: integer
by_model:
type: array
description: Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
items:
$ref: '#/components/schemas/WaveModelMetrics'
WaveModelMetrics:
type: object
required: [model_ver, served, listen_through_rate, early_skip_rate, like_rate]
properties:
model_ver:
type: string
served:
type: integer
listen_through_rate:
type: number
nullable: true
early_skip_rate:
type: number
nullable: true
like_rate:
type: number
nullable: true
AndroidRelease: AndroidRelease:
type: object type: object
required: [version, version_code, notes_md, size, sha256, download_url, min_supported] required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
+1 -1
View File
@@ -117,7 +117,7 @@ function Chips({
height: 38, height: 38,
paddingHorizontal: 14, paddingHorizontal: 14,
borderRadius: 999, borderRadius: 999,
backgroundColor: on ? colors.accent : colors.surface2, backgroundColor: on ? colors['accent-fill'] : colors.surface2,
borderWidth: 1, borderWidth: 1,
borderColor: on ? colors.accent : colors.line, borderColor: on ? colors.accent : colors.line,
}} }}
+16
View File
@@ -90,6 +90,7 @@ class Controller {
private lastSave = 0; private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = []; private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false; private waveLoading = false;
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null; private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>(); private listeners = new Set<() => void>();
private state: PlayerState = { private state: PlayerState = {
@@ -189,6 +190,14 @@ class Controller {
if (!a) return; if (!a) return;
this.attempt = null; this.attempt = null;
const pos = Math.round(lastPositionS * 1000); const pos = Math.round(lastPositionS * 1000);
if (this.neutralSwitch) {
this.neutralSwitch = false;
if (a.playedMs >= EARLY_SKIP_MS) {
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush();
}
return;
}
const kind = ended ? 'play_end' : a.outcome(pos); const kind = ended ? 'play_end' : a.outcome(pos);
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) }); this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush(); void this.events?.flush();
@@ -316,6 +325,13 @@ class Controller {
); );
await this.replaceUpcoming(res.tracks); await this.replaceUpcoming(res.tracks);
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } }); this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
if (res.tracks.some(playable)) {
this.neutralSwitch = true;
await TrackPlayer.skipToNext().catch(() => {
this.neutralSwitch = false;
});
}
} }
private leaveWave() { private leaveWave() {
+2 -2
View File
@@ -107,7 +107,7 @@ export function Button({
}) { }) {
const s = useStyles(); const s = useStyles();
const colors = useColors(); const colors = useColors();
const bg = { primary: colors.accent, secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant]; const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant]; const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
return ( return (
<Pressable <Pressable
@@ -282,7 +282,7 @@ export function Segmented<T extends string>({
onPress={() => onChange(o.value)} onPress={() => onChange(o.value)}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityState={{ selected: value === o.value }} accessibilityState={{ selected: value === o.value }}
style={[s.segment, value === o.value && { backgroundColor: colors.accent }]} style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
> >
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text> <Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
</Pressable> </Pressable>
+1 -1
View File
@@ -250,7 +250,7 @@ describe('player', () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice }); const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!; const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row); await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument()); await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
+5
View File
@@ -21,6 +21,7 @@ import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage'; import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage'; import { CollectionPage } from '../pages/CollectionPage';
import { HomePage } from '../pages/HomePage'; import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage'; import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage'; import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage'; import { SettingsPage } from '../pages/SettingsPage';
@@ -59,6 +60,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError, errorComponent: RouteError,
}); });
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
const loginRoute = createRoute({ const loginRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/login', path: '/login',
@@ -233,6 +237,7 @@ const adminSettingsRoute = createRoute({
}); });
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
androidAppRoute,
loginRoute, loginRoute,
setupAdminRoute, setupAdminRoute,
setupPasswordRoute, setupPasswordRoute,
+2 -2
View File
@@ -132,7 +132,7 @@ export function TrackList({
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1)); const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null; let lastDisc: number | null = null;
return ( return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}> <div className={s.tracks} role="list" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => { {tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1; const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc; const header = numbered && discs.size > 1 && disc !== lastDisc;
@@ -145,7 +145,7 @@ export function TrackList({
<Fragment key={`${tr.id}-${i}`}> <Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>} {header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div <div
role="row" role="listitem"
tabIndex={tr.available ? 0 : -1} tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined} aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)} className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
+70
View File
@@ -0,0 +1,70 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
*/
export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']);
const session = useSession();
const [copied, setCopied] = useState(false);
const latest = useQuery({
queryKey: ['android-latest'],
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
staleTime: 10 * 60_000,
});
const server = location.origin;
const copy = () =>
void navigator.clipboard?.writeText(server).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
const r = latest.data;
return (
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
<span className={s.badge}>Android</span>
<h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? null : r ? (
<>
<a className={s.download} href={r.download_url} download>
<Icon src={downloadIcon} size={22} />
<span>
{t('app.download')}
<span className={s.downloadMeta}>
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
</span>
</span>
</a>
<ol className={s.steps}>
<li>{t('app.step_install')}</li>
<li>
{t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} />
</button>
</li>
<li>{t('app.step_updates')}</li>
</ol>
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{r.sha256}</code>
</details>
</>
) : (
<div className={s.hint}>{t('app.unavailable')}</div>
)}
</AuthLayout>
);
}
+23 -2
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -24,6 +24,7 @@ import {
import { cx, FormError, Icon } from '../shared/ui/ui'; import { cx, FormError, Icon } from '../shared/ui/ui';
import { WaveButton } from '../widgets/wave/WaveButton'; import { WaveButton } from '../widgets/wave/WaveButton';
import s from './home.module.css'; import s from './home.module.css';
import { Onboarding } from './Onboarding';
/** Иконки Material Symbols по именам из /wave/options. */ /** Иконки Material Symbols по именам из /wave/options. */
const ICONS: Record<string, string> = { const ICONS: Record<string, string> = {
@@ -44,8 +45,28 @@ const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string; type Tr = (key: string) => string;
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */ /** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
export function HomePage() { export function HomePage() {
const session = useSession();
const queryClient = useQueryClient();
const onboarding = useQuery({
queryKey: ['onboarding'],
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
staleTime: Infinity,
});
if (onboarding.data?.required) {
return (
<Onboarding
options={onboarding.data}
onDone={() => queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })}
/>
);
}
return <WaveHome />;
}
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
function WaveHome() {
const { t } = useTranslation(); const { t } = useTranslation();
const tr = t as unknown as Tr; const tr = t as unknown as Tr;
const session = useSession(); const session = useSession();
+9 -1
View File
@@ -37,7 +37,15 @@ export function LoginPage() {
}; };
return ( return (
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}> <AuthLayout
footer={
info.data ? (
<>
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
</>
) : undefined
}
>
<h1>{t('login.title')}</h1> <h1>{t('login.title')}</h1>
<p className={s.lead}>{t('login.subtitle')}</p> <p className={s.lead}>{t('login.subtitle')}</p>
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>} {info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
+113
View File
@@ -0,0 +1,113 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { checkIcon } from '../shared/ui/icons';
import { Button, cx, FormError, Icon } from '../shared/ui/ui';
import s from './onboarding.module.css';
type Options = components['schemas']['Onboarding'];
/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */
const MIN_ARTISTS = 3;
/**
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
*/
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set());
const [genres, setGenres] = useState<Set<string>>(new Set());
const save = useMutation({
mutationFn: async (skip: boolean) =>
unwrap(
await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
}),
),
onSuccess: onDone,
});
// Функциональное обновление: быстрые нажатия подряд не теряют выбор.
const toggle = (update: (fn: (prev: Set<string>) => Set<string>) => void, key: string) =>
update((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
return (
<div className={s.page}>
<header className={s.head}>
<h1>{t('onboarding.title')}</h1>
<p>{t('onboarding.subtitle')}</p>
</header>
<section aria-label={t('onboarding.artists')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}>
{options.artists.map((a) => {
const on = artists.has(a.id);
return (
<button
key={a.id}
type="button"
className={cx(s.artist, on && s.on)}
aria-pressed={on}
onClick={() => toggle(setArtists, a.id)}
>
<span className={s.avatar}>
<Cover album={{ id: a.id, cover_url: a.cover_url, cover_colors: a.cover_colors }} size={96} round />
{on && (
<span className={s.check}>
<Icon src={checkIcon} size={22} />
</span>
)}
</span>
<span className={s.name}>{a.name}</span>
</button>
);
})}
</div>
</section>
{options.genres.length > 0 && (
<section aria-label={t('onboarding.genres')}>
<h2 className={s.label}>{t('onboarding.genres')}</h2>
<div className={s.genres}>
{options.genres.map((g) => (
<button
key={g.name}
type="button"
className={s.genre}
aria-pressed={genres.has(g.name)}
onClick={() => toggle(setGenres, g.name)}
>
{g.name}
</button>
))}
</div>
</section>
)}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
{t('onboarding.skip')}
</Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button>
</div>
</div>
);
}
+13
View File
@@ -126,6 +126,19 @@ function AppearanceSection() {
]} ]}
/> />
</div> </div>
<div className={s.settingRow}>
<span>{t('settings.search_position')}</span>
<Segmented<'left' | 'center' | 'right'>
label={t('settings.search_position')}
value={user.ui_prefs.search_position ?? 'center'}
onChange={(v) => void setPrefs({ search_position: v })}
options={[
{ value: 'left', label: t('settings.search_left') },
{ value: 'center', label: t('settings.search_center') },
{ value: 'right', label: t('settings.search_right') },
]}
/>
</div>
<div className={s.settingRow}> <div className={s.settingRow}>
<span>{t('settings.ambient')}</span> <span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'> <Segmented<'on' | 'off'>
+52 -4
View File
@@ -1,5 +1,6 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session'; import { useSession, useUser } from '../../app/session';
@@ -31,6 +32,11 @@ export function LibraryPage() {
const reindex = useMutation({ const reindex = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')), mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
}); });
const queryClient = useQueryClient();
const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
});
const d = status.data; const d = status.data;
const tiles = d const tiles = d
? ([ ? ([
@@ -76,7 +82,19 @@ export function LibraryPage() {
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed, total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
})} })}
/> />
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>} {d.analysis.failed > 0 && (
<div className={l.retryRow}>
<span className={l.scanError}>
{t('library.analysis_failed', { count: d.analysis.failed })}
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span>
{admin && (
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
)}
</div>
)}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>} {d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
</section> </section>
)} )}
@@ -99,6 +117,33 @@ export function LibraryPage() {
</div> </div>
))} ))}
</div> </div>
{d.wave.by_model.length > 1 && (
<div className={s.tableWrap}>
<table className={s.table}>
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
<thead>
<tr>
<th scope="col">{t('library.wave_model')}</th>
<th scope="col">{t('library.wave_served')}</th>
<th scope="col">{t('library.wave_listen')}</th>
<th scope="col">{t('library.wave_early_skip')}</th>
<th scope="col">{t('library.wave_likes')}</th>
</tr>
</thead>
<tbody>
{d.wave.by_model.map((m) => (
<tr key={m.model_ver}>
<td>{m.model_ver || '—'}</td>
<td>{m.served.toLocaleString()}</td>
<td>{pct(m.listen_through_rate)}</td>
<td>{pct(m.early_skip_rate)}</td>
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section> </section>
)} )}
{d && d.sources.length > 0 && ( {d && d.sources.length > 0 && (
@@ -133,10 +178,13 @@ const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) { function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
const value = total > 0 ? Math.round((done / total) * 100) : 0; const value = total > 0 ? Math.round((done / total) * 100) : 0;
const id = useId();
return ( return (
<div className={l.scan}> <div className={l.scan}>
<div className={l.scanText}>{label}</div> <div className={l.scanText} id={id}>
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}> {label}
</div>
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${value}%` }} /> <span style={{ width: `${value}%` }} />
</div> </div>
</div> </div>
+4 -2
View File
@@ -1,4 +1,5 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../../shared/lib/format'; import { formatDateTime } from '../../shared/lib/format';
@@ -16,14 +17,15 @@ export function StatusBadge({ status }: { status: SourceStatus }) {
/** Прогресс идущего скана или итог последнего. */ /** Прогресс идущего скана или итог последнего. */
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) { export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
const id = useId();
if (scan?.running) { if (scan?.running) {
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0; const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
return ( return (
<div className={s.scan}> <div className={s.scan}>
<div className={s.scanText}> <div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })} {scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
</div> </div>
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}> <div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} /> <span style={{ width: `${pct}%` }} />
</div> </div>
</div> </div>
@@ -118,6 +118,20 @@
color: var(--danger); color: var(--danger);
} }
.retryRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.errorDetail {
display: block;
margin-top: 4px;
color: var(--text3);
word-break: break-word;
}
.bar { .bar {
height: 6px; height: 6px;
border-radius: 3px; border-radius: 3px;
+77
View File
@@ -161,6 +161,83 @@
color: var(--text3); color: var(--text3);
} }
.footer a {
color: var(--text2);
}
/* Страница Android-приложения (/android). */
.download {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
border-radius: var(--radius-lg);
background: var(--accent-fill);
color: #fff;
font-weight: 700;
text-decoration: none;
box-shadow: var(--glow);
transition:
background 0.2s,
transform 0.15s;
}
.download:hover {
background: var(--accent-fill2);
}
.download:active {
transform: scale(0.98);
}
.downloadMeta {
display: block;
margin-top: 2px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
}
.steps {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--text2);
font-size: 14px;
line-height: 1.5;
}
.server {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 6px;
padding: 2px 10px;
border-radius: 8px;
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
cursor: pointer;
}
.details {
font-size: 12px;
color: var(--text3);
}
.details summary {
cursor: pointer;
}
.details code {
display: block;
margin-top: 6px;
word-break: break-all;
font-family: var(--font-mono);
}
.link { .link {
align-self: center; align-self: center;
} }
+2 -1
View File
@@ -7,6 +7,7 @@
gap: 28px; gap: 28px;
padding: 24px 0; padding: 24px 0;
text-align: center; text-align: center;
user-select: none; /* главная — только элементы управления волной */
} }
.orbLabel { .orbLabel {
@@ -86,7 +87,7 @@
.chipOn, .chipOn,
.chipOn:hover { .chipOn:hover {
background: var(--accent); background: var(--accent-fill);
border-color: var(--accent); border-color: var(--accent);
color: #fff; color: #fff;
box-shadow: var(--glow); box-shadow: var(--glow);
+124
View File
@@ -0,0 +1,124 @@
.page {
display: flex;
flex-direction: column;
gap: 28px;
max-width: 1100px;
margin: 0 auto;
animation: pipuIn 0.2s ease-out;
}
.head h1 {
margin: 0 0 8px;
font-family: var(--font-display);
font-size: 32px;
font-weight: 600;
letter-spacing: -0.02em;
}
.head p {
margin: 0;
color: var(--text2);
}
.label {
margin: 0 0 14px;
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text3);
}
.artists {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 18px 12px;
}
.artist {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 10px 6px;
border-radius: var(--radius-lg);
text-align: center;
}
.artist:hover {
background: var(--hover);
}
.avatar {
position: relative;
border-radius: 50%;
transition:
box-shadow 0.15s,
transform 0.15s;
}
.on .avatar {
box-shadow:
0 0 0 3px var(--bg),
0 0 0 5px var(--accent);
transform: scale(0.96);
}
.check {
position: absolute;
right: -2px;
bottom: -2px;
display: grid;
place-items: center;
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.name {
font-size: 14px;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
.genres {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.genre {
height: 38px;
padding: 0 16px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
font-weight: 600;
font-size: 14px;
}
.genre[aria-pressed='true'] {
background: var(--accent-fill);
border-color: var(--accent);
color: #fff;
box-shadow: var(--glow);
}
.actions {
position: sticky;
bottom: 16px;
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 12px;
border-radius: var(--radius-lg);
border: 1px solid var(--line2);
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
}
+4 -2
View File
@@ -1,6 +1,7 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
@@ -47,18 +48,19 @@ export function useUploadQuota() {
export function QuotaMeter() { export function QuotaMeter() {
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota(); const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null; if (!quota.data) return null;
const q = quota.data; const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1; const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language); const fmt = (n: number) => formatBytes(n, i18n.language);
return ( return (
<div className={u.quota}> <div className={u.quota}>
<span className={u.quotaText}> <span className={u.quotaText} id={id}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })} {t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null && {q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`} ` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span> </span>
<div className={u.progress} role="progressbar" aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}> <div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} /> <span style={{ width: `${ratio * 100}%` }} />
</div> </div>
</div> </div>
+22 -4
View File
@@ -186,7 +186,10 @@ export class Player {
void this.load(true); void this.load(true);
} }
/** Сменить занятие / настроение на лету: текущий трек доигрывает, дальше — новая пачка. */ /**
* Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7).
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
*/
async setWaveContext(activity: string | null, mood: string | null) { async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave; const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood); if (!wave) return this.startWave(activity, mood);
@@ -197,10 +200,17 @@ export class Player {
body: { activity, mood }, body: { activity, mood },
}), }),
); );
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
this.queue.dropUpcoming(); this.queue.dropUpcoming();
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url)); this.queue.append(fresh);
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } }); this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
this.syncQueue(); if (fresh.length && this.queue.next()) {
this.finishAttempt('skip', true);
this.syncQueue();
void this.load(true);
} else {
this.syncQueue();
}
} }
stopWave() { stopWave() {
@@ -395,11 +405,19 @@ export class Player {
} }
/** Завершает текущую попытку: событие с реально прослушанным временем. */ /** Завершает текущую попытку: событие с реально прослушанным временем. */
private finishAttempt(type: 'play_end' | 'skip') { /** neutral — трек прерван сменой контекста волны: не отрицательный сигнал и без короткого «скипа» в событиях. */
private finishAttempt(type: 'play_end' | 'skip', neutral = false) {
const a = this.attempt; const a = this.attempt;
if (!a) return; if (!a) return;
this.attempt = null; this.attempt = null;
const pos = Math.round(this.audio.currentTime * 1000); const pos = Math.round(this.audio.currentTime * 1000);
if (neutral) {
if (a.playedMs >= EARLY_SKIP_MS) {
this.events.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush();
}
return;
}
const kind = type === 'skip' ? a.outcome(pos) : 'play_end'; 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) }); this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush(); void this.events.flush();
+2
View File
@@ -23,6 +23,8 @@ export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.sv
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.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 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 chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.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 editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg'; export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
+3 -3
View File
@@ -53,13 +53,13 @@
} }
.primary { .primary {
background: var(--accent); background: var(--accent-fill);
color: #fff; color: #fff;
box-shadow: var(--glow); box-shadow: var(--glow);
} }
.primary:hover:not(:disabled) { .primary:hover:not(:disabled) {
background: var(--accent2); background: var(--accent-fill2);
} }
.secondary { .secondary {
@@ -186,7 +186,7 @@
} }
.segmented button[aria-pressed='true'] { .segmented button[aria-pressed='true'] {
background: var(--accent); background: var(--accent-fill);
color: #fff; color: #fff;
} }
+21
View File
@@ -41,6 +41,19 @@ button {
cursor: pointer; cursor: pointer;
} }
/* Элементы управления не выделяются при кликах и двойных кликах — текст остаётся выделяемым на страницах. */
button,
[role='button'],
[role='tab'],
[role='option'],
[role='menuitem'],
[role='listitem'],
nav a,
label {
user-select: none;
-webkit-user-select: none;
}
:focus-visible { :focus-visible {
outline: 2px solid var(--accent); outline: 2px solid var(--accent);
outline-offset: 2px; outline-offset: 2px;
@@ -53,6 +66,14 @@ button {
} }
} }
/* Выезжающие справа панели (очередь, docs/design.md#4). */
@keyframes pipuSlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+20 -4
View File
@@ -27,12 +27,15 @@
padding-bottom: 132px; padding-bottom: 132px;
} }
/* Над контентом: выпадающие подсказки поиска и меню профиля перекрывают страницу, а не уходят под неё. */
.top { .top {
grid-area: top; grid-area: top;
display: flex; z-index: 30;
/* Три зоны: логотип, середина, профиль; поиск — в одной из них (настройка «Расположение поиска»). */
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center; align-items: center;
gap: 24px; gap: 16px;
justify-content: space-between;
padding: 0 20px; padding: 0 20px;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
background: var(--bar-a); background: var(--bar-a);
@@ -51,9 +54,22 @@
} }
} }
.topLeft {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topCenter {
display: flex;
justify-content: center;
}
.topRight { .topRight {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end;
gap: 12px; gap: 12px;
} }
@@ -155,7 +171,7 @@
height: 20px; height: 20px;
padding: 0 6px; padding: 0 6px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--accent); background: var(--accent-fill);
color: #fff; color: #fff;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
+12 -4
View File
@@ -22,6 +22,7 @@ import {
dislikeFillIcon, dislikeFillIcon,
dislikeIcon, dislikeIcon,
settingsIcon, settingsIcon,
androidIcon,
storageFillIcon, storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons'; storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui'; import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
@@ -92,6 +93,8 @@ function Shell() {
const name = user.display_name ?? user.username; const name = user.display_name ?? user.username;
const staff = user.role !== 'user'; const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island'; const island = user.ui_prefs.web_player === 'island';
const searchAt = user.ui_prefs.search_position ?? 'center';
const search = <SearchBox align={searchAt} />;
const counts = useCollectionCounts(); const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin'); const pending = usePendingUploads(user.role === 'admin');
@@ -99,11 +102,15 @@ function Shell() {
<div className={cx(s.shell, island && s.island)}> <div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />} {user.ui_prefs.ambient !== false && <ShellAmbient />}
<header className={s.top}> <header className={s.top}>
<Link to="/" className={s.logoLink}> <div className={s.topLeft}>
<Logo /> <Link to="/" className={s.logoLink}>
</Link> <Logo />
<SearchBox /> </Link>
{searchAt === 'left' && search}
</div>
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
<div className={s.topRight}> <div className={s.topRight}>
{searchAt === 'right' && search}
<Menu <Menu
label={t('profile.menu')} label={t('profile.menu')}
triggerClassName={s.profileButton} triggerClassName={s.profileButton}
@@ -122,6 +129,7 @@ function Shell() {
} }
items={[ items={[
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) }, { label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() }, { label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
]} ]}
/> />
+9 -1
View File
@@ -246,7 +246,15 @@ a.source:hover {
background: var(--glass); background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3); backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow); box-shadow: var(--shadow);
animation: pipuIn 0.2s ease-out; animation: pipuSlideIn 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawerNote {
margin: 0;
padding: 12px 18px 16px;
border-top: 1px solid var(--line);
color: var(--text3);
font-size: 13px;
} }
.drawerIsland { .drawerIsland {
+25 -5
View File
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from 'react'; import { useCallback, useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -35,6 +35,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const player = usePlayer(); const player = usePlayer();
const st = usePlayerState(); const st = usePlayerState();
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId();
const closeQueue = useCallback(() => {
setQueueOpen(false);
queueButton.current?.focus(); // фокус — туда, откуда открыли
}, []);
const track = st.track; const track = st.track;
if (!track) { if (!track) {
@@ -167,10 +173,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} /> <Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button> </button>
<button <button
ref={queueButton}
type="button" type="button"
className={cx(s.icon, queueOpen && s.accent)} className={cx(s.icon, queueOpen && s.accent)}
aria-label={t('player.queue')} aria-label={t('player.queue')}
aria-expanded={queueOpen} aria-expanded={queueOpen}
aria-controls={queueOpen ? queueId : undefined}
onClick={() => setQueueOpen((v) => !v)} onClick={() => setQueueOpen((v) => !v)}
> >
<Icon src={queueIcon} size={20} /> <Icon src={queueIcon} size={20} />
@@ -195,23 +203,33 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties} style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/> />
</div> </div>
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />} {queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
</footer> </footer>
); );
} }
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */ /** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose: () => void }) { function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
const st = usePlayerState(); const st = usePlayerState();
const upcoming = st.queue.slice(st.index, st.index + 40); const upcoming = st.queue.slice(st.index, st.index + 40);
const close = useRef<HTMLButtonElement>(null);
// Клавиатура: при открытии фокус в панель, Escape закрывает.
useEffect(() => {
close.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе. // Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal( return createPortal(
<aside className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}> <aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}> <div className={s.drawerHead}>
<span>{t('player.queue')}</span> <span>{t('player.queue')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}> <button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} /> <Icon src={closeIcon} size={20} />
</button> </button>
</div> </div>
@@ -221,6 +239,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
key={`${tr.id}-${k}`} key={`${tr.id}-${k}`}
type="button" type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)} className={cx(s.queueRow, k === 0 && s.queueCurrent)}
aria-current={k === 0 || undefined}
onClick={() => player.jump(st.index + k)} onClick={() => player.jump(st.index + k)}
> >
<Cover album={tr.album} size={40} /> <Cover album={tr.album} size={40} />
@@ -232,6 +251,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
</button> </button>
))} ))}
</div> </div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>, </aside>,
document.body, document.body,
); );
+47 -6
View File
@@ -1,12 +1,17 @@
.box { .box {
position: relative; position: relative;
flex: 1; flex: none;
max-width: 520px; width: 40px;
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.expanded {
width: min(520px, 46vw);
} }
.icon { .icon {
position: absolute; position: absolute;
left: 14px; left: 11px;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
color: var(--text3); color: var(--text3);
@@ -16,13 +21,36 @@
.input { .input {
width: 100%; width: 100%;
height: 40px; height: 40px;
padding: 0 40px 0 42px; padding: 0 40px 0 40px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
border: 1px solid var(--line); border: 1px solid var(--line);
background: var(--surface2); background: var(--surface2);
color: var(--text); color: var(--text);
font: inherit; font: inherit;
outline: none; outline: none;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s;
}
/* Свёрнутое поле — круглая кнопка: без подсказки и курсора ввода. */
.box:not(.expanded) .input {
padding: 0;
cursor: pointer;
color: transparent;
}
.box:not(.expanded) .input::placeholder {
color: transparent;
}
.box:not(.expanded) .input:hover {
background: var(--surface3);
}
.box:not(.expanded) .kbd {
display: none;
} }
.input:focus { .input:focus {
@@ -56,8 +84,7 @@
.popover { .popover {
position: absolute; position: absolute;
left: 0; width: min(520px, calc(100vw - 32px));
right: 0;
top: calc(100% + 8px); top: calc(100% + 8px);
z-index: 45; z-index: 45;
max-height: min(70vh, 560px); max-height: min(70vh, 560px);
@@ -70,6 +97,20 @@
animation: pipuIn 0.15s ease-out; animation: pipuIn 0.15s ease-out;
} }
/* Подсказки раскрываются в сторону свободного места: слева — вправо, справа — влево, по центру — от середины. */
.left .popover {
left: 0;
}
.right .popover {
right: 0;
}
.center .popover {
left: 50%;
transform: translateX(-50%);
}
.corrected { .corrected {
margin: 4px 10px 8px; margin: 4px 10px 8px;
font-size: 13px; font-size: 13px;
+20 -5
View File
@@ -23,8 +23,11 @@ type Item = {
go: () => void; go: () => void;
}; };
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */ /**
export function SearchBox() { * Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
*/
export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'right' }) {
const { t } = useTranslation(); const { t } = useTranslation();
const session = useSession(); const session = useSession();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -34,6 +37,10 @@ export function SearchBox() {
const [debounced, setDebounced] = useState(''); const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1); const [active, setActive] = useState(-1);
const [focused, setFocused] = useState(false);
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
const expanded = focused || q !== '';
useEffect(() => { useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS); const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
@@ -141,7 +148,7 @@ export function SearchBox() {
const optionId = (i: number) => `${listId}-${i}`; const optionId = (i: number) => `${listId}-${i}`;
return ( return (
<div className={s.box}> <div className={cx(s.box, expanded && s.expanded, s[align])}>
<Icon src={searchIcon} size={18} className={s.icon} /> <Icon src={searchIcon} size={18} className={s.icon} />
<input <input
ref={inputRef} ref={inputRef}
@@ -160,8 +167,16 @@ export function SearchBox() {
setOpen(true); setOpen(true);
setActive(-1); setActive(-1);
}} }}
onFocus={() => setOpen(true)} onFocus={() => {
onBlur={() => setTimeout(close, 120)} // Вернулись в поле раньше, чем сработало закрытие по blur, — подсказки не прячем.
clearTimeout(blurTimer.current);
setFocused(true);
setOpen(true);
}}
onBlur={() => {
setFocused(false);
blurTimer.current = setTimeout(close, 120);
}}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
/> />
<kbd className={s.kbd} aria-hidden> <kbd className={s.kbd} aria-hidden>
+4 -2
View File
@@ -45,6 +45,8 @@ SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0 MIN_ANDROID_VERSION_CODE=0
# --- Самообновление Android (M5) --- # --- Самообновление Android (M5) ---
GITEA_URL=https://git.example.lan # Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
GITEA_REPO=owner/pipu-music # Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
GITEA_URL=
GITEA_REPO=
GITEA_TOKEN= GITEA_TOKEN=
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Резервные копии PostgreSQL (docs/deployment.md#бэкапы). Работает в контейнере backup (compose.yaml) и в CI;
# подключение — стандартные PGHOST, PGUSER, PGPASSWORD, PGDATABASE.
# backup.sh loop — каждый день в BACKUP_HOUR (UTC): копия и ротация; раз в неделю — проверка восстановления
# backup.sh once — копия сейчас; печатает путь к файлу
# backup.sh verify FILE — восстановить FILE во временную базу и сверить таблицы и число строк
# backup.sh restore FILE — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены);
# прежняя база остаётся рядом как <db>_before_restore_<время>
set -euo pipefail
DIR="${BACKUP_DIR:-/backups}"
DB="${PGDATABASE:-pipu}"
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
log() { echo "$(date -u +%FT%TZ) backup: $*" >&2; }
# Число строк в каждой таблице базы $1: «таблица<TAB>строк», по алфавиту.
counts() {
psql -X -d "$1" -Atq -F $'\t' -c "
SELECT table_name,
(xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name),
false, true, '')))[1]::text
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY 1"
}
rotate() {
# shellcheck disable=SC2012 # имена файлов наши, без пробелов
ls -1t "$DIR"/daily/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_DAILY + 1)) | xargs -r rm -f --
# shellcheck disable=SC2012
ls -1t "$DIR"/weekly/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_WEEKLY + 1)) | xargs -r rm -f --
find "$DIR/daily" "$DIR/weekly" -name '*.counts' | while read -r c; do
[ -f "${c%.counts}.dump" ] || rm -f -- "$c"
done
}
once() {
mkdir -p "$DIR/daily" "$DIR/weekly"
local name file
name="pipu-$(date -u +%Y%m%d-%H%M%S)"
file="$DIR/daily/$name.dump"
pg_dump -Fc -d "$DB" -f "$file.part"
pg_restore -l "$file.part" >/dev/null # оглавление читается — архив не обрезан
counts "$DB" >"$file.counts" # для verify: что было в базе в момент копии
mv "$file.part" "$file"
# Еженедельная копия — первая за 7 дней (не зависит от того, в какой день сервер был выключен).
if ! find "$DIR/weekly" -name 'pipu-*.dump' -mtime -7 | grep -q .; then
cp "$file" "$DIR/weekly/$name.dump"
cp "$file.counts" "$DIR/weekly/$name.dump.counts"
fi
rotate
log "$file ($(du -h "$file" | cut -f1))"
echo "$file"
}
verify() {
local file="$1" tmpdb="${DB}_verify" got want
[ -f "$file" ] || { log "нет файла $file"; return 1; }
dropdb --if-exists "$tmpdb"
createdb "$tmpdb"
if ! pg_restore --no-owner --exit-on-error -d "$tmpdb" "$file"; then
dropdb --if-exists "$tmpdb"
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file не восстанавливается"
return 1
fi
got="$(counts "$tmpdb")"
dropdb "$tmpdb"
want="$(cat "$file.counts" 2>/dev/null || counts "$DB")"
# Таблица пропала или опустела — копия негодна. Расхождение в числе строк — рабочая база менялась во время
# копии (предупреждение); VERIFY_EXACT=1 (CI, база в покое) — тоже ошибка.
local bad=0 table n m
while IFS=$'\t' read -r table n; do
m="$(printf '%s\n' "$got" | awk -F'\t' -v t="$table" '$1 == t { print $2 }')"
if [ -z "$m" ]; then
log "в копии нет таблицы $table"
bad=1
elif [ "$m" = 0 ] && [ "$n" != 0 ]; then
log "в копии пуста таблица $table (было $n)"
bad=1
elif [ "$m" != "$n" ]; then
log "$table: $m строк в копии, $n при копировании"
[ "${VERIFY_EXACT:-}" = 1 ] && bad=1
fi
done <<<"$want"
if [ "$bad" = 1 ]; then
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file"
return 1
fi
log "проверено: $file восстанавливается ($(printf '%s\n' "$got" | wc -l) таблиц)"
date -u +%FT%TZ >"$DIR/.last-verify"
}
restore() {
local file="$1" old
verify "$file"
old="${DB}_before_restore_$(date -u +%Y%m%d%H%M%S)"
psql -X -d postgres -Atqc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '$DB' AND pid <> pg_backend_pid()" >/dev/null
psql -X -d postgres -qc "ALTER DATABASE \"$DB\" RENAME TO \"$old\""
createdb "$DB"
if ! pg_restore --no-owner --exit-on-error -d "$DB" "$file"; then
dropdb "$DB"
psql -X -d postgres -qc "ALTER DATABASE \"$old\" RENAME TO \"$DB\""
log "восстановление не удалось — рабочая база не тронута"
return 1
fi
log "база $DB восстановлена из $file; прежняя сохранена как $old (удалить: dropdb $old)"
}
loop() {
local hour="${BACKUP_HOUR:-3}" now next file
log "ежедневная копия в $hour:30 UTC, хранится $KEEP_DAILY ежедневных и $KEEP_WEEKLY еженедельных"
while true; do
now="$(date -u +%s)"
next="$(date -u -d "today $hour:30" +%s)"
[ "$next" -gt "$now" ] || next="$(date -u -d "tomorrow $hour:30" +%s)"
sleep $((next - now))
if ! file="$(once)"; then
log "КОПИЯ НЕ СДЕЛАНА"
continue
fi
if [ -z "$(find "$DIR" -maxdepth 1 -name .last-verify -mtime -7)" ]; then
verify "$file" || true
fi
done
}
case "${1:-}" in
loop) loop ;;
once) once ;;
verify) verify "${2:?укажите файл копии}" ;;
restore) restore "${2:?укажите файл копии}" ;;
*)
sed -n '2,9p' "$0" >&2
exit 2
;;
esac
+23
View File
@@ -20,11 +20,13 @@ x-library-ro: &library-ro
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music. # Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw # Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
# (docs/library-and-storage.md#запись-в-хранилище). # (docs/library-and-storage.md#запись-в-хранилище).
# rslave: диск, смонтированный на хосте уже после старта контейнеров, становится виден внутри без перезапуска.
x-library-host-ro: &library-host-ro x-library-host-ro: &library-host-ro
type: bind type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host} source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host target: /library-host
read_only: true read_only: true
bind: { propagation: rslave }
x-restart: &restart x-restart: &restart
restart: unless-stopped restart: unless-stopped
@@ -96,6 +98,7 @@ services:
- type: bind - type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host} source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host-rw target: /library-host-rw
bind: { propagation: rslave }
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro - ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender: recommender:
@@ -142,6 +145,26 @@ services:
interval: 5s interval: 5s
retries: 20 retries: 20
# Ежедневная копия базы и еженедельная проверка восстановления (deploy/backup.sh, docs/deployment.md#бэкапы).
backup:
<<: *restart
image: pgvector/pgvector:0.8.7-pg17
entrypoint: ["bash", "/backup.sh"]
command: ["loop"]
environment:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: pipu
BACKUP_HOUR: ${BACKUP_HOUR:-3}
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
volumes:
- ./backup.sh:/backup.sh:ro
- ${PIPU_DATA:-/srv/pipu}/backups:/backups
depends_on:
postgres: { condition: service_healthy }
redis: redis:
<<: *restart <<: *restart
image: redis:7.4-alpine image: redis:7.4-alpine
+33
View File
@@ -76,6 +76,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi fi
done done
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
fill_gitea() {
local url repo origin host path
url="$(sed -n 's/^GITEA_URL=//p' .env)"
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
case "$url" in "" | *example.*) url="" ;; esac
case "$repo" in "" | owner/*) repo="" ;; esac
[ -n "$url" ] && [ -n "$repo" ] && return 0
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || return 0
case "$origin" in
http://* | https://*)
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
ssh://*)
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
*:*)
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
path="${origin#*:}" ;;
*) return 0 ;;
esac
path="${path%.git}"
[ -n "$url" ] || url="$host"
[ -n "$repo" ] || repo="$path"
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
done
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
}
fill_gitea
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env # LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
if [ -n "$LIBRARY_HOST" ]; then if [ -n "$LIBRARY_HOST" ]; then
if grep -q '^LIBRARY_HOST=' .env; then if grep -q '^LIBRARY_HOST=' .env; then
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Восстановление базы из резервной копии (docs/deployment.md#бэкапы).
# ./restore.sh — список копий
# ./restore.sh daily/pipu-20261020-033000.dump
# Сервисы, пишущие в базу, останавливаются на время восстановления; прежняя база сохраняется рядом.
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
DATA="$(sed -n 's/^PIPU_DATA=//p' .env)"
DATA="${DATA:-/srv/pipu}"
if [ -z "${1:-}" ]; then
echo "Копии в $DATA/backups (новые сверху):"
(cd "$DATA/backups" && ls -1t daily/*.dump weekly/*.dump 2>/dev/null) || echo " копий нет"
exit 0
fi
rel="${1#"$DATA/backups/"}"
[ -f "$DATA/backups/$rel" ] || { echo "нет файла $DATA/backups/$rel" >&2; exit 1; }
echo "→ восстановление из $rel: api, рекомендации и анализ будут остановлены на время восстановления"
read -r -p "Продолжить? [y/N] " yes
[ "$yes" = y ] || [ "$yes" = Y ] || exit 1
docker compose stop api recommender analyzer storage-agent
if docker compose run --rm backup restore "/backups/$rel"; then
status=0
else
status=1
fi
docker compose up -d
[ "$status" = 0 ] || { echo "восстановление не удалось — база не изменена" >&2; exit 1; }
echo "готово. Поиск переиндексируется при старте api (или вручную: «Администрирование → Библиотека»)."
+48
View File
@@ -26,6 +26,48 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi fi
done done
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
fill_gitea() {
local url repo origin host path
url="$(sed -n 's/^GITEA_URL=//p' .env)"
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
case "$url" in "" | *example.*) url="" ;; esac
case "$repo" in "" | owner/*) repo="" ;; esac
[ -n "$url" ] && [ -n "$repo" ] && return 0
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || return 0
case "$origin" in
http://* | https://*)
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
ssh://*)
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
*:*)
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
path="${origin#*:}" ;;
*) return 0 ;;
esac
path="${path%.git}"
[ -n "$url" ] || url="$host"
[ -n "$repo" ] || repo="$path"
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
done
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
}
fill_gitea
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
echo "LIBRARY_HOST=$LIBRARY_HOST пуст или не существует — диск с музыкой не смонтирован?" >&2
echo " проверьте: findmnt $LIBRARY_HOST; смонтируйте (sudo mount -a) и повторите." >&2
echo " Если папка и должна быть пустой: PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh ${1:-}" >&2
exit 1
fi
REF="${1:-}" REF="${1:-}"
git fetch --tags --prune --quiet origin git fetch --tags --prune --quiet origin
@@ -53,6 +95,12 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
fi fi
fi fi
# Копия базы перед обновлением: миграции назад не откатываются (deploy/restore.sh — вернуть).
if [ -n "$(docker compose ps -q --status running postgres 2>/dev/null)" ]; then
echo "→ резервная копия базы"
docker compose run --rm --no-deps backup once >/dev/null
fi
echo "→ переключаемся на $REF" echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF" git checkout --quiet "$REF"
+1
View File
@@ -23,6 +23,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [testing.md](testing.md) | Стратегия тестирования | | [testing.md](testing.md) | Стратегия тестирования |
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу | | [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android | | [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, бэкапы, диагностика |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы | | [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы |
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям | | [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности | | [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
+139
View File
@@ -0,0 +1,139 @@
# Руководство администратора
Повседневная работа с сервером после установки ([install-guide.md](install-guide.md)): пользователи, музыка,
очередь на добавление, «Моя волна», обновления, бэкапы и диагностика. Все команды — из каталога
`/opt/pipu-music/deploy`. Справочник по сервисам и переменным — [deployment.md](deployment.md).
## 1. Первый вход
1. Логин `admin`. Пароль — `BOOTSTRAP_ADMIN_PASSWORD` из `.env`, а если он не задан — сгенерированный, его
печатает лог один раз: `docker compose logs api | grep -i bootstrap`.
2. Сервер сразу попросит сменить логин и пароль администратора (пароль ≥ 12 символов). До этого остальные
разделы недоступны.
3. Подключите хранилище с музыкой (раздел 3) — после первого скана появятся библиотека, поиск и «Моя волна».
## 2. Пользователи и роли
«Администрирование → Пользователи».
| Роль | Что может |
|---|---|
| Пользователь | Слушать, искать, собирать коллекцию, «Моя волна» |
| Модератор | Плюс: заводить пользователей и сбрасывать им пароли, смотреть статус библиотеки, предлагать треки |
| Администратор | Всё: хранилища, сканы, очередь на добавление, настройки сервера, роли, аудит |
Полная матрица прав — [auth-and-roles.md](auth-and-roles.md#матрица-прав).
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
раз, передайте его человеку. При первом входе пароль придётся сменить.
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
- Последнего администратора нельзя понизить, заблокировать или удалить.
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
паролей, изменения хранилищ и настроек, решения по очереди.
## 3. Хранилища и библиотека
«Администрирование → Хранилища».
- **Папка на сервере** — каталог хоста из `LIBRARY_HOST` в `.env` (например, смонтированный диск `/mnt/music`);
в форме указывается путь внутри него. **NFS** — сервер и экспорт; монтирует `storage-agent`.
- «Проверить» — доступ к хранилищу, число найденных аудиофайлов.
- **Шаблоны путей** («Шаблоны»): как из пути файла получить исполнителя, альбом, год и номер трека, если в тегах
пусто. «Предпросмотр» показывает разбор реальных путей до сохранения. Что важнее — теги или путь, задаётся
в карточке хранилища (по умолчанию теги).
- **Скан:** обычный — только новые и изменённые файлы (по расписанию — интервал в карточке хранилища — и после
изменения настроек), «Полный» — перечитать всё. Удалённые с диска файлы помечаются отсутствующими, а не стираются из истории.
- «Администрирование → Библиотека» — счётчики, прогресс скана и аудиоанализа, файлы с ошибками чтения
(битые файлы, неизвестные форматы) и «Переиндексировать поиск» — если поиск выдаёт устаревшее (обычно не нужно:
индекс обновляется сам и пересобирается при старте api).
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
Не удалось проанализировать часть треков (например, пока диск не был смонтирован) — «Повторить» под счётчиком
ошибок возвращает их в очередь; рядом показана самая частая ошибка. Пропавшие файлы анализатор не считает
попыткой и ждёт, пока диск вернётся.
## 4. Очередь на добавление
Модераторы и администраторы предлагают треки («Предложить треки»), администратор решает, что попадёт
в библиотеку. Подробно — [uploads.md](uploads.md).
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
получит те же исполнителя, альбом и номер трека. Хранилище по умолчанию — в «Настройках сервера».
- **Рассмотрение** («Очередь на добавление»): прослушать, поправить метаданные (в том числе сразу для всего
альбома), одобрить или отклонить с причиной — автор её увидит. Итоговый путь пересчитывается при правке.
Отметка «возможный дубль» — со ссылкой на похожий трек библиотеки; точные копии отсекаются ещё до загрузки.
- **Импорт:** одобренный файл записывается в хранилище только под окончательным именем, с проверкой контрольной
суммы; кроме самих аудиофайлов (и папок для них) на диск ничего не пишется.
- **Квота** («Настройки сервера»): общий лимит очереди (10 ГБ по умолчанию), лимит на одного модератора
(выключен) и максимальный размер файла (2 ГБ). Отклонённые файлы и дубли место не занимают; снижение лимита ниже
занятого не удаляет загрузки, просто новые не принимаются.
## 5. «Моя волна»
- «Администрирование → Библиотека → Моя волна за 7 дней»: сколько треков выдано, доля дослушанных до конца,
ранних скипов и лайков на 100 треков. Ориентир — ранних скипов меньше 30%.
- Таблица по **версиям модели**: `heuristic-v1` — эвристика, `heuristic-v1+cf` — она же с коллаборативной
фильтрацией (ALS), `lgbm-…` — обученный ранжировщик. По ней видно, стало ли лучше после смены модели.
- Обучение — каждую ночь в 3:00 UTC: ALS по прослушиваниям, бандит по источникам
кандидатов, ранжировщик по показам волны. Новая модель включается, только если по проверке на последней неделе
не хуже текущей. Ранжировщику нужно ~20 тыс. показов с известным исходом — до этого работает эвристика.
- Новые пользователи с почти пустой историей проходят **онбординг вкуса**: выбирают исполнителей и жанры
библиотеки. Пропуск тоже запоминается.
## 6. Обновление
```bash
./update.sh
```
Скрипт делает копию базы, переключается на последний релиз, собирает образы, перезапускает сервисы и ждёт
готовности; миграции применяются при старте api. Конкретная версия — `./update.sh v1.0.0`; откат на старую версию
требует `PIPU_ALLOW_DOWNGRADE=1` (миграции не откатываются — сначала проверьте совместимость). Перед обновлением
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
## 7. Бэкапы и восстановление
- Каждую ночь (`BACKUP_HOUR`, по умолчанию 3:30 UTC) контейнер `backup` делает копию базы в
`/srv/pipu/backups/daily` (14 последних) и раз в неделю — в `weekly` (8 последних).
- Раз в неделю копия **проверяется восстановлением** во временную базу. Результат:
`docker compose logs backup | tail`; при ошибке в логе — «ПРОВЕРКА НЕ ПРОЙДЕНА».
- Копия сейчас: `docker compose run --rm backup once`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/<файл>` — восстановить. Прежняя база
остаётся рядом как `pipu_before_restore_<время>`; убедившись, что всё в порядке, удалите её:
`docker compose exec postgres dropdb -U pipu pipu_before_restore_<время>`.
- В копию входит всё, что нельзя пересоздать: пользователи, история, лайки, состояние плеера, очередь на добавление,
результаты аудиоанализа и модели волны. Поиск, обложки и транскоды восстанавливаются сами.
- **Сама музыка не бэкапится** приложением — это ваш диск или NFS. Каталог бэкапов тоже лежит на сервере:
копируйте `/srv/pipu/backups` на другую машину, например `rsync` по cron.
## 8. Наблюдение
| Что | Как |
|---|---|
| Сервер жив и готов | `curl -s https://music.example.com/readyz` → все зависимости `ok` |
| Состояние сервисов | `docker compose ps` |
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`, `backup`) |
| Место на диске | `df -h /srv/pipu`; больше всего занимают `cache/transcode` (чистится сам) и `backups` |
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
## 9. Частые ситуации
| Ситуация | Что делать |
|---|---|
| Ни один трек не играет («Не удалось воспроизвести трек»), в логе api `no such file or directory` | Диск с музыкой не смонтирован: `findmnt <LIBRARY_HOST>`, `sudo mount -a`. Контейнеры увидят диск сами; скан на пустой папке прерывается с ошибкой «is the disk mounted?» и ничего не помечает пропавшим |
| Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» |
| Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан |
| Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» |
| Волна однообразная | Идёт ли аудиоанализ («Библиотека → Аудиоанализ»); чем больше прослушиваний, тем точнее волна |
| Загрузка застряла в «Обработке» | `docker compose logs api | grep upload`; повторный импорт — «Одобрить» ещё раз |
| Пользователь не может войти | Не заблокирован ли; «Сбросить пароль» |
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
+1 -1
View File
@@ -103,7 +103,7 @@ container:
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml - /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
``` ```
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=6g` без свопа, CMake / ninja / Gradle Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там: по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста, фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост. лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
+16 -2
View File
@@ -77,7 +77,7 @@
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | | | `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | | | `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
| `TRANSCODE_CONCURRENCY` | `2` | | | `TRANSCODE_CONCURRENCY` | `2` | |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | `https://git.home.lan`, `owner/pipu-music`, read-only токен | Для самообновления Android | | `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища | | `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) | | `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `LOG_LEVEL` | `info` | | | `LOG_LEVEL` | `info` | |
@@ -97,7 +97,21 @@
## Бэкапы ## Бэкапы
- `pg_dump` ежедневно (cron-контейнер `backup`), хранить 14 дней + еженедельные 8 недель → `/srv/pipu/backups`. - Контейнер `backup` (`deploy/backup.sh loop`): каждый день в `BACKUP_HOUR`:30 UTC (по умолчанию 3:30) —
`pg_dump -Fc` → `/srv/pipu/backups/daily/`, хранится `BACKUP_KEEP_DAILY` = 14 последних; первая копия за неделю
копируется в `weekly/` (`BACKUP_KEEP_WEEKLY` = 8). Рядом с копией — `*.counts`: число строк в каждой таблице.
- Раз в неделю копия **проверяется восстановлением**: разворачивается во временную базу `pipu_verify`, сверяются
таблицы и число строк, база удаляется; время последней успешной проверки — `backups/.last-verify`, ошибки —
в `docker compose logs backup` («ПРОВЕРКА НЕ ПРОЙДЕНА»). Тот же цикл копия → проверка → восстановление
выполняется в CI (`ci / db`) на базе после сквозного сценария.
- `update.sh` перед обновлением делает внеочередную копию.
- Вручную: `docker compose run --rm backup once` — копия сейчас; `docker compose run --rm backup verify /backups/daily/<файл>`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/pipu-….dump` — останавливает api, рекомендации и
анализ, проверяет копию, переименовывает текущую базу в `pipu_before_restore_<время>` (страховка; удалить —
`docker compose exec postgres dropdb -U pipu <имя>`), восстанавливает и запускает сервисы; поиск переиндексируется
при старте api. Redis (сессии волны, очереди) не восстанавливается — пользователи просто начнут новую волну.
- Копии лежат на том же сервере: для защиты от потери диска синхронизируйте `/srv/pipu/backups` на другую машину
(например, `rclone sync` или `rsync` по cron).
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ). - Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен. - Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
- Сама музыка — на NFS, вне зоны ответственности приложения. - Сама музыка — на NFS, вне зоны ответственности приложения.
+2 -1
View File
@@ -48,8 +48,9 @@
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню | | `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
| `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении | | `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении |
| `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки | | `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки |
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#737F9C` | `#0A0F1E` / `#4B5675` / `#677290` | Текст: основной / вторичный / подписи | | `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#7F8BA8` | `#0A0F1E` / `#4B5675` / `#5C6785` | Текст: основной / вторичный / подписи |
| `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки | | `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки |
| `accent-fill` / `accent-fill2` | `#1A66E6` / `#1F6FF2` | `#1A63DB` / `#165ED6` | Заливка под белым текстом (кнопки, выбранные сегменты) и её hover — контраст ≥ 4.5:1 |
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус | | `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) | | `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех | | `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
+17 -4
View File
@@ -250,8 +250,10 @@ curl -s https://music.example.com/readyz
приложение попросит обновиться. приложение попросит обновиться.
Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Для этого в `.env` сервера нужно заполнить и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
`GITEA_URL`, `GITEA_REPO` и `GITEA_TOKEN` (токен только на чтение). `install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
ссылка `https://<ваш-домен>/android`.
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится. > Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции. > Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
@@ -314,12 +316,21 @@ cd /opt/pipu-music/deploy && ./update.sh
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
автоматически при старте API. автоматически при старте API.
Бэкап базы (всё остальное восстанавливается само — см. [deployment.md](deployment.md#бэкапы)): Бэкапы базы делаются сами: каждую ночь в `/srv/pipu/backups`, раз в неделю копия проверяется восстановлением,
перед каждым `update.sh` — внеочередная копия. Всё остальное восстанавливается само — см.
[deployment.md](deployment.md#бэкапы). Копия сейчас и восстановление:
```bash ```bash
docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump cd /opt/pipu-music/deploy && docker compose run --rm backup once
``` ```
```bash
cd /opt/pipu-music/deploy && ./restore.sh
```
Без аргумента `restore.sh` показывает список копий; с именем файла (`./restore.sh daily/pipu-20261020-033000.dump`)
восстанавливает базу, сохранив прежнюю рядом.
Остановить сервис, сохранив данные: Остановить сервис, сохранив данные:
```bash ```bash
@@ -339,6 +350,8 @@ cd /opt/pipu-music/deploy && docker compose down
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша | | Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` | | Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились | | `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
| Сервисы не стартуют: `… is not a shared or slave mount` | `sudo mount --make-rshared /` (и добавьте в автозагрузку); на systemd-системах корень и так shared |
| `update.sh`: «LIBRARY_HOST … пуст» | Диск с музыкой не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; если папка и должна быть пустой — `PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh` |
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` | | Сервисы не стартуют: `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) | | Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`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` | | `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
+10 -6
View File
@@ -35,8 +35,7 @@
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента. `fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`, - Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`. задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет запись: создаёт и удаляет - `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет право записи — без создания файлов.
`.pipu-write-test` в корне.
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре - У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)). и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
@@ -44,8 +43,11 @@
- Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`; - Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`;
`POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог `POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог
внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). Итоговое имя создаётся жёсткой ссылкой `/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). На музыкальный диск не пишется ничего,
на временный файл (`link` не перезаписывает, в отличие от `rename`); занято — ` (2)`, ` (3)`… кроме самих треков: файл создаётся сразу под итоговым именем (`O_EXCL` — существующий не перезаписывается;
занято — ` (2)`, ` (3)`…), `fsync`, сверка SHA-256; при ошибке агент удаляет свой недописанный файл и созданные
для него пустые каталоги. Проверка записи — `access(W_OK)` и флаг read-only файловой системы, без пробного файла.
Остальные сервисы видят музыку только на чтение (`read_only` в compose).
Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять). Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять).
- Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через - Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через
`regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`, `regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`,
@@ -159,7 +161,9 @@ Meilisearch, индексы:
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней). - Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс, - `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
отменяет предыдущий запрос (`AbortController`). отменяет предыдущий запрос (`AbortController`).
- Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше - Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
в выдаче `suggest` (re-rank первых 20 на стороне `api`). (`internal/search/personal.go`). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10,
реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на
2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка.
- Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача - Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача
`reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма). `reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).
+2
View File
@@ -82,6 +82,8 @@
## Самообновление ## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play). Приложение распространяется только через Gitea Releases (не Google Play).
**Поток:** **Поток:**
+19
View File
@@ -152,6 +152,16 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%. Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%.
Пока размеченных показов меньше ~20k, используется v1 (переключение автоматическое по порогу). Пока размеченных показов меньше ~20k, используется v1 (переключение автоматическое по порогу).
Реализация (`pipu_rec/train/ranker.py`): при каждом показе волна пишет вектор признаков кандидата
в `rec_impression_features` (29 признаков, `FEATURE_NAMES` в `wave/engine.py`; порядок версионируется
`FEATURES_VERSION`). Ночью признаки соединяются с исходами `wave_impressions`, группа — сессия волны;
валидация — сессии последних 7 дней. Модель включается, если её NDCG@10 не ниже порядка по оценке v1
и не хуже активной более чем на 2%. Модель хранится строкой в `model_versions.artifact` (kind `ranker`),
сервис загружает активную при старте и после ночного обучения; в выдаче её порядок заменяет оценку v1,
MMR-разнообразие и квоты остаются. Пока модели нет, `model_ver` показа — версия v1.
Онлайн-сравнение — админка «Библиотека → Моя волна»: доля доигрываний, ранних скипов и лайков
за 7 дней отдельно по каждому `model_ver` показа.
### 6.4 Занятия и настроения ### 6.4 Занятия и настроения
Каждый контекст — профиль целевых значений аудиопризнаков (`services/recommender/config/contexts.yaml`, Каждый контекст — профиль целевых значений аудиопризнаков (`services/recommender/config/contexts.yaml`,
@@ -191,6 +201,11 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают
(×0.99 в день), чтобы подстраиваться под смену вкуса. (×0.99 в день), чтобы подстраиваться под смену вкуса.
Реализация (`pipu_rec/train/bandit.py`): параметры пересчитываются ночью по `wave_impressions` за 90 дней
(награда — `complete` / `like`) в `user_profiles_rec.bandit`; при сборке пачки источник получает бонус
`0.2·(θ − 0.5)`, где θ ~ Beta(α, β) (источник без наблюдений — нейтральные 0.5). `exploration` следует за
успехом `fresh` относительно остальных источников (0.05–0.4).
### 6.7 Реакция в реальном времени ### 6.7 Реакция в реальном времени
- Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2. - Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
@@ -204,6 +219,10 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды. - **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
(по одному треку из разных кластеров), exploration 40% на первые 30 треков. (по одному треку из разных кластеров), exploration 40% на первые 30 треков.
- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` —
не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор —
пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1,
жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны.
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания. - **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
## 8. Обучение на всех пользователях ## 8. Обучение на всех пользователях
+5
View File
@@ -49,10 +49,15 @@
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик. - ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
- Персонализация suggest. - Персонализация suggest.
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate. - **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
- **Состояние:** всё реализовано (v0.8, v1.0). Ранжировщик включается сам после ~20 тыс. размеченных показов;
критерий проверяется по метрикам версий в админке («Библиотека → Моя волна») и NDCG в `model_versions`.
## M7 — Стабилизация (v1.0) ## M7 — Стабилизация (v1.0)
- Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора, - Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора,
доступность, полировка анимаций, багфиксы. доступность, полировка анимаций, багфиксы.
- **Состояние:** v1.0 — бэкапы с еженедельной проверкой восстановления (и цикл копия → проверка → восстановление
в CI), [руководство администратора](admin-guide.md), аудит доступности WCAG 2.1 AA, k6-smoke
(`tests/load/smoke.js`, запускается вручную на сервере перед релизом).
## Дальше (идеи) ## Дальше (идеи)
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA, - Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
+25 -1
View File
@@ -49,4 +49,28 @@
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток. - Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
- Тест на каждый исправленный баг. - Тест на каждый исправленный баг.
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно. - Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
- Нагрузочный smoke (k6, вручную перед релизом): 100 одновременных слушателей, 20 rps на suggest — p95 в рамках целей. - Нагрузочный smoke (k6, вручную перед релизом) — см. ниже.
## Нагрузочный smoke
`tests/load/smoke.js` (k6 ≥ 0.50), 3 минуты: 100 одновременных слушателей волны (старт волны, начало потока
каждого трека — первые 64 КБ, события прослушивания, следующая пачка с отзывами) и 20 запросов подсказок поиска
в секунду. Пороги — цели из [api.md](api.md) и [recommendations.md](recommendations.md#10-требования-к-производительности):
| Запрос | p95 |
|---|---|
| `GET /search/suggest` | < 50 мс |
| `POST /wave/sessions/{id}/next` | < 150 мс |
| `POST /wave/sessions` | < 300 мс |
| начало потока (`Range: bytes=0-65535`) | < 300 мс |
| `POST /events` | < 100 мс |
Ошибок < 1%. k6 завершается с ненулевым кодом, если порог не выполнен.
```bash
k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
```
Пользователь — отдельный обычный (`role=user`): тест наполняет его историю и лайки. Нагрузку можно менять:
`-e LISTENERS=20 -e SUGGEST_RPS=5 -e DURATION=1m`. Запускать с машины в той же сети, что и сервер, —
иначе в цифрах будет задержка канала.
+3 -3
View File
@@ -161,9 +161,9 @@
2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`. 2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`.
Агент: Агент:
- создаёт каталоги; - создаёт каталоги;
- копирует во временный файл `.<name>.pipu-tmp` в целевой папке, `fsync`; - создаёт файл сразу под итоговым именем (`O_EXCL`, существующий не перезаписывается), копирует, `fsync`;
- сверяет SHA-256 копии со staging; - сверяет SHA-256 копии со staging; при ошибке удаляет свой файл и созданные пустые каталоги —
- атомарно переименовывает в итоговое имя. на музыкальный диск не попадает ничего, кроме треков.
3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ. 3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ.
В `media_files` сохраняется `upload_id` — история происхождения. В `media_files` сохраняется `upload_id` — история происхождения.
4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`. 4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`.
+12
View File
@@ -101,3 +101,15 @@ apps/web/src/
## Доступность ## Доступность
- Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA. - Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA.
## Доступность
Цель — WCAG 2.1 AA. Проверено axe-core на всех экранах в тёмной и светлой темах (с играющим треком и открытой
очередью) — нарушений нет. Правила, которые держим:
- текст на заливке акцента — только `--accent-fill` (белый текст ≥ 4.5:1), вторичный текст `--text3` — ≥ 4.5:1
на фоне и поверхностях;
- у каждого экрана есть `h1`; у кнопок-иконок — `aria-label`, у переключателей — `aria-pressed`;
- списки треков — `role="list"` / `listitem` (строка фокусируется, Enter запускает трек, лайк и ссылки — отдельные
элементы); прогресс-бары подписаны через `aria-labelledby`;
- выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку;
- `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена.
+157
View File
@@ -880,6 +880,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/admin/library/analysis/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Вернуть в очередь треки, которые не удалось проанализировать (admin) */
post: operations["retryAnalysis"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/search/reindex": { "/api/v1/admin/search/reindex": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1066,6 +1083,43 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/onboarding": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Онбординг вкуса — исполнители и жанры библиотеки для выбора
* @description `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
*/
get: operations["getOnboarding"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/me/onboarding": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Сохранить выбор онбординга (или пропуск — пустые списки) */
put: operations["saveOnboarding"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/wave/options": { "/api/v1/wave/options": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1181,6 +1235,26 @@ export interface paths {
export type webhooks = Record<string, never>; export type webhooks = Record<string, never>;
export interface components { export interface components {
schemas: { schemas: {
Onboarding: {
required: boolean;
artists: {
/** Format: uuid */
id: string;
name: string;
track_count: number;
/** @description Обложка самого большого альбома исполнителя */
cover_url: string | null;
cover_colors: string[];
}[];
genres: {
name: string;
track_count: number;
}[];
};
OnboardingChoice: {
artist_ids: string[];
genres: string[];
};
UserRef: { UserRef: {
/** Format: uuid */ /** Format: uuid */
id: string; id: string;
@@ -1435,6 +1509,11 @@ export interface components {
wave_variant?: "orb" | "liquid"; wave_variant?: "orb" | "liquid";
/** @enum {string} */ /** @enum {string} */
web_player?: "bar" | "island"; web_player?: "bar" | "island";
/**
* @description Где в топбаре веба поиск (по умолчанию center)
* @enum {string}
*/
search_position?: "center" | "left" | "right";
/** @enum {string} */ /** @enum {string} */
mobile_player?: "cover" | "wave"; mobile_player?: "cover" | "wave";
ambient?: boolean; ambient?: boolean;
@@ -1955,6 +2034,8 @@ export interface components {
done: number; done: number;
failed: number; failed: number;
analyzer_ver: string | null; analyzer_ver: string | null;
/** @description Самая частая ошибка среди неудавшихся — подсказка, что чинить */
top_error: string | null;
}; };
/** @description Метрики волны за 7 дней */ /** @description Метрики волны за 7 дней */
WaveMetrics: { WaveMetrics: {
@@ -1964,6 +2045,15 @@ export interface components {
/** @description Лайков на 100 треков */ /** @description Лайков на 100 треков */
like_rate: number | null; like_rate: number | null;
sessions: number; sessions: number;
/** @description Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн */
by_model: components["schemas"]["WaveModelMetrics"][];
};
WaveModelMetrics: {
model_ver: string;
served: number;
listen_through_rate: number | null;
early_skip_rate: number | null;
like_rate: number | null;
}; };
AndroidRelease: { AndroidRelease: {
/** @example 0.6.1 */ /** @example 0.6.1 */
@@ -3289,6 +3379,29 @@ export interface operations {
default: components["responses"]["Problem"]; default: components["responses"]["Problem"];
}; };
}; };
retryAnalysis: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Сколько задач снова в очереди */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
requeued: number;
};
};
};
default: components["responses"]["Problem"];
};
};
reindexSearch: { reindexSearch: {
parameters: { parameters: {
query?: never; query?: never;
@@ -3599,6 +3712,50 @@ export interface operations {
default: components["responses"]["Problem"]; default: components["responses"]["Problem"];
}; };
}; };
getOnboarding: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Варианты и состояние */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Onboarding"];
};
};
default: components["responses"]["Problem"];
};
};
saveOnboarding: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["OnboardingChoice"];
};
};
responses: {
/** @description Сохранено */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
default: components["responses"]["Problem"];
};
};
getWaveOptions: { getWaveOptions: {
parameters: { parameters: {
query?: never; query?: never;
@@ -22,9 +22,11 @@
--line2: rgba(130,160,255,.18); --line2: rgba(130,160,255,.18);
--text: #EDF1FF; --text: #EDF1FF;
--text2: #A0ABC6; --text2: #A0ABC6;
--text3: #737F9C; --text3: #7F8BA8;
--accent: #2F80FF; --accent: #2F80FF;
--accent2: #5E9BFF; --accent2: #5E9BFF;
--accent-fill: #1A66E6;
--accent-fill2: #1F6FF2;
--accent-soft: rgba(47,128,255,.15); --accent-soft: rgba(47,128,255,.15);
--violet: #9B7CFF; --violet: #9B7CFF;
--violet-soft: rgba(124,92,255,.16); --violet-soft: rgba(124,92,255,.16);
@@ -56,9 +58,11 @@
--line2: rgba(20,35,80,.17); --line2: rgba(20,35,80,.17);
--text: #0A0F1E; --text: #0A0F1E;
--text2: #4B5675; --text2: #4B5675;
--text3: #677290; --text3: #5C6785;
--accent: #1F6FF2; --accent: #1F6FF2;
--accent2: #1F6FF2; --accent2: #1F6FF2;
--accent-fill: #1A63DB;
--accent-fill2: #165ED6;
--accent-soft: rgba(31,111,242,.10); --accent-soft: rgba(31,111,242,.10);
--violet: #6A47F0; --violet: #6A47F0;
--violet-soft: rgba(106,71,240,.10); --violet-soft: rgba(106,71,240,.10);
@@ -90,9 +94,11 @@
--line2: rgba(20,35,80,.17); --line2: rgba(20,35,80,.17);
--text: #0A0F1E; --text: #0A0F1E;
--text2: #4B5675; --text2: #4B5675;
--text3: #677290; --text3: #5C6785;
--accent: #1F6FF2; --accent: #1F6FF2;
--accent2: #1F6FF2; --accent2: #1F6FF2;
--accent-fill: #1A63DB;
--accent-fill2: #165ED6;
--accent-soft: rgba(31,111,242,.10); --accent-soft: rgba(31,111,242,.10);
--violet: #6A47F0; --violet: #6A47F0;
--violet-soft: rgba(106,71,240,.10); --violet-soft: rgba(106,71,240,.10);
@@ -123,9 +129,11 @@
--line2: rgba(130,160,255,.18); --line2: rgba(130,160,255,.18);
--text: #EDF1FF; --text: #EDF1FF;
--text2: #A0ABC6; --text2: #A0ABC6;
--text3: #737F9C; --text3: #7F8BA8;
--accent: #2F80FF; --accent: #2F80FF;
--accent2: #5E9BFF; --accent2: #5E9BFF;
--accent-fill: #1A66E6;
--accent-fill2: #1F6FF2;
--accent-soft: rgba(47,128,255,.15); --accent-soft: rgba(47,128,255,.15);
--violet: #9B7CFF; --violet: #9B7CFF;
--violet-soft: rgba(124,92,255,.16); --violet-soft: rgba(124,92,255,.16);
@@ -13,9 +13,11 @@ export const tokens = {
"line2": "rgba(130,160,255,.18)", "line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF", "text": "#EDF1FF",
"text2": "#A0ABC6", "text2": "#A0ABC6",
"text3": "#737F9C", "text3": "#7F8BA8",
"accent": "#2F80FF", "accent": "#2F80FF",
"accent2": "#5E9BFF", "accent2": "#5E9BFF",
"accent-fill": "#1A66E6",
"accent-fill2": "#1F6FF2",
"accent-soft": "rgba(47,128,255,.15)", "accent-soft": "rgba(47,128,255,.15)",
"violet": "#9B7CFF", "violet": "#9B7CFF",
"violet-soft": "rgba(124,92,255,.16)", "violet-soft": "rgba(124,92,255,.16)",
@@ -42,9 +44,11 @@ export const tokens = {
"line2": "rgba(20,35,80,.17)", "line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E", "text": "#0A0F1E",
"text2": "#4B5675", "text2": "#4B5675",
"text3": "#677290", "text3": "#5C6785",
"accent": "#1F6FF2", "accent": "#1F6FF2",
"accent2": "#1F6FF2", "accent2": "#1F6FF2",
"accent-fill": "#1A63DB",
"accent-fill2": "#165ED6",
"accent-soft": "rgba(31,111,242,.10)", "accent-soft": "rgba(31,111,242,.10)",
"violet": "#6A47F0", "violet": "#6A47F0",
"violet-soft": "rgba(106,71,240,.10)", "violet-soft": "rgba(106,71,240,.10)",
+5 -5
View File
@@ -1,11 +1,11 @@
{ {
"$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate", "$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate. accent-fill — заливка под белым текстом (контраст ≥ 4.5:1, WCAG AA)",
"color": { "color": {
"dark": { "dark": {
"bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236", "bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236",
"elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)", "elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#737F9C", "text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#7F8BA8",
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-soft": "rgba(47,128,255,.15)", "accent": "#2F80FF", "accent2": "#5E9BFF", "accent-fill": "#1A66E6", "accent-fill2": "#1F6FF2", "accent-soft": "rgba(47,128,255,.15)",
"violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)", "violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)",
"ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)", "ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)",
"danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)", "danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)",
@@ -14,8 +14,8 @@
"light": { "light": {
"bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3", "bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3",
"elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)", "elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#677290", "text": "#0A0F1E", "text2": "#4B5675", "text3": "#5C6785",
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-soft": "rgba(31,111,242,.10)", "accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-fill": "#1A63DB", "accent-fill2": "#165ED6", "accent-soft": "rgba(31,111,242,.10)",
"violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)", "violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)",
"ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)", "ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)",
"danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)", "danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)",
+4 -1
View File
@@ -158,7 +158,10 @@
"wave_sessions": "Sessions", "wave_sessions": "Sessions",
"wave_listen": "Played ≥ 50%", "wave_listen": "Played ≥ 50%",
"wave_early_skip": "Early skips", "wave_early_skip": "Early skips",
"wave_likes": "Likes per 100" "wave_likes": "Likes per 100",
"wave_model": "Model version",
"wave_by_model": "Wave model versions compared",
"analysis_retry": "Retry"
}, },
"write_template": { "write_template": {
"title": "Uploads and path template", "title": "Uploads and path template",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "At least {{min}} characters", "min_length": "At least {{min}} characters",
"submit": "Save and continue", "submit": "Save and continue",
"logout": "Sign out" "logout": "Sign out"
},
"app": {
"link": "Android app",
"title": "pipu for Android",
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
"download": "Download APK",
"version": "version {{version}}",
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
"step_server": "Launch pipu and enter the server address:",
"copy_server": "Copy the server address",
"step_updates": "Sign in with your account. The app offers new versions by itself.",
"checksum": "SHA-256 checksum",
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
"to_login": "Sign in to the web app"
} }
} }
+21 -3
View File
@@ -39,7 +39,8 @@
"profile": { "profile": {
"menu": "Profile menu", "menu": "Profile menu",
"settings": "Settings", "settings": "Settings",
"logout": "Sign out" "logout": "Sign out",
"android_app": "Android app"
}, },
"roles": { "roles": {
"admin": "Administrator", "admin": "Administrator",
@@ -81,7 +82,8 @@
"source_wave": "My Wave", "source_wave": "My Wave",
"source_queue": "Queue", "source_queue": "Queue",
"queue": "Queue", "queue": "Queue",
"source_wave_seed": "Artist wave · {{label}}" "source_wave_seed": "Artist wave · {{label}}",
"queue_wave": "The wave never ends: next tracks are picked as you listen"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
@@ -113,7 +115,11 @@
"player_island": "Island", "player_island": "Island",
"ambient": "Ambient background", "ambient": "Ambient background",
"on": "On", "on": "On",
"off": "Off" "off": "Off",
"search_position": "Search position",
"search_left": "Left",
"search_center": "Center",
"search_right": "Right"
}, },
"form": { "form": {
"show_password": "Show password", "show_password": "Show password",
@@ -278,5 +284,17 @@
"cache": "Recent tracks cache", "cache": "Recent tracks cache",
"cache_clear": "Clear ({{size}} MB)", "cache_clear": "Clear ({{size}} MB)",
"cache_pending": "Will be cleared on next launch" "cache_pending": "Will be cleared on next launch"
},
"onboarding": {
"title": "What do you like to listen to?",
"subtitle": "Pick a few artists and genres to start My Wave. After that it learns from what you play.",
"artists": "Artists",
"genres": "Genres",
"skip": "Skip",
"done": "Done — to the wave",
"pick_more_one": "Pick {{count}} more artist",
"pick_more_few": "Pick {{count}} more artists",
"pick_more_many": "Pick {{count}} more artists",
"pick_more_other": "Pick {{count}} more artists"
} }
} }
+4 -1
View File
@@ -158,7 +158,10 @@
"wave_sessions": "Сессий", "wave_sessions": "Сессий",
"wave_listen": "Дослушано ≥ 50%", "wave_listen": "Дослушано ≥ 50%",
"wave_early_skip": "Ранние скипы", "wave_early_skip": "Ранние скипы",
"wave_likes": "Лайков на 100" "wave_likes": "Лайков на 100",
"wave_model": "Версия модели",
"wave_by_model": "Сравнение версий модели волны",
"analysis_retry": "Повторить"
}, },
"write_template": { "write_template": {
"title": "Приём загрузок и шаблон пути", "title": "Приём загрузок и шаблон пути",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "Не короче {{min}} символов", "min_length": "Не короче {{min}} символов",
"submit": "Сохранить и продолжить", "submit": "Сохранить и продолжить",
"logout": "Выйти" "logout": "Выйти"
},
"app": {
"link": "Приложение для Android",
"title": "pipu для Android",
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
"download": "Скачать APK",
"version": "версия {{version}}",
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"step_server": "Запустите pipu и введите адрес сервера:",
"copy_server": "Скопировать адрес сервера",
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
"checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
"to_login": "Войти в веб-версию"
} }
} }
+21 -3
View File
@@ -39,7 +39,8 @@
"profile": { "profile": {
"menu": "Меню профиля", "menu": "Меню профиля",
"settings": "Настройки", "settings": "Настройки",
"logout": "Выйти" "logout": "Выйти",
"android_app": "Приложение для Android"
}, },
"roles": { "roles": {
"admin": "Администратор", "admin": "Администратор",
@@ -81,7 +82,8 @@
"source_wave": "Моя волна", "source_wave": "Моя волна",
"source_queue": "Очередь", "source_queue": "Очередь",
"queue": "Очередь", "queue": "Очередь",
"source_wave_seed": "Волна по исполнителю · {{label}}" "source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
}, },
"settings": { "settings": {
"title": "Настройки", "title": "Настройки",
@@ -113,7 +115,11 @@
"player_island": "Остров", "player_island": "Остров",
"ambient": "Живой фон", "ambient": "Живой фон",
"on": "Вкл.", "on": "Вкл.",
"off": "Выкл." "off": "Выкл.",
"search_position": "Расположение поиска",
"search_left": "Слева",
"search_center": "По центру",
"search_right": "Справа"
}, },
"form": { "form": {
"show_password": "Показать пароль", "show_password": "Показать пароль",
@@ -278,5 +284,17 @@
"cache": "Кэш недавних треков", "cache": "Кэш недавних треков",
"cache_clear": "Очистить ({{size}} МБ)", "cache_clear": "Очистить ({{size}} МБ)",
"cache_pending": "Очистится при следующем запуске" "cache_pending": "Очистится при следующем запуске"
},
"onboarding": {
"title": "Что вы любите слушать?",
"subtitle": "Отметьте несколько исполнителей и жанров — с них начнётся «Моя волна». Дальше она будет учиться на ваших прослушиваниях.",
"artists": "Исполнители",
"genres": "Жанры",
"skip": "Пропустить",
"done": "Готово — к волне",
"pick_more_one": "Выберите ещё {{count}} исполнителя",
"pick_more_few": "Выберите ещё {{count}} исполнителя",
"pick_more_many": "Выберите ещё {{count}} исполнителей",
"pick_more_other": "Выберите ещё {{count}} исполнителя"
} }
} }
+21
View File
@@ -271,6 +271,27 @@ func TestUserJourney(t *testing.T) {
c.ok("GET", "/api/v1/me/likes", nil, &likes) c.ok("GET", "/api/v1/me/likes", nil, &likes)
require.Equal(t, 1, likes.Total) require.Equal(t, 1, likes.Total)
// Персональные подсказки: лайкнутый трек — первым среди треков альбома.
var sug struct {
Tracks []track `json:"tracks"`
}
c.ok("GET", "/api/v1/search/suggest?q="+url.QueryEscape("кино"), nil, &sug)
require.NotEmpty(t, sug.Tracks)
require.Equal(t, tr.ID, sug.Tracks[0].ID)
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
var onb struct {
Required bool `json:"required"`
Artists []struct {
ID string `json:"id"`
} `json:"artists"`
}
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.NotEmpty(t, onb.Artists)
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}}, nil)
c.ok("GET", "/api/v1/onboarding", nil, &onb)
require.False(t, onb.Required)
// 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки. // 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки.
var wave struct { var wave struct {
SessionID string `json:"session_id"` SessionID string `json:"session_id"`
+1 -1
View File
@@ -170,7 +170,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Auth: auth.NewHandler(authSvc, cfg.CookieSecure), Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc, AuthService: authSvc,
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel), Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
Search: search.NewHandler(meili, libStore, indexer, dto, log), Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
Playback: pb, Playback: pb,
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken, Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log), Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
+3 -3
View File
@@ -6,6 +6,7 @@ tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/cespare/xxhash/v2 v2.3.0
github.com/getkin/kin-openapi v0.127.0 github.com/getkin/kin-openapi v0.127.0
github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/chi/v5 v5.2.2
github.com/golang-jwt/jwt/v5 v5.2.2 github.com/golang-jwt/jwt/v5 v5.2.2
@@ -16,11 +17,12 @@ require (
github.com/redis/go-redis/v9 v9.10.0 github.com/redis/go-redis/v9 v9.10.0
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
golang.org/x/sys v0.33.0
golang.org/x/text v0.25.0
) )
require ( require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect 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/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
@@ -43,8 +45,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.17.0 // indirect golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.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 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -100,8 +100,10 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log} return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
} }
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
func (h *Handler) configured() bool { func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
} }
func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) { func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
+1
View File
@@ -565,5 +565,6 @@ func TestPublicRoutes(t *testing.T) {
e := newEnv(t) e := newEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code) assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code) assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
} }
+9 -7
View File
@@ -241,13 +241,14 @@ func MeDTO(u User) gen.Me {
func uiPrefsDTO(p UIPrefs) gen.UiPrefs { func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{ return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme), Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant), WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer), WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer), SearchPosition: (*gen.UiPrefsSearchPosition)(p.SearchPos),
Ambient: p.Ambient, MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
CrossfadeS: p.CrossfadeS, Ambient: p.Ambient,
Normalize: p.Normalize, CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
} }
} }
@@ -256,6 +257,7 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
Theme: (*string)(p.Theme), Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant), WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer), WebPlayer: (*string)(p.WebPlayer),
SearchPos: (*string)(p.SearchPosition),
MobilePlayer: (*string)(p.MobilePlayer), MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient, Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS, CrossfadeS: p.CrossfadeS,
+4 -1
View File
@@ -55,6 +55,8 @@ var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true, "GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true, "POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true, "POST /api/v1/auth/refresh": true,
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
"GET /api/v1/app/android/latest": true,
} }
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль. // Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
@@ -90,7 +92,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись. // Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя. // Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) && cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/")) (strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover { if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
return return
+1
View File
@@ -37,6 +37,7 @@ type UIPrefs struct {
Theme *string `json:"theme,omitempty"` Theme *string `json:"theme,omitempty"`
WaveVariant *string `json:"wave_variant,omitempty"` WaveVariant *string `json:"wave_variant,omitempty"`
WebPlayer *string `json:"web_player,omitempty"` WebPlayer *string `json:"web_player,omitempty"`
SearchPos *string `json:"search_position,omitempty"`
MobilePlayer *string `json:"mobile_player,omitempty"` MobilePlayer *string `json:"mobile_player,omitempty"`
Ambient *bool `json:"ambient,omitempty"` Ambient *bool `json:"ambient,omitempty"`
CrossfadeS *int `json:"crossfade_s,omitempty"` CrossfadeS *int `json:"crossfade_s,omitempty"`
+9 -4
View File
@@ -86,10 +86,11 @@ var commonPasswords = func() map[string]struct{} {
}() }()
var uiPrefEnums = map[string][]string{ var uiPrefEnums = map[string][]string{
"theme": {"dark", "light", "system"}, "theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"}, "wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"}, "web_player": {"bar", "island"},
"mobile_player": {"cover", "wave"}, "search_position": {"center", "left", "right"},
"mobile_player": {"cover", "wave"},
} }
// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда. // ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда.
@@ -107,6 +108,7 @@ func ValidateUIPrefs(p UIPrefs) error {
} }
for name, v := range map[string]*string{ for name, v := range map[string]*string{
"theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer, "theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer,
"search_position": p.SearchPos,
} { } {
if err := check(name, v); err != nil { if err := check(name, v); err != nil {
return err return err
@@ -130,6 +132,9 @@ func MergeUIPrefs(base, patch UIPrefs) UIPrefs {
if patch.WebPlayer != nil { if patch.WebPlayer != nil {
base.WebPlayer = patch.WebPlayer base.WebPlayer = patch.WebPlayer
} }
if patch.SearchPos != nil {
base.SearchPos = patch.SearchPos
}
if patch.MobilePlayer != nil { if patch.MobilePlayer != nil {
base.MobilePlayer = patch.MobilePlayer base.MobilePlayer = patch.MobilePlayer
} }
@@ -103,7 +103,8 @@ func TestLibraryAndUploads(t *testing.T) {
assert.True(t, src.AcceptsUploads) assert.True(t, src.AcceptsUploads)
require.NotNil(t, src.WriteTemplate) require.NotNil(t, src.WriteTemplate)
scanner := library.NewScanner(lib, fakeProber{}, library.NewProgressBoard(), log) board := library.NewProgressBoard()
scanner := library.NewScanner(lib, fakeProber{}, board, log)
var fileIDs []uuid.UUID var fileIDs []uuid.UUID
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} { for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
id, err := scanner.ScanFile(ctx, src, root, rel) id, err := scanner.ScanFile(ctx, src, root, rel)
@@ -114,6 +115,15 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, fileIDs[0], again, "rescan keeps the file row") assert.Equal(t, fileIDs[0], again, "rescan keeps the file row")
// Пустой каталог при известных файлах (диск не смонтирован) — скан прерывается и ничего не помечает пропавшим.
require.NoError(t, scanner.Start(src, t.TempDir(), false))
require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 50*time.Millisecond)
p, _ := board.Get(src.ID)
assert.Contains(t, p.Error, "is the disk mounted")
var missing int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM media_files WHERE source_id = $1 AND missing_since IS NOT NULL`, src.ID).Scan(&missing))
assert.Zero(t, missing)
// --- Отпечатки --- // --- Отпечатки ---
todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10) todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10)
require.NoError(t, err) require.NoError(t, err)
@@ -198,6 +198,7 @@ func (fp *Fingerprinter) chromaprint() bool {
func (fp *Fingerprinter) Run(ctx context.Context) { func (fp *Fingerprinter) Run(ctx context.Context) {
withFP := fp.chromaprint() withFP := fp.chromaprint()
roots := map[uuid.UUID]string{} roots := map[uuid.UUID]string{}
unavailable := map[uuid.UUID]bool{} // каталог источника пуст или не открывается — диск не смонтирован
var after uuid.UUID var after uuid.UUID
for ctx.Err() == nil { for ctx.Err() == nil {
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200) todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
@@ -213,6 +214,9 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
return return
} }
after = f.ID after = f.ID
if unavailable[f.SourceID] {
continue
}
root, ok := roots[f.SourceID] root, ok := roots[f.SourceID]
if !ok { if !ok {
if root, err = fp.rootOf(ctx, f.SourceID); err != nil { if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
@@ -220,12 +224,31 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
continue continue
} }
roots[f.SourceID] = root roots[f.SourceID] = root
if empty, err := dirEmpty(root); empty || err != nil {
fp.log.Warn("fingerprints: source folder is empty or unreadable, skipped — is the disk mounted?",
"source_id", f.SourceID, "root", root, "err", err)
unavailable[f.SourceID] = true
continue
}
} }
fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP) fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP)
} }
} }
} }
func dirEmpty(dir string) (bool, error) {
d, err := os.Open(dir)
if err != nil {
return false, err
}
defer func() { _ = d.Close() }()
names, err := d.Readdirnames(1)
if errors.Is(err, io.EOF) {
return true, nil
}
return len(names) == 0, err
}
func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) { func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) {
sum := f.SHA256 sum := f.SHA256
if sum == nil { if sum == nil {
+17 -2
View File
@@ -292,6 +292,15 @@ func (h *Handler) ScanSource(ctx context.Context, req gen.ScanSourceRequestObjec
return gen.ScanSource202JSONResponse(progressDTO(p)), nil return gen.ScanSource202JSONResponse(progressDTO(p)), nil
} }
// RetryAnalysis реализует gen.StrictServerInterface.
func (h *Handler) RetryAnalysis(ctx context.Context, _ gen.RetryAnalysisRequestObject) (gen.RetryAnalysisResponseObject, error) {
n, err := h.svc.RetryAnalysis(ctx)
if err != nil {
return nil, err
}
return gen.RetryAnalysis200JSONResponse{Requeued: n}, nil
}
// GetLibraryStatus реализует gen.StrictServerInterface. // GetLibraryStatus реализует gen.StrictServerInterface.
func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) { func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) {
st, err := h.svc.Status(ctx) st, err := h.svc.Status(ctx)
@@ -304,13 +313,19 @@ func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRe
MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{}, MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{},
Analysis: gen.AnalysisStatus{ Analysis: gen.AnalysisStatus{
Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done, Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done,
Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, TopError: st.Analysis.TopError,
}, },
Wave: gen.WaveMetrics{ Wave: gen.WaveMetrics{
Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough, Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough,
EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate, EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate, ByModel: []gen.WaveModelMetrics{},
}, },
} }
for _, m := range st.Wave.ByModel {
out.Wave.ByModel = append(out.Wave.ByModel, gen.WaveModelMetrics{
ModelVer: m.ModelVer, Served: m.Served, ListenThroughRate: m.ListenThrough,
EarlySkipRate: m.EarlySkip, LikeRate: m.LikeRate,
})
}
for _, s := range st.Sources { for _, s := range st.Sources {
dto := h.sourceDTO(s) dto := h.sourceDTO(s)
out.Sources = append(out.Sources, gen.SourceStatusItem{ out.Sources = append(out.Sources, gen.SourceStatusItem{
+17
View File
@@ -119,6 +119,15 @@ type walked struct {
hash []byte hash []byte
} }
func hasPresent(files map[string]FileRow) bool {
for _, f := range files {
if !f.Missing {
return true
}
}
return false
}
// job — файл, который нужно прочитать. // job — файл, который нужно прочитать.
type job struct { type job struct {
walked walked
@@ -135,6 +144,11 @@ type probed struct {
// ErrScanRunning — скан источника уже идёт. // ErrScanRunning — скан источника уже идёт.
var ErrScanRunning = errors.New("library: scan already running") var ErrScanRunning = errors.New("library: scan already running")
// ErrSourceEmpty — в каталоге источника нет ни одного файла, а в библиотеке они есть: скорее всего, диск
// не смонтирован. Скан прерывается, чтобы не пометить всю библиотеку отсутствующей (и через MissingRetention —
// не удалить треки вместе с историей).
var ErrSourceEmpty = errors.New("library: source folder is empty but the library has its files — is the disk mounted?")
// Start запускает скан в фоне. root — каталог источника в файловой системе api. // Start запускает скан в фоне. root — каталог источника в файловой системе api.
func (sc *Scanner) Start(src Source, root string, full bool) error { func (sc *Scanner) Start(src Source, root string, full bool) error {
if !sc.board.start(src.ID, full, sc.now()) { if !sc.board.start(src.ID, full, sc.now()) {
@@ -192,6 +206,9 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
if err != nil { if err != nil {
return err return err
} }
if len(found) == 0 && hasPresent(existing) {
return ErrSourceEmpty
}
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) }) sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
var ( var (
+9
View File
@@ -587,6 +587,15 @@ func (s *Service) previewOne(ctx context.Context, root, rel string, patterns []m
} }
// StartScan запускает скан источника в фоне. // StartScan запускает скан источника в фоне.
// RetryAnalysis возвращает в очередь треки, которые анализатор не смог обработать (например, пока диск
// не был смонтирован).
func (s *Service) RetryAnalysis(ctx context.Context) (int, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return 0, err
}
return s.store.RetryFailedAnalysis(ctx)
}
func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) { func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil { if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Progress{}, err return Progress{}, err
+63 -12
View File
@@ -605,7 +605,7 @@ func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string
// AnalysisCounts — состояние очереди аудиоанализа. // AnalysisCounts — состояние очереди аудиоанализа.
type AnalysisCounts struct { type AnalysisCounts struct {
Queued, Running, Done, Failed int Queued, Running, Done, Failed int
AnalyzerVer *string AnalyzerVer, TopError *string
} }
// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора. // AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора.
@@ -614,20 +614,59 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'), SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'), count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1) (SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer) (SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL
GROUP BY error ORDER BY count(*) DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
if err != nil { if err != nil {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err) return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
} }
return c, nil return c, nil
} }
// RetryFailedAnalysis возвращает неудавшиеся задачи анализа в очередь с нуля попыток.
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
tag, err := st.pool.Exec(ctx, `
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
WHERE status = 'failed'`)
if err != nil {
return 0, fmt.Errorf("library: retry analysis: %w", err)
}
return int(tag.RowsAffected()), nil
}
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех). // WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
type WaveMetrics struct { type WaveMetrics struct {
Served, Sessions int Served, Sessions int
WaveRates
ByModel []WaveModelMetrics
}
// WaveRates — доли по показам с известным исходом (nil — исходов ещё нет).
type WaveRates struct {
ListenThrough, EarlySkip, LikeRate *float32 ListenThrough, EarlySkip, LikeRate *float32
} }
// WaveModelMetrics — метрики показов одной версии модели.
type WaveModelMetrics struct {
ModelVer string
Served int
WaveRates
}
func rates(judged, through, early, liked int) WaveRates {
if judged == 0 {
return WaveRates{}
}
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
return WaveRates{ListenThrough: &lt, EarlySkip: &es, LikeRate: &lr}
}
const waveOutcomes = `count(outcome),
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
count(*) FILTER (WHERE outcome = 'like')`
// WaveMetrics считает метрики по показам с известным исходом. // WaveMetrics считает метрики по показам с известным исходом.
func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) { func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
var ( var (
@@ -635,20 +674,32 @@ func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
judged, through, early, liked int judged, through, early, liked int
) )
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
SELECT count(*), count(DISTINCT wave_session_id), count(outcome), SELECT count(*), count(DISTINCT wave_session_id), `+waveOutcomes+`
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
count(*) FILTER (WHERE outcome = 'like')
FROM wave_impressions WHERE served_at > now() - interval '7 days'`). FROM wave_impressions WHERE served_at > now() - interval '7 days'`).
Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked) Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked)
if err != nil { if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err) return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err)
} }
if judged > 0 { m.WaveRates = rates(judged, through, early, liked)
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
m.ListenThrough, m.EarlySkip, m.LikeRate = &lt, &es, &lr rows, err := st.pool.Query(ctx, `
SELECT coalesce(model_ver, ''), count(*), `+waveOutcomes+`
FROM wave_impressions WHERE served_at > now() - interval '7 days'
GROUP BY 1 ORDER BY 2 DESC`)
if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
} }
return m, nil defer rows.Close()
m.ByModel = []WaveModelMetrics{}
for rows.Next() {
var mm WaveModelMetrics
if err := rows.Scan(&mm.ModelVer, &mm.Served, &judged, &through, &early, &liked); err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
}
mm.WaveRates = rates(judged, through, early, liked)
m.ByModel = append(m.ByModel, mm)
}
return m, rows.Err()
} }
// FingerprintTodo — файл, которому нужны отпечатки. // FingerprintTodo — файл, которому нужны отпечатки.
+5
View File
@@ -77,6 +77,7 @@ func (m *Meili) wait(ctx context.Context, t taskRef) error {
Status string `json:"status"` Status string `json:"status"`
Error *struct { Error *struct {
Message string `json:"message"` Message string `json:"message"`
Code string `json:"code"`
} `json:"error"` } `json:"error"`
} }
if err := m.do(ctx, http.MethodGet, fmt.Sprintf("/tasks/%d", t.TaskUID), nil, &task); err != nil { if err := m.do(ctx, http.MethodGet, fmt.Sprintf("/tasks/%d", t.TaskUID), nil, &task); err != nil {
@@ -88,6 +89,10 @@ func (m *Meili) wait(ctx context.Context, t taskRef) error {
case "failed", "canceled": case "failed", "canceled":
msg := task.Status msg := task.Status
if task.Error != nil { if task.Error != nil {
// Удаление отсутствующего индекса принимается (202) и падает уже в задаче.
if task.Error.Code == "index_not_found" {
return fmt.Errorf("%w: task %d: %s", ErrMeiliNotFound, t.TaskUID, task.Error.Message)
}
msg = task.Error.Message msg = task.Error.Message
} }
return fmt.Errorf("meilisearch: task %d: %s", t.TaskUID, msg) return fmt.Errorf("meilisearch: task %d: %s", t.TaskUID, msg)
@@ -0,0 +1,28 @@
package search
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
)
// На чистом Meilisearch удаление несуществующего индекса падает в задаче, а не ответом 404.
func TestCallMapsFailedIndexTaskToNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
if r.Method == http.MethodDelete {
w.WriteHeader(http.StatusAccepted)
_, _ = w.Write([]byte(`{"taskUid":0}`))
return
}
_, _ = w.Write([]byte(`{"status":"failed","error":{"code":"index_not_found","message":"Index ` + "`x`" + ` not found."}}`))
}))
defer srv.Close()
err := NewMeili(srv.URL, "").call(context.Background(), http.MethodDelete, "/indexes/x", nil)
if !errors.Is(err, ErrMeiliNotFound) {
t.Fatalf("err = %v, want ErrMeiliNotFound", err)
}
}
+83
View File
@@ -0,0 +1,83 @@
package search
import (
"context"
"math"
"sort"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок.
const suggestPool = 3
// Affinity — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания,
// лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска.
type Affinity struct{ pool *pgxpool.Pool }
// NewAffinity создаёт источник предпочтений.
func NewAffinity(pool *pgxpool.Pool) *Affinity { return &Affinity{pool: pool} }
// Of возвращает вес для каждого из ids, о котором что-то известно.
func (a *Affinity) Of(ctx context.Context, user uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]float64, error) {
rows, err := a.pool.Query(ctx, `
WITH s AS (
SELECT track_id, sum(w) AS w FROM (
SELECT track_id, (play_count + 2 * complete_count - early_skip_count)::float8 AS w
FROM user_track_stats WHERE user_id = $1
UNION ALL
SELECT track_id, CASE reaction WHEN 'like' THEN 10 ELSE -10 END FROM track_reactions WHERE user_id = $1
) x GROUP BY track_id
)
SELECT track_id, w FROM s WHERE track_id = ANY($2)
UNION ALL
SELECT t.album_id, sum(s.w) FROM s JOIN tracks t ON t.id = s.track_id WHERE t.album_id = ANY($2) GROUP BY t.album_id
UNION ALL
SELECT ta.artist_id, sum(s.w) FROM s JOIN track_artists ta ON ta.track_id = s.track_id
WHERE ta.artist_id = ANY($2) GROUP BY ta.artist_id
UNION ALL
SELECT artist_id, CASE reaction WHEN 'like' THEN 20 ELSE -20 END FROM artist_reactions
WHERE user_id = $1 AND artist_id = ANY($2)`, user, ids)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[uuid.UUID]float64{}
for rows.Next() {
var id uuid.UUID
var w float64
if err := rows.Scan(&id, &w); err != nil {
return nil, err
}
out[id] += w
}
return out, rows.Err()
}
// personalize переставляет найденное: знакомое и любимое поднимается на несколько позиций, нелюбимое
// опускается; релевантность Meilisearch остаётся основой порядка. Возвращает не больше n.
func personalize(found []string, aff map[uuid.UUID]float64, n int) []string {
type item struct {
id string
key float64
}
items := make([]item, len(found))
for i, s := range found {
key := float64(i)
if id, err := uuid.Parse(s); err == nil {
if w := aff[id]; w > 0 {
key -= 2 * math.Log1p(w)
} else if w < 0 {
key += 3
}
}
items[i] = item{s, key}
}
sort.SliceStable(items, func(i, j int) bool { return items[i].key < items[j].key })
out := make([]string, 0, min(n, len(items)))
for _, it := range items[:min(n, len(items))] {
out = append(out, it.id)
}
return out
}
+29 -3
View File
@@ -28,12 +28,15 @@ type Handler struct {
meili *Meili meili *Meili
catalog *library.Store catalog *library.Store
indexer *Indexer indexer *Indexer
aff *Affinity
log *slog.Logger log *slog.Logger
} }
// NewHandler создаёт обработчик поиска. // NewHandler создаёт обработчик поиска.
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, dto library.DTO, log *slog.Logger) *Handler { func NewHandler(
return &Handler{meili: meili, catalog: catalog, indexer: indexer, dto: dto, log: log} meili *Meili, catalog *library.Store, indexer *Indexer, aff *Affinity, dto library.DTO, log *slog.Logger,
) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, aff: aff, dto: dto, log: log}
} }
type limits struct{ artists, albums, tracks, offset int } type limits struct{ artists, albums, tracks, offset int }
@@ -150,10 +153,16 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
// SearchSuggest реализует gen.StrictServerInterface. // SearchSuggest реализует gen.StrictServerInterface.
func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) { func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) {
f, corrected, err := h.run(ctx, req.Params.Q, limits{artists: 3, albums: 3, tracks: 5}) want := limits{artists: 3, albums: 3, tracks: 5}
l := want
if h.aff != nil {
l = limits{artists: want.artists * suggestPool, albums: want.albums * suggestPool, tracks: want.tracks * suggestPool}
}
f, corrected, err := h.run(ctx, req.Params.Q, l)
if err != nil { if err != nil {
return nil, err return nil, err
} }
f = h.personal(ctx, f, want)
out, err := h.hydrate(ctx, f, corrected) out, err := h.hydrate(ctx, f, corrected)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -161,6 +170,23 @@ func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestReques
return gen.SearchSuggest200JSONResponse(out), nil return gen.SearchSuggest200JSONResponse(out), nil
} }
// personal оставляет в подсказках по want.* лучших с учётом вкуса пользователя.
// Без пользователя или при сбое — просто первые по релевантности.
func (h *Handler) personal(ctx context.Context, f found, want limits) found {
var aff map[uuid.UUID]float64
if p, ok := auth.PrincipalFrom(ctx); ok && h.aff != nil {
all := ids(append(append(append([]string{}, f.artists...), f.albums...), f.tracks...))
var err error
if aff, err = h.aff.Of(ctx, p.UserID, all); err != nil {
h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
}
}
f.artists = personalize(f.artists, aff, want.artists)
f.albums = personalize(f.albums, aff, want.albums)
f.tracks = personalize(f.tracks, aff, want.tracks)
return f
}
// Search реализует gen.StrictServerInterface. // Search реализует gen.StrictServerInterface.
func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) { func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) {
typ := gen.All typ := gen.All
@@ -3,6 +3,8 @@ package search
import ( import (
"testing" "testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -20,3 +22,14 @@ func TestSwitchLayout(t *testing.T) {
assert.Equal(t, "кино", SwitchLayout("rbyj")) assert.Equal(t, "кино", SwitchLayout("rbyj"))
assert.Equal(t, "", SwitchLayout("123")) assert.Equal(t, "", SwitchLayout("123"))
} }
func TestPersonalizeLiftsFamiliarAndSinksDisliked(t *testing.T) {
liked, disliked := uuid.New(), uuid.New()
other := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
found := []string{other[0], disliked.String(), other[1], other[2], liked.String()}
aff := map[uuid.UUID]float64{liked: 30, disliked: -10}
got := personalize(found, aff, 3)
assert.Equal(t, []string{liked.String(), other[0], other[1]}, got)
assert.Equal(t, found[:2], personalize(found, nil, 2), "без вкуса — порядок релевантности")
}
@@ -0,0 +1,20 @@
//go:build !unix
package storageagent
import (
"errors"
"os"
)
// canWrite — запасная проверка для не-Unix (только разработка): бит записи у каталога.
func canWrite(dir string) error {
info, err := os.Stat(dir)
if err != nil {
return err
}
if info.Mode().Perm()&0o200 == 0 {
return errors.New("directory is not writable")
}
return nil
}
@@ -0,0 +1,18 @@
//go:build unix
package storageagent
import (
"errors"
"golang.org/x/sys/unix"
)
// canWrite — можно ли создавать файлы в каталоге (без пробной записи); read-only монтирование — ошибка.
func canWrite(dir string) error {
var st unix.Statfs_t
if err := unix.Statfs(dir, &st); err == nil && st.Flags&unix.ST_RDONLY != 0 {
return errors.New("filesystem is mounted read-only")
}
return unix.Access(dir, unix.W_OK|unix.X_OK)
}
+50 -46
View File
@@ -142,27 +142,19 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, res) writeJSON(w, http.StatusOK, res)
} }
// write копирует staging во временный файл рядом с целью, fsync, сверяет SHA-256 и создаёт итоговое имя // write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
// жёсткой ссылкой (не перезаписывает существующий файл, в отличие от rename), затем убирает временный. // конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека:
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
// каталоги удаляются — агент убирает только то, что создал сам.
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) { func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
target := filepath.Join(root, filepath.FromSlash(rel)) dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
dir := filepath.Dir(target) created, err := a.mkdirs(root, dir)
if err := a.mkdirs(root, dir); err != nil {
return WriteResult{}, err
}
tmp := filepath.Join(dir, "."+filepath.Base(target)+".pipu-tmp")
size, err := a.copyTemp(staging, tmp)
if err != nil {
_ = os.Remove(tmp)
return WriteResult{}, err
}
defer func() { _ = os.Remove(tmp) }()
got, err := fileSHA256(tmp)
if err != nil { if err != nil {
return WriteResult{}, err return WriteResult{}, err
} }
if !bytes.Equal(got, want) { fail := func(err error) (WriteResult, error) {
return WriteResult{}, fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want) removeEmpty(created)
return WriteResult{}, err
} }
ext := path.Ext(rel) ext := path.Ext(rel)
@@ -172,24 +164,39 @@ func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, erro
if n > 1 { if n > 1 {
name = fmt.Sprintf("%s (%d)%s", base, n, ext) name = fmt.Sprintf("%s (%d)%s", base, n, ext)
} }
err := os.Link(tmp, filepath.Join(root, filepath.FromSlash(name))) target := filepath.Join(root, filepath.FromSlash(name))
dst, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if os.IsExist(err) {
continue
}
if err != nil {
return fail(fmt.Errorf("create: %w", err))
}
size, err := copyInto(dst, staging)
if err == nil { if err == nil {
syncDir(dir) var got []byte
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil if got, err = fileSHA256(target); err == nil && !bytes.Equal(got, want) {
err = fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
}
} }
if !os.IsExist(err) { if err != nil {
return WriteResult{}, fmt.Errorf("link: %w", err) _ = os.Remove(target) // наш собственный недописанный / испорченный файл
return fail(err)
} }
a.chown(target)
syncDir(dir)
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
} }
return WriteResult{}, errors.New("too many files with the same name") return fail(errors.New("too many files with the same name"))
} }
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID). // mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID) и возвращает созданные.
func (a *agent) mkdirs(root, dir string) error { func (a *agent) mkdirs(root, dir string) ([]string, error) {
rel, err := filepath.Rel(root, dir) rel, err := filepath.Rel(root, dir)
if err != nil || strings.HasPrefix(rel, "..") { if err != nil || strings.HasPrefix(rel, "..") {
return errBadPath return nil, errBadPath
} }
var created []string
cur := root cur := root
for _, seg := range strings.Split(rel, string(filepath.Separator)) { for _, seg := range strings.Split(rel, string(filepath.Separator)) {
if seg == "." || seg == "" { if seg == "." || seg == "" {
@@ -200,24 +207,30 @@ func (a *agent) mkdirs(root, dir string) error {
if os.IsExist(err) { if os.IsExist(err) {
continue continue
} }
return fmt.Errorf("mkdir: %w", err) removeEmpty(created)
return nil, fmt.Errorf("mkdir: %w", err)
} }
created = append(created, cur)
a.chown(cur) a.chown(cur)
} }
return nil return created, nil
} }
func (a *agent) copyTemp(staging, tmp string) (int64, error) { // removeEmpty удаляет созданные каталоги от глубоких к верхним; os.Remove не трогает непустые.
func removeEmpty(dirs []string) {
for i := len(dirs) - 1; i >= 0; i-- {
_ = os.Remove(dirs[i])
}
}
// copyInto копирует staging в открытый файл и делает fsync; файл закрывается.
func copyInto(dst *os.File, staging string) (int64, error) {
src, err := os.Open(staging) src, err := os.Open(staging)
if err != nil { if err != nil {
_ = dst.Close()
return 0, fmt.Errorf("staging: %w", err) return 0, fmt.Errorf("staging: %w", err)
} }
defer func() { _ = src.Close() }() defer func() { _ = src.Close() }()
_ = os.Remove(tmp) // остаток прерванной попытки
dst, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
if err != nil {
return 0, fmt.Errorf("create temp: %w", err)
}
n, err := io.Copy(dst, src) n, err := io.Copy(dst, src)
if err == nil { if err == nil {
err = dst.Sync() err = dst.Sync()
@@ -228,7 +241,6 @@ func (a *agent) copyTemp(staging, tmp string) (int64, error) {
if err != nil { if err != nil {
return 0, fmt.Errorf("copy: %w", err) return 0, fmt.Errorf("copy: %w", err)
} }
a.chown(tmp)
return n, nil return n, nil
} }
@@ -240,7 +252,8 @@ func (a *agent) chown(p string) {
} }
} }
// testWrite создаёт и удаляет .pipu-write-test в корне источника на запись. // testWrite проверяет право записи в корень источника, ничего не создавая на диске (access(W_OK);
// на NFS ответ даёт сам сервер с учётом root_squash и прав экспорта).
func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) { func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
var req WriteTestRequest var req WriteTestRequest
if !a.decode(w, r, &req) { if !a.decode(w, r, &req) {
@@ -251,16 +264,7 @@ func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()}) writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()})
return return
} }
p := filepath.Join(root, ".pipu-write-test") if err := canWrite(root); err != nil {
f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err == nil {
_, err = f.WriteString("pipu")
if cerr := f.Close(); err == nil {
err = cerr
}
}
_ = os.Remove(p)
if err != nil {
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()}) writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
return return
} }
@@ -9,6 +9,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -74,6 +75,7 @@ func TestWriteFile(t *testing.T) {
assert.True(t, res.Renamed) assert.True(t, res.Renamed)
entries, _ := os.ReadDir(dir) entries, _ := os.ReadDir(dir)
assert.Len(t, entries, 2) assert.Len(t, entries, 2)
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
// Local-источник пишется в LIBRARY_HOST. // Local-источник пишется в LIBRARY_HOST.
local := "music" local := "music"
@@ -103,8 +105,21 @@ func TestWriteFileRejects(t *testing.T) {
other := sha256.Sum256([]byte("other")) other := sha256.Sum256([]byte("other"))
rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])}) rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])})
assert.Equal(t, http.StatusInternalServerError, rec.Code) assert.Equal(t, http.StatusInternalServerError, rec.Code)
entries, _ := os.ReadDir(filepath.Join(e.paths.LibraryRW, srcID, "A")) // Созданная под файл папка тоже убрана: на диске не остаётся следов неудачной записи.
assert.Empty(t, entries) assert.NoDirExists(t, filepath.Join(e.paths.LibraryRW, srcID, "A"))
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
}
// assertOnlyMusic — в дереве хранилища только аудиофайлы и каталоги: ни временных, ни служебных файлов.
func assertOnlyMusic(t *testing.T, root string) {
t.Helper()
require.NoError(t, filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
assert.True(t, audioExt[strings.ToLower(filepath.Ext(p))] && !strings.HasPrefix(d.Name(), "."), "unexpected file %s", p)
return nil
}))
} }
func TestWritableMount(t *testing.T) { func TestWritableMount(t *testing.T) {
@@ -120,7 +135,8 @@ func TestWritableMount(t *testing.T) {
var res WriteTestResult var res WriteTestResult
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res)) require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
assert.True(t, res.OK, res.Message) assert.True(t, res.OK, res.Message)
assert.NoFileExists(t, filepath.Join(rw, ".pipu-write-test")) entries, _ := os.ReadDir(rw)
assert.Empty(t, entries, "write check creates nothing")
// Без accepts_uploads rw-точка снимается. // Без accepts_uploads rw-точка снимается.
e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}}) e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}})
+141
View File
@@ -0,0 +1,141 @@
package wave
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"pipu/api/internal/api/gen"
)
// Онбординг вкуса (docs/recommendations.md#7): новый пользователь отмечает исполнителей и жанры — рекомендатель
// берёт их как начальный вкус, пока своей истории мало. Пропуск сохраняется пустым выбором.
const (
onboardingArtists = 40
onboardingGenres = 24
// История, после которой онбординг уже не предлагается: вкус и так виден по прослушиваниям.
onboardingMinHistory = 10
)
// GetOnboarding реализует gen.StrictServerInterface.
func (h *Handler) GetOnboarding(ctx context.Context, _ gen.GetOnboardingRequestObject) (gen.GetOnboardingResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
out := gen.GetOnboarding200JSONResponse{}
var done bool
var plays int
if err := h.pool.QueryRow(ctx, `
SELECT EXISTS (SELECT 1 FROM user_profiles_rec WHERE user_id = $1 AND onboarding IS NOT NULL),
(SELECT count(*) FROM user_track_stats WHERE user_id = $1)`, p.UserID).Scan(&done, &plays); err != nil {
return nil, fmt.Errorf("wave: onboarding state: %w", err)
}
rows, err := h.pool.Query(ctx, `
SELECT a.id, a.name, count(*) AS n,
(SELECT al.id FROM albums al WHERE al.album_artist_id = a.id
ORDER BY (SELECT count(*) FROM tracks t WHERE t.album_id = al.id) DESC, al.id LIMIT 1)
FROM artists a JOIN track_artists ta ON ta.artist_id = a.id AND ta.role = 'main'
GROUP BY a.id ORDER BY n DESC, a.name LIMIT $1`, onboardingArtists)
if err != nil {
return nil, fmt.Errorf("wave: onboarding artists: %w", err)
}
type artist struct {
id, album *uuid.UUID
name string
n int
}
var artists []artist
for rows.Next() {
var a artist
var id uuid.UUID
if err := rows.Scan(&id, &a.name, &a.n, &a.album); err != nil {
rows.Close()
return nil, fmt.Errorf("wave: onboarding artists: %w", err)
}
a.id = &id
artists = append(artists, a)
}
rows.Close()
dto := h.dto()
for _, a := range artists {
item := struct {
CoverColors []string `json:"cover_colors"`
CoverUrl *string `json:"cover_url"`
Id uuid.UUID `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{Id: *a.id, Name: a.name, TrackCount: a.n, CoverColors: []string{}}
if a.album != nil {
if al, err := h.catalog.Album(ctx, *a.album); err == nil {
sum := dto.AlbumSummary(al)
item.CoverUrl, item.CoverColors = sum.CoverUrl, sum.CoverColors
}
}
out.Artists = append(out.Artists, item)
}
grows, err := h.pool.Query(ctx, `
SELECT g.name, count(DISTINCT tg.track_id) AS n FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
GROUP BY g.name ORDER BY n DESC, g.name LIMIT $1`, onboardingGenres)
if err != nil {
return nil, fmt.Errorf("wave: onboarding genres: %w", err)
}
for grows.Next() {
var g struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
}
if err := grows.Scan(&g.Name, &g.TrackCount); err != nil {
grows.Close()
return nil, fmt.Errorf("wave: onboarding genres: %w", err)
}
out.Genres = append(out.Genres, g)
}
grows.Close()
if out.Artists == nil {
out.Artists = []struct {
CoverColors []string `json:"cover_colors"`
CoverUrl *string `json:"cover_url"`
Id uuid.UUID `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{}
}
if out.Genres == nil {
out.Genres = []struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
}{}
}
out.Required = !done && plays < onboardingMinHistory && len(out.Artists) > 0
return out, nil
}
// SaveOnboarding реализует gen.StrictServerInterface.
func (h *Handler) SaveOnboarding(ctx context.Context, req gen.SaveOnboardingRequestObject) (gen.SaveOnboardingResponseObject, error) {
p, err := principal(ctx)
if err != nil {
return nil, err
}
ids := req.Body.ArtistIds
if ids == nil {
ids = []uuid.UUID{}
}
genres := req.Body.Genres
if genres == nil {
genres = []string{}
}
raw, _ := json.Marshal(map[string]any{"artist_ids": ids, "genres": genres})
if _, err := h.pool.Exec(ctx, `
INSERT INTO user_profiles_rec (user_id, onboarding, updated_at) VALUES ($1, $2, now())
ON CONFLICT (user_id) DO UPDATE SET onboarding = EXCLUDED.onboarding, updated_at = now()`,
p.UserID, raw); err != nil {
return nil, fmt.Errorf("wave: save onboarding: %w", err)
}
return gen.SaveOnboarding204Response{}, nil
}
@@ -0,0 +1,7 @@
-- +goose Up
-- Онбординг «Моей волны» (docs/recommendations.md#7): выбранные исполнители и жанры — начальный вкус,
-- пока своей истории мало. NULL — онбординг ещё не пройден и не пропущен.
ALTER TABLE user_profiles_rec ADD COLUMN onboarding jsonb;
-- +goose Down
ALTER TABLE user_profiles_rec DROP COLUMN onboarding;
+17
View File
@@ -0,0 +1,17 @@
-- +goose Up
-- Ранжировщик волны v2 (docs/recommendations.md#6.3): признаки кандидата в момент показа — для обучения
-- LightGBM на исходах показов (wave_impressions.outcome); артефакт модели хранится в model_versions.
CREATE TABLE rec_impression_features (
wave_session_id uuid NOT NULL REFERENCES wave_sessions(id) ON DELETE CASCADE,
track_id uuid NOT NULL,
features real[] NOT NULL,
served_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (wave_session_id, track_id)
);
CREATE INDEX rec_impression_features_served_idx ON rec_impression_features (served_at);
ALTER TABLE model_versions ADD COLUMN artifact text;
-- +goose Down
ALTER TABLE model_versions DROP COLUMN artifact;
DROP TABLE rec_impression_features;
+2 -2
View File
@@ -15,8 +15,8 @@ COPY services/recommender/ ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
FROM python:3.12-slim FROM python:3.12-slim
# ffmpeg декодирует аудио для анализатора. # ffmpeg декодирует аудио для анализатора; libgomp1 нужен LightGBM (ранжировщик волны).
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libgomp1 && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu
COPY --from=build /app /app COPY --from=build /app /app
ARG VERSION=0.0.0-dev ARG VERSION=0.0.0-dev
ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1 ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1
@@ -20,6 +20,7 @@ log = logging.getLogger(__name__)
MAX_ATTEMPTS = 3 MAX_ATTEMPTS = 3
MAX_DURATION_S = 15 * 60 MAX_DURATION_S = 15 * 60
IDLE_SLEEP_S = 10.0 IDLE_SLEEP_S = 10.0
MISSING_SLEEP_S = 60.0 # файлы пропали целиком — ждём, пока смонтируют диск
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -168,6 +169,18 @@ class Worker:
) )
conn.commit() conn.commit()
def postpone(self, conn: psycopg.Connection[Any], track_ids: list[str]) -> None:
"""Файла нет на диске (не смонтирован?) — обратно в очередь, попытка не засчитывается."""
conn.execute(
"""
UPDATE analysis_jobs SET status = 'queued', attempts = greatest(attempts - 1, 0),
error = 'file not found — is the disk mounted?', updated_at = now()
WHERE track_id = ANY(%s::uuid[])
""",
(track_ids,),
)
conn.commit()
def requeue_stale(self, conn: psycopg.Connection[Any]) -> None: def requeue_stale(self, conn: psycopg.Connection[Any]) -> None:
"""Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь.""" """Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь."""
conn.execute( conn.execute(
@@ -189,6 +202,16 @@ class Worker:
self.requeue_stale(conn) self.requeue_stale(conn)
while True: while True:
jobs = self.claim(conn, self.workers * 2) jobs = self.claim(conn, self.workers * 2)
missing = [j for j in jobs if not Path(j.path).exists()]
if missing:
self.postpone(conn, [j.track_id for j in missing])
jobs = [j for j in jobs if j not in missing]
if not jobs:
log.warning(
"analysis: %d files not found, waiting — is the disk mounted?", len(missing)
)
time.sleep(MISSING_SLEEP_S)
continue
if not jobs: if not jobs:
time.sleep(IDLE_SLEEP_S) time.sleep(IDLE_SLEEP_S)
continue continue
+1 -1
View File
@@ -30,7 +30,7 @@ def _serve(settings: Settings) -> None:
wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path)) wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path))
if settings.train_hour >= 0: if settings.train_hour >= 0:
# После активации новой модели каталог перечитывается сразу — горячая подмена векторов. # После активации новой модели каталог перечитывается сразу — горячая подмена векторов.
NightlyTrainer(settings.database_url, settings.train_hour, on_done=wave.index.refresh).start() NightlyTrainer(settings.database_url, settings.train_hour, on_done=wave.reload).start()
app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave) app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave)
uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level) uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level)
@@ -0,0 +1,68 @@
"""Бандит по источникам кандидатов (docs/recommendations.md#6.6): Beta(α, β) на пару (пользователь, источник).
Награда — трек из источника доигран или лайкнут; скип и дизлайк — неудача. Наблюдения затухают ×0.99 в день,
чтобы бандит подстраивался под смену вкуса. Доля «нового» (exploration) следует за успехом источника `fresh`.
"""
from __future__ import annotations
import json
from collections import defaultdict
from typing import Any
import psycopg
DAILY_DECAY = 0.99
REWARD = {"complete": 1.0, "like": 1.0, "skip": 0.0, "skip_early": 0.0, "dislike": 0.0}
EXPLORATION_MIN, EXPLORATION_MAX = 0.05, 0.4
Params = dict[str, list[float]] # источник → [α, β]
def compute(rows: list[tuple[str, str, str, float]], now: float) -> dict[str, Params]:
"""rows: (user, source, outcome, served_at) → user → source → [α, β] с приором Beta(1, 1)."""
out: dict[str, Params] = defaultdict(dict)
for user, source, outcome, ts in rows:
r = REWARD.get(outcome)
if r is None:
continue
w = DAILY_DECAY ** max((now - ts) / 86400.0, 0.0)
ab = out[user].setdefault(source, [1.0, 1.0])
ab[0] += w * r
ab[1] += w * (1.0 - r)
return {u: {s: [round(a, 4), round(b, 4)] for s, (a, b) in p.items()} for u, p in out.items()}
def exploration(params: Params, default: float = 0.15) -> float:
"""Доля «нового»: успешнее `fresh` относительно остальных источников — больше исследования."""
fresh = params.get("fresh")
others = [a / (a + b) for s, (a, b) in params.items() if s != "fresh"]
if fresh is None or not others:
return default
rate = fresh[0] / (fresh[0] + fresh[1])
base = sum(others) / len(others)
value = default * (rate / base if base > 0 else 1.0)
return round(min(max(value, EXPLORATION_MIN), EXPLORATION_MAX), 3)
def update_bandits(conn: psycopg.Connection[Any], now: float) -> int:
"""Пересчитывает параметры по показам волны за 90 дней. Возвращает число пользователей."""
rows = conn.execute(
"""
SELECT ws.user_id::text, wi.candidate_source, wi.outcome, extract(epoch FROM wi.served_at)::float8
FROM wave_impressions wi JOIN wave_sessions ws ON ws.id = wi.wave_session_id
WHERE wi.outcome IS NOT NULL AND wi.served_at > now() - interval '90 days'
"""
).fetchall()
params = compute([(u, s, o, float(ts)) for u, s, o, ts in rows], now)
with conn.transaction(), conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO user_profiles_rec (user_id, bandit, exploration, updated_at)
VALUES (%s, %s, %s, now())
ON CONFLICT (user_id) DO UPDATE SET bandit = EXCLUDED.bandit, exploration = EXCLUDED.exploration,
updated_at = now()
""",
[(u, json.dumps(p), exploration(p)) for u, p in params.items()],
)
return len(params)
@@ -15,7 +15,9 @@ import numpy as np
import psycopg import psycopg
from pipu_rec.train.als import ALSModel, normalize, train_als from pipu_rec.train.als import ALSModel, normalize, train_als
from pipu_rec.train.bandit import update_bandits
from pipu_rec.train.metrics import evaluate from pipu_rec.train.metrics import evaluate
from pipu_rec.train.ranker import train_and_save
from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -157,9 +159,21 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
if not conn.execute("SELECT pg_try_advisory_lock(%s)", (ADVISORY_LOCK,)).fetchone()[0]: # type: ignore[index] if not conn.execute("SELECT pg_try_advisory_lock(%s)", (ADVISORY_LOCK,)).fetchone()[0]: # type: ignore[index]
return {"skipped": "another training is running"} return {"skipped": "another training is running"}
try: try:
# Бандит считается всегда — ему хватает и пары сессий волны, в отличие от ALS.
bandit_users = update_bandits(conn, now)
# Ранжировщик v2 учится на показах волны и от числа событий ALS не зависит.
try:
ranker = train_and_save(conn, now)
except Exception as e: # обучение ранжировщика не должно мешать ALS
log.exception("ranker training failed")
ranker = {"ranker": {"error": str(e)}}
interactions = load_interactions(conn) interactions = load_interactions(conn)
if len(interactions) < MIN_INTERACTIONS: if len(interactions) < MIN_INTERACTIONS:
return {"skipped": f"only {len(interactions)} interactions"} return {
"skipped": f"only {len(interactions)} interactions",
"bandit_users": bandit_users,
**ranker,
}
started = time.monotonic() started = time.monotonic()
offline = offline_metrics(interactions, now) offline = offline_metrics(interactions, now)
model = train_als(implicit_scores(interactions, now)) model = train_als(implicit_scores(interactions, now))
@@ -171,6 +185,7 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
"users": len(model.users), "users": len(model.users),
"items": len(model.items), "items": len(model.items),
"interactions": len(interactions), "interactions": len(interactions),
"bandit_users": bandit_users,
"train_s": round(time.monotonic() - started, 1), "train_s": round(time.monotonic() - started, 1),
} }
if not should_activate(offline, active_metrics(conn, "als")): if not should_activate(offline, active_metrics(conn, "als")):
@@ -180,10 +195,10 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
(version, json.dumps(metrics)), (version, json.dumps(metrics)),
) )
log.warning("als %s not activated: offline metrics regressed %s", version, offline) log.warning("als %s not activated: offline metrics regressed %s", version, offline)
return {"version": version, "activated": False, **metrics} return {"version": version, "activated": False, **metrics, **ranker}
save_als(conn, model, version, metrics) save_als(conn, model, version, metrics)
log.info("als %s activated: %s", version, metrics) log.info("als %s activated: %s", version, metrics)
return {"version": version, "activated": True, **metrics} return {"version": version, "activated": True, **metrics, **ranker}
finally: finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (ADVISORY_LOCK,)) conn.execute("SELECT pg_advisory_unlock(%s)", (ADVISORY_LOCK,))
@@ -214,7 +229,8 @@ class NightlyTrainer:
try: try:
result = train_all(self.dsn) result = train_all(self.dsn)
log.info("training finished: %s", result) log.info("training finished: %s", result)
if result.get("activated") and self.on_done: ranker_on = bool((result.get("ranker") or {}).get("activated"))
if (result.get("activated") or ranker_on) and self.on_done:
self.on_done() self.on_done()
except Exception: except Exception:
log.exception("training failed") log.exception("training failed")
@@ -0,0 +1,197 @@
"""Ранжировщик волны v2 — LightGBM lambdarank на показах всех пользователей (docs/recommendations.md#6.3).
Признаки кандидата пишутся в момент показа (rec_impression_features), метка — исход показа:
3 — лайк, 2 — доиграл, 0 — скип; ранний скип и дизлайк — 0 с весом ×2. Группа — сессия волны.
Новая модель включается, только если её NDCG@10 на отложенной неделе не хуже порядка по оценке v1 и не хуже
активной модели более чем на 2%. Пока размеченных показов меньше MIN_ROWS, работает v1.
"""
from __future__ import annotations
import datetime as dt
import json
import logging
import math
import threading
from collections import defaultdict
from typing import Any
import lightgbm as lgb
import numpy as np
import psycopg
from numpy.typing import NDArray
from pipu_rec.wave.engine import FEATURE_NAMES, FEATURES_VERSION
log = logging.getLogger(__name__)
MIN_ROWS = 20_000
HOLDOUT_DAYS = 7
MAX_REGRESSION = 0.02
LABEL = {"like": 3, "complete": 2, "skip": 0, "skip_early": 0, "dislike": 0}
WEIGHT = {"skip_early": 2.0, "dislike": 2.0}
V1_COLUMN = FEATURE_NAMES.index("v1_score")
Row = tuple[str, list[float], str, float] # сессия, признаки, исход, время показа
def load_rows(conn: psycopg.Connection[Any]) -> list[Row]:
rows = conn.execute(
"""
SELECT f.wave_session_id::text, f.features, wi.outcome, extract(epoch FROM f.served_at)::float8
FROM rec_impression_features f
JOIN wave_impressions wi ON wi.wave_session_id = f.wave_session_id AND wi.track_id = f.track_id
WHERE wi.outcome IS NOT NULL AND f.served_at > now() - interval '90 days'
ORDER BY f.wave_session_id, f.served_at
"""
).fetchall()
return [(s, list(x), o, float(ts)) for s, x, o, ts in rows if o in LABEL and len(x) == len(FEATURE_NAMES)]
def _groups(rows: list[Row]) -> list[list[Row]]:
by: dict[str, list[Row]] = defaultdict(list)
for r in rows:
by[r[0]].append(r)
return [g for g in by.values() if len(g) > 1]
def _ndcg(scores: NDArray[np.float64], labels: NDArray[np.float64], k: int = 10) -> float | None:
if labels.max() <= 0:
return None # в группе нечего ранжировать — в среднем не участвует
order = np.argsort(-scores)[:k]
dcg = sum((2 ** labels[i] - 1) / math.log2(r + 2) for r, i in enumerate(order))
ideal = sorted(labels, reverse=True)[:k]
idcg = sum((2**v - 1) / math.log2(r + 2) for r, v in enumerate(ideal))
return float(dcg / idcg) if idcg > 0 else None
def mean_ndcg(groups: list[list[Row]], score: Any) -> float:
vals = []
for g in groups:
x = np.array([r[1] for r in g], dtype=np.float64)
y = np.array([LABEL[r[2]] for r in g], dtype=np.float64)
v = _ndcg(np.asarray(score(x), dtype=np.float64), y)
if v is not None:
vals.append(v)
return round(float(np.mean(vals)), 4) if vals else 0.0
def train(rows: list[Row], now: float, min_rows: int = MIN_ROWS) -> tuple[lgb.Booster | None, dict[str, Any]]:
"""Обучает модель; None — данных мало (метрики объясняют почему)."""
metrics: dict[str, Any] = {"rows": len(rows), "features_version": FEATURES_VERSION}
if len(rows) < min_rows:
metrics["skipped"] = f"need {min_rows} labelled impressions"
return None, metrics
cutoff = now - HOLDOUT_DAYS * 86400
groups = _groups(rows)
train_g = [g for g in groups if max(r[3] for r in g) < cutoff]
valid_g = [g for g in groups if max(r[3] for r in g) >= cutoff]
if not train_g or not valid_g:
metrics["skipped"] = "no time split"
return None, metrics
def matrix(
gs: list[list[Row]],
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64], list[int]]:
x = np.array([r[1] for g in gs for r in g], dtype=np.float64)
y = np.array([LABEL[r[2]] for g in gs for r in g], dtype=np.int64)
w = np.array([WEIGHT.get(r[2], 1.0) for g in gs for r in g], dtype=np.float64)
return x, y, w, [len(g) for g in gs]
x, y, w, group = matrix(train_g)
params = {
"objective": "lambdarank",
"learning_rate": 0.05,
"num_leaves": 31,
"min_data_in_leaf": 20,
"verbose": -1,
}
booster = lgb.train(params, lgb.Dataset(x, y, weight=w, group=group), num_boost_round=200)
metrics.update(
{
"train_groups": len(train_g),
"valid_groups": len(valid_g),
"ndcg@10": mean_ndcg(valid_g, booster.predict),
"ndcg@10_v1": mean_ndcg(valid_g, lambda m: m[:, V1_COLUMN]),
}
)
return booster, metrics
def should_activate(metrics: dict[str, Any], current: dict[str, Any] | None) -> bool:
new = float(metrics.get("ndcg@10", 0.0))
if new < float(metrics.get("ndcg@10_v1", 0.0)):
return False
if current and current.get("ndcg@10"):
return new >= float(current["ndcg@10"]) * (1 - MAX_REGRESSION)
return True
def train_and_save(conn: psycopg.Connection[Any], now: float) -> dict[str, Any]:
"""Шаг ночного пайплайна: обучение, проверка, запись в model_versions."""
booster, metrics = train(load_rows(conn), now)
if booster is None:
return {"ranker": metrics}
version = "lgbm-" + dt.datetime.fromtimestamp(now, dt.UTC).strftime("%Y%m%d%H%M")
row = conn.execute(
"SELECT metrics FROM model_versions WHERE kind = 'ranker' AND is_active "
"ORDER BY created_at DESC LIMIT 1"
).fetchone()
active = should_activate(metrics, row[0] if row else None)
with conn.transaction():
if active:
conn.execute("UPDATE model_versions SET is_active = false WHERE kind = 'ranker' AND is_active")
conn.execute(
"INSERT INTO model_versions (id, kind, metrics, is_active, artifact) "
"VALUES (%s, 'ranker', %s, %s, %s) "
"ON CONFLICT (id) DO NOTHING",
(version, json.dumps(metrics), active, booster.model_to_string()),
)
log.info("ranker %s %s: %s", version, "activated" if active else "kept inactive", metrics)
return {"ranker": {"version": version, "activated": active, **metrics}}
class LoadedRanker:
"""Активная модель в памяти сервиса (Ranker для engine.build_batch)."""
def __init__(self, version: str, model: str) -> None:
self.version = version
self.booster = lgb.Booster(model_str=model)
def predict(self, x: NDArray[np.float64]) -> NDArray[np.float64]:
return np.asarray(self.booster.predict(x), dtype=np.float64)
class RankerHolder:
"""Загружает активный ранжировщик из model_versions; reload — после ночного обучения."""
def __init__(self, dsn: str) -> None:
self.dsn = dsn
self._ranker: LoadedRanker | None = None
self._loaded = False
self._lock = threading.Lock()
def get(self) -> LoadedRanker | None:
if not self._loaded:
self.reload()
return self._ranker
def reload(self) -> None:
with self._lock:
self._loaded = True
try:
with psycopg.connect(self.dsn) as conn:
row = conn.execute(
"SELECT id, artifact, metrics FROM model_versions "
"WHERE kind = 'ranker' AND is_active "
"AND artifact IS NOT NULL ORDER BY created_at DESC LIMIT 1"
).fetchone()
if row is None or (row[2] or {}).get("features_version") != FEATURES_VERSION:
self._ranker = None
return
self._ranker = LoadedRanker(row[0], row[1])
log.info("ranker %s loaded", row[0])
except Exception:
log.exception("ranker load failed — wave uses v1")
self._ranker = None
+161 -5
View File
@@ -2,7 +2,10 @@
from __future__ import annotations from __future__ import annotations
import math
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Protocol
import numpy as np import numpy as np
from numpy.typing import NDArray from numpy.typing import NDArray
@@ -93,6 +96,9 @@ class Profile:
rediscover: set[str] # любимые, не игравшие > 60 дней rediscover: set[str] # любимые, не игравшие > 60 дней
exploration: float = 0.15 exploration: float = 0.15
taste_cf: list[float] | None = None # фактор пользователя ALS taste_cf: list[float] | None = None # фактор пользователя ALS
bandit: dict[str, list[float]] = field(
default_factory=dict
) # источник → [α, β] (docs/recommendations.md#6.6)
recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf) recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf)
@@ -111,8 +117,60 @@ class Pick:
id: str id: str
source: str source: str
score: float score: float
features: list[float] | None = None # вектор признаков в момент показа (для обучения ранжировщика)
# Источники кандидатов (one-hot в признаках ранжировщика; порядок фиксирован).
SOURCES = ["seed", "fresh", "audio_sim", "cf", "item_cf", "artist", "rediscover", "popular", "explore"]
# Признаки ранжировщика v2 (docs/recommendations.md#6.3). Порядок — часть формата модели:
# менять только вместе с FEATURES_VERSION (старые модели тогда не загружаются).
FEATURE_NAMES = [
"sim_session",
"sim_taste",
"cf",
"artist_affinity",
"popularity",
"v1_score",
"context_fit",
"is_new",
"liked_recent",
"bandit_mean",
*[f"src_{s}" for s in SOURCES],
"energy",
"bpm",
"danceability",
"valence",
"hour_sin",
"hour_cos",
"position",
"skips_in_row",
"has_activity",
"has_mood",
]
FEATURES_VERSION = 1
class Ranker(Protocol):
"""Обученный ранжировщик: оценки кандидатов по матрице признаков (строки — кандидаты)."""
version: str
def predict(self, x: NDArray[np.float64]) -> NDArray[np.float64]: ...
@dataclass
class Context:
"""Контекст показа для признаков ранжировщика."""
hour: float = field(default_factory=lambda: time.localtime().tm_hour + time.localtime().tm_min / 60)
has_activity: bool = False
has_mood: bool = False
# Вес бонуса бандита к оценке (θ − 0.5 ∈ [−0.5, 0.5]).
BANDIT_WEIGHT = 0.2
# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e). # Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e).
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5} REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
ETA = 0.3 ETA = 0.3
@@ -176,10 +234,66 @@ def cf_scores(index: CatalogIndex, profile: Profile) -> NDArray[np.float64] | No
return np.where(index.has_cf, index.cf @ u, 0.0).astype(np.float64) return np.where(index.has_cf, index.cf @ u, 0.0).astype(np.float64)
def model_version(index: CatalogIndex, profile: Profile) -> str: def model_version(index: CatalogIndex, profile: Profile, ranker: Ranker | None = None) -> str:
if ranker is not None:
return ranker.version
return MODEL_VER + (CF_SUFFIX if cf_scores(index, profile) is not None else "") return MODEL_VER + (CF_SUFFIX if cf_scores(index, profile) is not None else "")
def candidate_features(
index: CatalogIndex,
profile: Profile,
session: Session,
cand: NDArray[np.intp],
sources: dict[int, str],
sim_session: NDArray[np.float64],
sim_taste: NDArray[np.float64],
cf_score: NDArray[np.float64] | None,
artist_aff: NDArray[np.float64],
v1: NDArray[np.float64],
context_fit: NDArray[np.float64],
ctx: Context,
) -> NDArray[np.float64]:
"""Матрица признаков (кандидаты × FEATURE_NAMES)."""
m = len(cand)
x = np.zeros((m, len(FEATURE_NAMES)), dtype=np.float64)
def feat(name: str) -> NDArray[np.float64]:
v = index.features.get(name)
if v is None:
return np.full(m, -1.0)
return np.nan_to_num(v[cand], nan=-1.0)
col = {n: k for k, n in enumerate(FEATURE_NAMES)}
x[:, col["sim_session"]] = sim_session[cand]
x[:, col["sim_taste"]] = sim_taste[cand]
x[:, col["cf"]] = cf_score[cand] if cf_score is not None else 0.0
x[:, col["artist_affinity"]] = artist_aff[cand]
x[:, col["popularity"]] = index.popularity[cand]
x[:, col["v1_score"]] = v1
x[:, col["context_fit"]] = context_fit
x[:, col["is_new"]] = [index.ids[i] not in profile.played for i in cand]
x[:, col["liked_recent"]] = [index.ids[i] in profile.liked_recent for i in cand]
for j, i in enumerate(cand):
src = sources[int(i)]
ab = profile.bandit.get(src)
x[j, col["bandit_mean"]] = ab[0] / (ab[0] + ab[1]) if ab else 0.5
if src in SOURCES:
x[j, col[f"src_{src}"]] = 1.0
x[:, col["energy"]] = feat("energy")
bpm = feat("bpm")
x[:, col["bpm"]] = np.where(bpm > 0, bpm / 200.0, -1.0)
x[:, col["danceability"]] = feat("danceability")
x[:, col["valence"]] = feat("valence")
x[:, col["hour_sin"]] = math.sin(2 * math.pi * ctx.hour / 24)
x[:, col["hour_cos"]] = math.cos(2 * math.pi * ctx.hour / 24)
x[:, col["position"]] = min(len(session.served) / 50.0, 1.0)
x[:, col["skips_in_row"]] = min(session.skips_in_row / 3.0, 1.0)
x[:, col["has_activity"]] = float(ctx.has_activity)
x[:, col["has_mood"]] = float(ctx.has_mood)
return x
def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]: def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]:
idx = np.nonzero(mask)[0] idx = np.nonzero(mask)[0]
if len(idx) <= k: if len(idx) <= k:
@@ -197,6 +311,8 @@ def build_batch(
exclude: set[str] | None = None, exclude: set[str] | None = None,
seed_ids: list[str] | None = None, seed_ids: list[str] | None = None,
rng: np.random.Generator | None = None, rng: np.random.Generator | None = None,
ranker: Ranker | None = None,
ctx: Context | None = None,
) -> list[Pick]: ) -> list[Pick]:
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия.""" """Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
rng = rng or np.random.default_rng() rng = rng or np.random.default_rng()
@@ -273,7 +389,7 @@ def build_batch(
add(rng.permutation(np.nonzero(allowed)[0])[:40].tolist(), "explore") add(rng.permutation(np.nonzero(allowed)[0])[:40].tolist(), "explore")
cand = np.array(sorted(sources)) cand = np.array(sorted(sources))
# 3. Оценка v1. # 3. Оценка v1 (она же — признак и запасной вариант для v2).
score = ( score = (
0.35 * sim_session[cand] 0.35 * sim_session[cand]
+ 0.25 * sim_taste[cand] + 0.25 * sim_taste[cand]
@@ -284,8 +400,17 @@ def build_batch(
) )
liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand]) liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand])
score = score - 0.2 * liked_recent score = score - 0.2 * liked_recent
# Бандит: семплирование Томпсона по источникам — удачные для пользователя источники получают бонус.
# Источник без наблюдений — нейтральные 0.5 (без шума, иначе холодный старт ранжировался бы случайно).
theta: dict[str, float] = {}
for src in set(sources.values()):
ab = profile.bandit.get(src)
theta[src] = float(rng.beta(ab[0], ab[1])) if ab else 0.5
score = score + BANDIT_WEIGHT * (np.array([theta[sources[int(i)]] for i in cand]) - 0.5)
v1 = score.copy()
# 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется. # 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
f = np.ones(len(cand))
if rules: if rules:
f = fit({k: v[cand] for k, v in index.features.items()}, rules) f = fit({k: v[cand] for k, v in index.features.items()}, rules)
# Нужен запас для разнообразия, но не больше 50 и не больше четверти пула. # Нужен запас для разнообразия, но не больше 50 и не больше четверти пула.
@@ -294,11 +419,33 @@ def build_batch(
keep = f >= threshold keep = f >= threshold
if keep.sum() >= enough or threshold == 0.0: if keep.sum() >= enough or threshold == 0.0:
break break
cand, score, f = cand[keep], score[keep], f[keep] cand, score, f, v1 = cand[keep], score[keep], f[keep], v1[keep]
score = score + 0.5 * f # контекст важнее вкуса, когда выбран явно score = score + 0.5 * f # контекст важнее вкуса, когда выбран явно
if len(cand) == 0: if len(cand) == 0:
return [] return []
# Признаки кандидатов: пишутся с показом (обучение v2) и подаются обученному ранжировщику.
ctx = ctx or Context()
f64 = np.float64
x = candidate_features(
index,
profile,
session,
cand,
sources,
np.asarray(sim_session, f64),
np.asarray(sim_taste, f64),
cf_score,
np.asarray(artist_aff, f64),
np.asarray(v1, f64),
np.asarray(f, f64),
ctx,
)
if ranker is not None:
# v2: порядок задаёт модель; в MMR нужна шкала 0..1 — берём ранг предсказания.
pred = ranker.predict(x)
score = np.argsort(np.argsort(pred)) / max(len(pred) - 1, 1)
# 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового». # 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового».
picks: list[Pick] = [] picks: list[Pick] = []
chosen: list[int] = [] chosen: list[int] = []
@@ -333,7 +480,14 @@ def build_batch(
i = int(cand[best_j]) i = int(cand[best_j])
chosen.append(i) chosen.append(i)
order.remove(best_j) order.remove(best_j)
picks.append(Pick(id=index.ids[i], source=sources[i], score=round(float(score[best_j]), 4))) picks.append(
Pick(
id=index.ids[i],
source=sources[i],
score=round(float(score[best_j]), 4),
features=x[best_j].tolist(),
)
)
# Исследование: «новых» (неслышанных) меньше доли exploration — заменяем ими слабейшие выборы. # Исследование: «новых» (неслышанных) меньше доли exploration — заменяем ими слабейшие выборы.
fresh_left = sorted( fresh_left = sorted(
@@ -348,5 +502,7 @@ def build_batch(
weakest = min(others, key=lambda k: picks[k].score) weakest = min(others, key=lambda k: picks[k].score)
if any(index.artist[index.pos[p.id]] == index.artist[i] for k, p in enumerate(picks) if k != weakest): if any(index.artist[index.pos[p.id]] == index.artist[i] for k, p in enumerate(picks) if k != weakest):
continue continue
picks[weakest] = Pick(id=index.ids[i], source="fresh", score=round(float(score[j]), 4)) picks[weakest] = Pick(
id=index.ids[i], source="fresh", score=round(float(score[j]), 4), features=x[j].tolist()
)
return picks return picks
+32 -2
View File
@@ -2,18 +2,22 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import Literal from typing import Literal
import numpy as np import numpy as np
import psycopg import psycopg
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pipu_rec.train.ranker import RankerHolder
from pipu_rec.wave.contexts import Contexts, Rule from pipu_rec.wave.contexts import Contexts, Rule
from pipu_rec.wave.engine import Pick, build_batch, model_version, taste_centroids, update_session from pipu_rec.wave.engine import Context, Pick, build_batch, model_version, taste_centroids, update_session
from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks
MAX_SERVED_MEMORY = 300 MAX_SERVED_MEMORY = 300
log = logging.getLogger(__name__)
Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"] Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"]
@@ -55,6 +59,12 @@ class WaveService:
self.index = IndexHolder(dsn) self.index = IndexHolder(dsn)
self.sessions = SessionStore(redis_url) self.sessions = SessionStore(redis_url)
self.contexts = contexts self.contexts = contexts
self.ranker = RankerHolder(dsn)
def reload(self) -> None:
"""После ночного обучения: перечитать каталог (векторы ALS) и активный ранжировщик."""
self.index.refresh()
self.ranker.reload()
def rules(self, activity: str | None, mood: str | None) -> list[Rule]: def rules(self, activity: str | None, mood: str | None) -> list[Rule]:
"""Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется.""" """Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется."""
@@ -74,6 +84,7 @@ class WaveService:
update_session( update_session(
index, session, [(f.track_id, f.outcome) for f in req.feedback], taste_centroids(index, profile) index, session, [(f.track_id, f.outcome) for f in req.feedback], taste_centroids(index, profile)
) )
ranker = self.ranker.get()
picks: list[Pick] = build_batch( picks: list[Pick] = build_batch(
index, index,
profile, profile,
@@ -83,12 +94,31 @@ class WaveService:
exclude=set(req.exclude_ids), exclude=set(req.exclude_ids),
seed_ids=seeds, seed_ids=seeds,
rng=np.random.default_rng(), rng=np.random.default_rng(),
ranker=ranker,
ctx=Context(has_activity=bool(req.activity), has_mood=bool(req.mood)),
) )
self.log_features(req.session_id, picks)
session.served = (session.served + [p.id for p in picks])[-MAX_SERVED_MEMORY:] session.served = (session.served + [p.id for p in picks])[-MAX_SERVED_MEMORY:]
artists = [index.artist[index.pos[p.id]] for p in picks] artists = [index.artist[index.pos[p.id]] for p in picks]
session.recent_artists = (session.recent_artists + artists)[-10:] session.recent_artists = (session.recent_artists + artists)[-10:]
self.sessions.put(req.session_id, session) self.sessions.put(req.session_id, session)
return BatchResponse( return BatchResponse(
tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks], tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks],
model_ver=model_version(index, profile), model_ver=model_version(index, profile, ranker),
) )
def log_features(self, session_id: str, picks: list[Pick]) -> None:
"""Признаки показанных треков — обучающие данные ранжировщика v2. Сбой записи не ломает волну."""
rows = [(session_id, p.id, p.features) for p in picks if p.features is not None]
if not rows:
return
try:
with psycopg.connect(self.dsn, autocommit=True) as conn, conn.cursor() as cur:
cur.executemany(
"INSERT INTO rec_impression_features (wave_session_id, track_id, features) "
"VALUES (%s, %s, %s) "
"ON CONFLICT DO NOTHING",
rows,
)
except psycopg.Error as e:
log.warning("impression features not saved: %s", e)
+36 -1
View File
@@ -156,10 +156,14 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
) )
} }
row = conn.execute( row = conn.execute(
"SELECT exploration, taste_cf::text FROM user_profiles_rec WHERE user_id = %s", (user_id,) "SELECT exploration, taste_cf::text, bandit, onboarding FROM user_profiles_rec WHERE user_id = %s",
(user_id,),
).fetchone() ).fetchone()
if row and row[3] and len(scores) < ONBOARDING_UNTIL:
apply_onboarding(conn, scores, row[3])
exploration = float(row[0]) if row else 0.15 exploration = float(row[0]) if row else 0.15
taste_cf = parse_vector(row[1]) if row else None taste_cf = parse_vector(row[1]) if row else None
bandit = {str(k): [float(x) for x in v] for k, v in (row[2] or {}).items()} if row else {}
if len(played) < 30: if len(played) < 30:
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
day = 86400 day = 86400
@@ -173,10 +177,41 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day}, rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
exploration=exploration, exploration=exploration,
taste_cf=taste_cf.tolist() if taste_cf is not None else None, taste_cf=taste_cf.tolist() if taste_cf is not None else None,
bandit=bandit,
recent_likes=recent_likes, recent_likes=recent_likes,
) )
# Пока своих оценок меньше, выбор онбординга задаёт начальный вкус (docs/recommendations.md#7).
ONBOARDING_UNTIL = 30
ONBOARDING_LIMIT = 300
def apply_onboarding(conn: psycopg.Connection[Any], scores: dict[str, float], choice: dict[str, Any]) -> None:
"""Псевдооценки: треки выбранных исполнителей +1, выбранных жанров +0.5 (до ONBOARDING_LIMIT треков).
Собственные оценки не перезаписываются; в «слышанное» эти треки не попадают."""
artists = [str(a) for a in choice.get("artist_ids") or []]
genres = [str(g).lower() for g in choice.get("genres") or []]
pseudo: dict[str, float] = {}
if artists:
for (tid,) in conn.execute(
"SELECT DISTINCT track_id::text FROM track_artists WHERE artist_id::text = ANY(%s) LIMIT %s",
(artists, ONBOARDING_LIMIT),
):
pseudo[tid] = pseudo.get(tid, 0.0) + 1.0
if genres:
for (tid,) in conn.execute(
"""
SELECT DISTINCT tg.track_id::text FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
WHERE lower(g.name) = ANY(%s) LIMIT %s
""",
(genres, ONBOARDING_LIMIT),
):
pseudo[tid] = pseudo.get(tid, 0.0) + 0.5
for tid, v in pseudo.items():
scores.setdefault(tid, v)
def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]: def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]:
"""Треки, от которых запущена волна: трек, альбом или исполнитель.""" """Треки, от которых запущена волна: трек, альбом или исполнитель."""
queries = { queries = {
+1
View File
@@ -11,6 +11,7 @@ dependencies = [
"redis>=6.2,<7", "redis>=6.2,<7",
"numpy>=2.2,<3", "numpy>=2.2,<3",
"pyyaml>=6.0,<7", "pyyaml>=6.0,<7",
"lightgbm>=4.5,<5",
] ]
[project.scripts] [project.scripts]
@@ -3,6 +3,7 @@
Локально: TEST_DATABASE_URL=postgresql://… uv run pytest tests/test_db_integration.py Локально: TEST_DATABASE_URL=postgresql://… uv run pytest tests/test_db_integration.py
""" """
import json
import os import os
import time import time
import uuid import uuid
@@ -139,3 +140,44 @@ def test_training_and_wave_queries(world: dict[str, Any]) -> None:
assert active is not None and active[0] == 1 assert active is not None and active[0] == 1
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0)) picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0))
assert len(picks) == 5 assert len(picks) == 5
def test_onboarding_seeds_taste(world: dict[str, Any]) -> None:
user = uid()
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute(
"INSERT INTO users (id, username, password_hash) VALUES (%s, %s, 'x')", (user, f"new-{user[:6]}")
)
artist = conn.execute(
"SELECT artist_id::text FROM track_artists WHERE track_id = %s", (world["tracks"][0],)
).fetchone()
assert artist is not None
conn.execute(
"INSERT INTO user_profiles_rec (user_id, onboarding) VALUES (%s, %s)",
(user, json.dumps({"artist_ids": [artist[0]], "genres": []})),
)
profile = load_profile(conn, user)
assert len(profile.scores) >= 10, "chosen artist's tracks become the initial taste"
assert not profile.played
def test_impression_features_roundtrip(world: dict[str, Any]) -> None:
from pipu_rec.train.ranker import load_rows, train_and_save
from pipu_rec.wave.engine import FEATURE_NAMES
session, user, track = uid(), world["users"][0], world["tracks"][0]
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute("INSERT INTO wave_sessions (id, user_id) VALUES (%s, %s)", (session, user))
conn.execute(
"INSERT INTO wave_impressions (wave_session_id, position, track_id, candidate_source, outcome) "
"VALUES (%s, 0, %s, 'audio_sim', 'complete')",
(session, track),
)
conn.execute(
"INSERT INTO rec_impression_features (wave_session_id, track_id, features) VALUES (%s, %s, %s)",
(session, track, [0.5] * len(FEATURE_NAMES)),
)
rows = load_rows(conn)
assert any(r[0] == session and r[2] == "complete" for r in rows)
result = train_and_save(conn, time.time())
assert "skipped" in result["ranker"], "too few impressions — v1 stays"
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more