Compare commits

..
20 Commits
Author SHA1 Message Date
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
mrleo1nidandClaude Opus 5.5 7cf741d82f ci(release): recover from a corrupted Gradle cache instead of failing
release / release (версия и заметки) (push) Successful in 10s
release / android (APK в релиз) (push) Successful in 9m5s
A hard reboot of the runner VM mid-build left broken transform metadata
in the persistent Gradle cache ("Could not read workspace metadata").
The APK step now detects it, drops the transforms cache and retries the
build once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 19:04:33 +03:00
80 changed files with 3006 additions and 381 deletions

No files matched your search

+26
View File
@@ -4,16 +4,19 @@
name: ci / db
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
- 'services/api/**'
- 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml'
pull_request:
paths:
- 'services/api/**'
- 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml'
concurrency:
@@ -64,3 +67,26 @@ jobs:
run: |
uv sync --frozen
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
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
+19 -3
View File
@@ -48,6 +48,8 @@ jobs:
android:
name: android (APK в релиз)
needs: release
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
timeout-minutes: 60
runs-on: ubuntu-latest
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
@@ -60,7 +62,7 @@ jobs:
image: docker.gitea.com/runner-images:ubuntu-latest
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
options: --cpus=3 --memory=6g --memory-swap=6g
options: --cpus=3 --memory=7g --memory-swap=7g
volumes:
- /opt/ci-cache/gradle:/root/.gradle
- /opt/ci-cache/android:/root/.android
@@ -106,6 +108,8 @@ jobs:
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
CMAKE_BUILD_PARALLEL_LEVEL: "2"
NINJAFLAGS: "-j2"
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
NODE_OPTIONS: "--max-old-space-size=1536"
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
@@ -115,16 +119,28 @@ jobs:
# библиотек, но плагин RN не объявляет эту зависимость, и с прогретым кэшем CMake успевал раньше codegen.
# Оба вызова идут через один демон Gradle (одинаковые jvmargs), поэтому второй не платит за старт JVM.
run: |
set -o pipefail
npx expo prebuild --platform android --clean --no-install
cd android
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
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 не нужен.
if grep -q '^newArchEnabled=true' gradle.properties; then
"${gradle[@]}" generateCodegenArtifactsFromSchema
fi
"${gradle[@]}" assembleRelease -x lintVitalRelease
# Постоянный кэш Gradle мог побиться (VM раннера перезагрузили посреди сборки): «Could not read workspace
# metadata» — удаляем кэш трансформаций и повторяем один раз, а не падаем до ручной чистки.
if ! "${gradle[@]}" assembleRelease -x lintVitalRelease 2>&1 | tee /tmp/gradle.log; then
if grep -qE "Could not read workspace metadata|Corrupted|is corrupt" /tmp/gradle.log; then
echo "::warning::кэш Gradle повреждён — чистим transforms и собираем заново"
./gradlew --stop || true
rm -rf ~/.gradle/caches/*/transforms ~/.gradle/caches/transforms-*
"${gradle[@]}" assembleRelease -x lintVitalRelease
else
exit 1
fi
fi
./gradlew --stop
- name: Диагностика сбоя (диск, память, OOM)
+108 -1
View File
@@ -1256,6 +1256,38 @@ paths:
description: Снято
default:
$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:
get:
operationId: getWaveOptions
@@ -1442,6 +1474,59 @@ components:
schema:
$ref: "#/components/schemas/Problem"
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:
type: object
required: [id, username]
@@ -3011,7 +3096,7 @@ components:
nullable: true
WaveMetrics:
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 дней
properties:
served:
@@ -3028,6 +3113,28 @@ components:
description: Лайков на 100 треков
sessions:
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:
type: object
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
+1 -1
View File
@@ -117,7 +117,7 @@ function Chips({
height: 38,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: on ? colors.accent : colors.surface2,
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
borderWidth: 1,
borderColor: on ? colors.accent : colors.line,
}}
+16
View File
@@ -90,6 +90,7 @@ class Controller {
private lastSave = 0;
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
private waveLoading = false;
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
private setupDone: Promise<void> | null = null;
private listeners = new Set<() => void>();
private state: PlayerState = {
@@ -189,6 +190,14 @@ class Controller {
if (!a) return;
this.attempt = null;
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);
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events?.flush();
@@ -316,6 +325,13 @@ class Controller {
);
await this.replaceUpcoming(res.tracks);
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() {
+2 -2
View File
@@ -107,7 +107,7 @@ export function Button({
}) {
const s = useStyles();
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];
return (
<Pressable
@@ -282,7 +282,7 @@ export function Segmented<T extends string>({
onPress={() => onChange(o.value)}
accessibilityRole="tab"
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>
</Pressable>
+1 -1
View File
@@ -250,7 +250,7 @@ describe('player', () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
+2 -2
View File
@@ -132,7 +132,7 @@ export function TrackList({
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
@@ -145,7 +145,7 @@ export function TrackList({
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
role="row"
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
+23 -2
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -24,6 +24,7 @@ import {
import { cx, FormError, Icon } from '../shared/ui/ui';
import { WaveButton } from '../widgets/wave/WaveButton';
import s from './home.module.css';
import { Onboarding } from './Onboarding';
/** Иконки Material Symbols по именам из /wave/options. */
const ICONS: Record<string, string> = {
@@ -44,8 +45,28 @@ const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
type Tr = (key: string) => string;
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
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 tr = t as unknown as Tr;
const session = useSession();
+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>
);
}
+33 -2
View File
@@ -1,5 +1,6 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
@@ -99,6 +100,33 @@ export function LibraryPage() {
</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>
)}
{d && d.sources.length > 0 && (
@@ -133,10 +161,13 @@ const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
const value = total > 0 ? Math.round((done / total) * 100) : 0;
const id = useId();
return (
<div className={l.scan}>
<div className={l.scanText}>{label}</div>
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<div className={l.scanText} id={id}>
{label}
</div>
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${value}%` }} />
</div>
</div>
+4 -2
View File
@@ -1,4 +1,5 @@
import type { components } from '@pipu/api-client';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
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 }) {
const { t, i18n } = useTranslation('admin');
const id = useId();
if (scan?.running) {
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
return (
<div className={s.scan}>
<div className={s.scanText}>
<div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
</div>
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} />
</div>
</div>
+1 -1
View File
@@ -86,7 +86,7 @@
.chipOn,
.chipOn:hover {
background: var(--accent);
background: var(--accent-fill);
border-color: var(--accent);
color: #fff;
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 { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
@@ -47,18 +48,19 @@ export function useUploadQuota() {
export function QuotaMeter() {
const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null;
const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language);
return (
<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) })}
{q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</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}%` }} />
</div>
</div>
+22 -4
View File
@@ -186,7 +186,10 @@ export class Player {
void this.load(true);
}
/** Сменить занятие / настроение на лету: текущий трек доигрывает, дальше — новая пачка. */
/**
* Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7).
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
*/
async setWaveContext(activity: string | null, mood: string | null) {
const wave = this.state.wave;
if (!wave) return this.startWave(activity, mood);
@@ -197,10 +200,17 @@ export class Player {
body: { activity, mood },
}),
);
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
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.syncQueue();
if (fresh.length && this.queue.next()) {
this.finishAttempt('skip', true);
this.syncQueue();
void this.load(true);
} else {
this.syncQueue();
}
}
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;
if (!a) return;
this.attempt = null;
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';
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
void this.events.flush();
+3 -3
View File
@@ -53,13 +53,13 @@
}
.primary {
background: var(--accent);
background: var(--accent-fill);
color: #fff;
box-shadow: var(--glow);
}
.primary:hover:not(:disabled) {
background: var(--accent2);
background: var(--accent-fill2);
}
.secondary {
@@ -186,7 +186,7 @@
}
.segmented button[aria-pressed='true'] {
background: var(--accent);
background: var(--accent-fill);
color: #fff;
}
+8
View File
@@ -53,6 +53,14 @@ button {
}
}
/* Выезжающие справа панели (очередь, docs/design.md#4). */
@keyframes pipuSlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+3 -1
View File
@@ -27,8 +27,10 @@
padding-bottom: 132px;
}
/* Над контентом: выпадающие подсказки поиска и меню профиля перекрывают страницу, а не уходят под неё. */
.top {
grid-area: top;
z-index: 30;
display: flex;
align-items: center;
gap: 24px;
@@ -155,7 +157,7 @@
height: 20px;
padding: 0 6px;
border-radius: var(--radius-pill);
background: var(--accent);
background: var(--accent-fill);
color: #fff;
font-size: 11px;
font-weight: 700;
+9 -1
View File
@@ -246,7 +246,15 @@ a.source:hover {
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
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 {
+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 { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
@@ -35,6 +35,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const player = usePlayer();
const st = usePlayerState();
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;
if (!track) {
@@ -167,10 +173,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
<button
ref={queueButton}
type="button"
className={cx(s.icon, queueOpen && s.accent)}
aria-label={t('player.queue')}
aria-expanded={queueOpen}
aria-controls={queueOpen ? queueId : undefined}
onClick={() => setQueueOpen((v) => !v)}
>
<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}
/>
</div>
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
</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 player = usePlayer();
const st = usePlayerState();
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-потомков относительными к себе.
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}>
<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} />
</button>
</div>
@@ -221,6 +239,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
key={`${tr.id}-${k}`}
type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
aria-current={k === 0 || undefined}
onClick={() => player.jump(st.index + k)}
>
<Cover album={tr.album} size={40} />
@@ -232,6 +251,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
</button>
))}
</div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>,
document.body,
);
+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.
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
# (docs/library-and-storage.md#запись-в-хранилище).
# rslave: диск, смонтированный на хосте уже после старта контейнеров, становится виден внутри без перезапуска.
x-library-host-ro: &library-host-ro
type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host
read_only: true
bind: { propagation: rslave }
x-restart: &restart
restart: unless-stopped
@@ -96,6 +98,7 @@ services:
- type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host-rw
bind: { propagation: rslave }
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender:
@@ -142,6 +145,26 @@ services:
interval: 5s
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:
<<: *restart
image: redis:7.4-alpine
+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 (или вручную: «Администрирование → Библиотека»)."
+15
View File
@@ -26,6 +26,15 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi
done
# Папка с музыкой пуста — почти всегда это несмонтированный диск: 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:-}"
git fetch --tags --prune --quiet origin
@@ -53,6 +62,12 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
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"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF"
+1
View File
@@ -23,6 +23,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [testing.md](testing.md) | Стратегия тестирования |
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, бэкапы, диагностика |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы |
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
+134
View File
@@ -0,0 +1,134 @@
# Руководство администратора
Повседневная работа с сервером после установки ([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.
## 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
```
Сборка 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), дополнительно ограничьте её там:
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
+15 -1
View File
@@ -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`, повторный транскод / анализ).
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
- Сама музыка — на NFS, вне зоны ответственности приложения.
+2 -1
View File
@@ -48,8 +48,9 @@
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
| `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` | Разделители / рамки |
| `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-fill` / `accent-fill2` | `#1A66E6` / `#1F6FF2` | `#1A63DB` / `#165ED6` | Заливка под белым текстом (кнопки, выбранные сегменты) и её hover — контраст ≥ 4.5:1 |
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
+13 -2
View File
@@ -314,12 +314,21 @@ cd /opt/pipu-music/deploy && ./update.sh
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
автоматически при старте API.
Бэкап базы (всё остальное восстанавливается само — см. [deployment.md](deployment.md#бэкапы)):
Бэкапы базы делаются сами: каждую ночь в `/srv/pipu/backups`, раз в неделю копия проверяется восстановлением,
перед каждым `update.sh` — внеочередная копия. Всё остальное восстанавливается само — см.
[deployment.md](deployment.md#бэкапы). Копия сейчас и восстановление:
```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
@@ -339,6 +348,8 @@ cd /opt/pipu-music/deploy && docker compose down
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
| Сервисы не стартуют: `… 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` |
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`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` |
+10 -6
View File
@@ -35,8 +35,7 @@
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет запись: создаёт и удаляет
`.pipu-write-test` в корне.
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет право записи — без создания файлов.
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
и проверкой обратимости через регэкспы источника (подробно — [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/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог
внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). Итоговое имя создаётся жёсткой ссылкой
на временный файл (`link` не перезаписывает, в отличие от `rename`); занято — ` (2)`, ` (3)`…
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). На музыкальный диск не пишется ничего,
кроме самих треков: файл создаётся сразу под итоговым именем (`O_EXCL` — существующий не перезаписывается;
занято — ` (2)`, ` (3)`…), `fsync`, сверка SHA-256; при ошибке агент удаляет свой недописанный файл и созданные
для него пустые каталоги. Проверка записи — `access(W_OK)` и флаг read-only файловой системы, без пробного файла.
Остальные сервисы видят музыку только на чтение (`read_only` в compose).
Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять).
- Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через
`regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`,
@@ -159,7 +161,9 @@ Meilisearch, индексы:
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
отменяет предыдущий запрос (`AbortController`).
- Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше
в выдаче `suggest` (re-rank первых 20 на стороне `api`).
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
(`internal/search/personal.go`). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10,
реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на
2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка.
- Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача
`reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).
+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%.
Пока размеченных показов меньше ~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 Занятия и настроения
Каждый контекст — профиль целевых значений аудиопризнаков (`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`. Параметры затухают
(×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 Реакция в реальном времени
- Клиент держит в очереди 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+ жанра или исполнителя) → начальные центроиды.
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` —
не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор —
пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1,
жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны.
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
## 8. Обучение на всех пользователях
+5
View File
@@ -49,10 +49,15 @@
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
- Персонализация suggest.
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
- **Состояние:** всё реализовано (v0.8, v1.0). Ранжировщик включается сам после ~20 тыс. размеченных показов;
критерий проверяется по метрикам версий в админке («Библиотека → Моя волна») и NDCG в `model_versions`.
## M7 — Стабилизация (v1.0)
- Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора,
доступность, полировка анимаций, багфиксы.
- **Состояние:** v1.0 — бэкапы с еженедельной проверкой восстановления (и цикл копия → проверка → восстановление
в CI), [руководство администратора](admin-guide.md), аудит доступности WCAG 2.1 AA, k6-smoke
(`tests/load/smoke.js`, запускается вручную на сервере перед релизом).
## Дальше (идеи)
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
+25 -1
View File
@@ -49,4 +49,28 @@
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
- Тест на каждый исправленный баг.
- 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}`.
Агент:
- создаёт каталоги;
- копирует во временный файл `.<name>.pipu-tmp` в целевой папке, `fsync`;
- сверяет SHA-256 копии со staging;
- атомарно переименовывает в итоговое имя.
- создаёт файл сразу под итоговым именем (`O_EXCL`, существующий не перезаписывается), копирует, `fsync`;
- сверяет SHA-256 копии со staging; при ошибке удаляет свой файл и созданные пустые каталоги —
на музыкальный диск не попадает ничего, кроме треков.
3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ.
В `media_files` сохраняется `upload_id` — история происхождения.
4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`.
+12
View File
@@ -101,3 +101,15 @@ apps/web/src/
## Доступность
- Вся навигация с клавиатуры, видимый фокус, 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 выключены, волна замедлена.
+110
View File
@@ -1066,6 +1066,43 @@ export interface paths {
patch?: 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": {
parameters: {
query?: never;
@@ -1181,6 +1218,26 @@ export interface paths {
export type webhooks = Record<string, never>;
export interface components {
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: {
/** Format: uuid */
id: string;
@@ -1964,6 +2021,15 @@ export interface components {
/** @description Лайков на 100 треков */
like_rate: number | null;
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: {
/** @example 0.6.1 */
@@ -3599,6 +3665,50 @@ export interface operations {
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: {
parameters: {
query?: never;
@@ -22,9 +22,11 @@
--line2: rgba(130,160,255,.18);
--text: #EDF1FF;
--text2: #A0ABC6;
--text3: #737F9C;
--text3: #7F8BA8;
--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);
@@ -56,9 +58,11 @@
--line2: rgba(20,35,80,.17);
--text: #0A0F1E;
--text2: #4B5675;
--text3: #677290;
--text3: #5C6785;
--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);
@@ -90,9 +94,11 @@
--line2: rgba(20,35,80,.17);
--text: #0A0F1E;
--text2: #4B5675;
--text3: #677290;
--text3: #5C6785;
--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);
@@ -123,9 +129,11 @@
--line2: rgba(130,160,255,.18);
--text: #EDF1FF;
--text2: #A0ABC6;
--text3: #737F9C;
--text3: #7F8BA8;
--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);
@@ -13,9 +13,11 @@ export const tokens = {
"line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF",
"text2": "#A0ABC6",
"text3": "#737F9C",
"text3": "#7F8BA8",
"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)",
@@ -42,9 +44,11 @@ export const tokens = {
"line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E",
"text2": "#4B5675",
"text3": "#677290",
"text3": "#5C6785",
"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)",
+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": {
"dark": {
"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)",
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#737F9C",
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-soft": "rgba(47,128,255,.15)",
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#7F8BA8",
"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)",
"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)",
@@ -14,8 +14,8 @@
"light": {
"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)",
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#677290",
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-soft": "rgba(31,111,242,.10)",
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#5C6785",
"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)",
"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)",
+3 -1
View File
@@ -158,7 +158,9 @@
"wave_sessions": "Sessions",
"wave_listen": "Played ≥ 50%",
"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"
},
"write_template": {
"title": "Uploads and path template",
+14 -1
View File
@@ -81,7 +81,8 @@
"source_wave": "My Wave",
"source_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": {
"title": "Settings",
@@ -278,5 +279,17 @@
"cache": "Recent tracks cache",
"cache_clear": "Clear ({{size}} MB)",
"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"
}
}
+3 -1
View File
@@ -158,7 +158,9 @@
"wave_sessions": "Сессий",
"wave_listen": "Дослушано ≥ 50%",
"wave_early_skip": "Ранние скипы",
"wave_likes": "Лайков на 100"
"wave_likes": "Лайков на 100",
"wave_model": "Версия модели",
"wave_by_model": "Сравнение версий модели волны"
},
"write_template": {
"title": "Приём загрузок и шаблон пути",
+14 -1
View File
@@ -81,7 +81,8 @@
"source_wave": "Моя волна",
"source_queue": "Очередь",
"queue": "Очередь",
"source_wave_seed": "Волна по исполнителю · {{label}}"
"source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
},
"settings": {
"title": "Настройки",
@@ -278,5 +279,17 @@
"cache": "Кэш недавних треков",
"cache_clear": "Очистить ({{size}} МБ)",
"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)
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. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки.
var wave struct {
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),
AuthService: authSvc,
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,
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
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 (
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/go-chi/chi/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/stretchr/testify v1.10.0
golang.org/x/crypto v0.38.0
golang.org/x/sys v0.33.0
golang.org/x/text v0.25.0
)
require (
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
@@ -43,8 +45,6 @@ require (
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+462 -236
View File
@@ -488,6 +488,30 @@ type Me struct {
Username string `json:"username"`
}
// Onboarding defines model for Onboarding.
type Onboarding struct {
Artists []struct {
CoverColors []string `json:"cover_colors"`
// CoverUrl Обложка самого большого альбома исполнителя
CoverUrl *string `json:"cover_url"`
Id openapi_types.UUID `json:"id"`
Name string `json:"name"`
TrackCount int `json:"track_count"`
} `json:"artists"`
Genres []struct {
Name string `json:"name"`
TrackCount int `json:"track_count"`
} `json:"genres"`
Required bool `json:"required"`
}
// OnboardingChoice defines model for OnboardingChoice.
type OnboardingChoice struct {
ArtistIds []openapi_types.UUID `json:"artist_ids"`
Genres []string `json:"genres"`
}
// PathPattern defines model for PathPattern.
type PathPattern struct {
IsEnabled bool `json:"is_enabled"`
@@ -1128,7 +1152,9 @@ type WaveFeedbackOutcome string
// WaveMetrics Метрики волны за 7 дней
type WaveMetrics struct {
EarlySkipRate *float32 `json:"early_skip_rate"`
// ByModel Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
ByModel []WaveModelMetrics `json:"by_model"`
EarlySkipRate *float32 `json:"early_skip_rate"`
// LikeRate Лайков на 100 треков
LikeRate *float32 `json:"like_rate"`
@@ -1137,6 +1163,15 @@ type WaveMetrics struct {
Sessions int `json:"sessions"`
}
// WaveModelMetrics defines model for WaveModelMetrics.
type WaveModelMetrics struct {
EarlySkipRate *float32 `json:"early_skip_rate"`
LikeRate *float32 `json:"like_rate"`
ListenThroughRate *float32 `json:"listen_through_rate"`
ModelVer string `json:"model_ver"`
Served int `json:"served"`
}
// WaveNext defines model for WaveNext.
type WaveNext struct {
Feedback *[]WaveFeedback `json:"feedback,omitempty"`
@@ -1394,6 +1429,9 @@ type UpdateMeJSONRequestBody = UpdateMeRequest
// SetBootstrapCredentialsJSONRequestBody defines body for SetBootstrapCredentials for application/json ContentType.
type SetBootstrapCredentialsJSONRequestBody = BootstrapCredentialsRequest
// SaveOnboardingJSONRequestBody defines body for SaveOnboarding for application/json ContentType.
type SaveOnboardingJSONRequestBody = OnboardingChoice
// ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType.
type ChangePasswordJSONRequestBody = ChangePasswordRequest
@@ -1564,6 +1602,9 @@ type ServerInterface interface {
// Лайки, новые сверху
// (GET /api/v1/me/likes)
GetLikes(w http.ResponseWriter, r *http.Request, params GetLikesParams)
// Сохранить выбор онбординга (или пропуск — пустые списки)
// (PUT /api/v1/me/onboarding)
SaveOnboarding(w http.ResponseWriter, r *http.Request)
// Сменить пароль
// (POST /api/v1/me/password)
ChangePassword(w http.ResponseWriter, r *http.Request)
@@ -1585,6 +1626,9 @@ type ServerInterface interface {
// Завершить сессию на устройстве
// (DELETE /api/v1/me/sessions/{id})
RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// (GET /api/v1/onboarding)
GetOnboarding(w http.ResponseWriter, r *http.Request)
// Страница поиска
// (GET /api/v1/search)
Search(w http.ResponseWriter, r *http.Request, params SearchParams)
@@ -1927,6 +1971,12 @@ func (_ Unimplemented) GetLikes(w http.ResponseWriter, r *http.Request, params G
w.WriteHeader(http.StatusNotImplemented)
}
// Сохранить выбор онбординга (или пропуск — пустые списки)
// (PUT /api/v1/me/onboarding)
func (_ Unimplemented) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Сменить пароль
// (POST /api/v1/me/password)
func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) {
@@ -1969,6 +2019,12 @@ func (_ Unimplemented) RevokeMySession(w http.ResponseWriter, r *http.Request, i
w.WriteHeader(http.StatusNotImplemented)
}
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// (GET /api/v1/onboarding)
func (_ Unimplemented) GetOnboarding(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotImplemented)
}
// Страница поиска
// (GET /api/v1/search)
func (_ Unimplemented) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
@@ -3519,6 +3575,26 @@ func (siw *ServerInterfaceWrapper) GetLikes(w http.ResponseWriter, r *http.Reque
handler.ServeHTTP(w, r)
}
// SaveOnboarding operation middleware
func (siw *ServerInterfaceWrapper) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.SaveOnboarding(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// ChangePassword operation middleware
func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) {
@@ -3692,6 +3768,26 @@ func (siw *ServerInterfaceWrapper) RevokeMySession(w http.ResponseWriter, r *htt
handler.ServeHTTP(w, r)
}
// GetOnboarding operation middleware
func (siw *ServerInterfaceWrapper) GetOnboarding(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetOnboarding(w, r)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// Search operation middleware
func (siw *ServerInterfaceWrapper) Search(w http.ResponseWriter, r *http.Request) {
@@ -4497,6 +4593,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/me/likes", wrapper.GetLikes)
})
r.Group(func(r chi.Router) {
r.Put(options.BaseURL+"/api/v1/me/onboarding", wrapper.SaveOnboarding)
})
r.Group(func(r chi.Router) {
r.Post(options.BaseURL+"/api/v1/me/password", wrapper.ChangePassword)
})
@@ -4518,6 +4617,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
r.Delete(options.BaseURL+"/api/v1/me/sessions/{id}", wrapper.RevokeMySession)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/onboarding", wrapper.GetOnboarding)
})
r.Group(func(r chi.Router) {
r.Get(options.BaseURL+"/api/v1/search", wrapper.Search)
})
@@ -5956,6 +6058,34 @@ func (response GetLikesdefaultApplicationProblemPlusJSONResponse) VisitGetLikesR
return json.NewEncoder(w).Encode(response.Body)
}
type SaveOnboardingRequestObject struct {
Body *SaveOnboardingJSONRequestBody
}
type SaveOnboardingResponseObject interface {
VisitSaveOnboardingResponse(w http.ResponseWriter) error
}
type SaveOnboarding204Response struct {
}
func (response SaveOnboarding204Response) VisitSaveOnboardingResponse(w http.ResponseWriter) error {
w.WriteHeader(204)
return nil
}
type SaveOnboardingdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response SaveOnboardingdefaultApplicationProblemPlusJSONResponse) VisitSaveOnboardingResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type ChangePasswordRequestObject struct {
Body *ChangePasswordJSONRequestBody
}
@@ -6155,6 +6285,34 @@ func (response RevokeMySessiondefaultApplicationProblemPlusJSONResponse) VisitRe
return json.NewEncoder(w).Encode(response.Body)
}
type GetOnboardingRequestObject struct {
}
type GetOnboardingResponseObject interface {
VisitGetOnboardingResponse(w http.ResponseWriter) error
}
type GetOnboarding200JSONResponse Onboarding
func (response GetOnboarding200JSONResponse) VisitGetOnboardingResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type GetOnboardingdefaultApplicationProblemPlusJSONResponse struct {
Body Problem
StatusCode int
}
func (response GetOnboardingdefaultApplicationProblemPlusJSONResponse) VisitGetOnboardingResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(response.StatusCode)
return json.NewEncoder(w).Encode(response.Body)
}
type SearchRequestObject struct {
Params SearchParams
}
@@ -6812,6 +6970,9 @@ type StrictServerInterface interface {
// Лайки, новые сверху
// (GET /api/v1/me/likes)
GetLikes(ctx context.Context, request GetLikesRequestObject) (GetLikesResponseObject, error)
// Сохранить выбор онбординга (или пропуск — пустые списки)
// (PUT /api/v1/me/onboarding)
SaveOnboarding(ctx context.Context, request SaveOnboardingRequestObject) (SaveOnboardingResponseObject, error)
// Сменить пароль
// (POST /api/v1/me/password)
ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error)
@@ -6833,6 +6994,9 @@ type StrictServerInterface interface {
// Завершить сессию на устройстве
// (DELETE /api/v1/me/sessions/{id})
RevokeMySession(ctx context.Context, request RevokeMySessionRequestObject) (RevokeMySessionResponseObject, error)
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
// (GET /api/v1/onboarding)
GetOnboarding(ctx context.Context, request GetOnboardingRequestObject) (GetOnboardingResponseObject, error)
// Страница поиска
// (GET /api/v1/search)
Search(ctx context.Context, request SearchRequestObject) (SearchResponseObject, error)
@@ -8220,6 +8384,37 @@ func (sh *strictHandler) GetLikes(w http.ResponseWriter, r *http.Request, params
}
}
// SaveOnboarding operation middleware
func (sh *strictHandler) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
var request SaveOnboardingRequestObject
var body SaveOnboardingJSONRequestBody
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
return
}
request.Body = &body
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.SaveOnboarding(ctx, request.(SaveOnboardingRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "SaveOnboarding")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(SaveOnboardingResponseObject); ok {
if err := validResponse.VisitSaveOnboardingResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// ChangePassword operation middleware
func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
var request ChangePasswordRequestObject
@@ -8415,6 +8610,30 @@ func (sh *strictHandler) RevokeMySession(w http.ResponseWriter, r *http.Request,
}
}
// GetOnboarding operation middleware
func (sh *strictHandler) GetOnboarding(w http.ResponseWriter, r *http.Request) {
var request GetOnboardingRequestObject
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetOnboarding(ctx, request.(GetOnboardingRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetOnboarding")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetOnboardingResponseObject); ok {
if err := validResponse.VisitGetOnboardingResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// Search operation middleware
func (sh *strictHandler) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
var request SearchRequestObject
@@ -8911,241 +9130,248 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
"YSFPodutPb77+P8FAAD//9wKjL6lLQEA",
"H4sIAAAAAAAC/+x9fW8cx5nnV2nM7gFkdkRStuVsKCwOsmzHurViRlTOd4h0M82ZItmrnu5xv1BiDAGk",
"GFv2URFjX3Y32I3teHOLOyD/jCiOOOIr4E9Q/RXySQ71PFXdVd3VL0P2UPbi/pI401Nd9dRTTz2vv+fj",
"Rsft9V2HOIHfmP+40Tc9s0cC4sFf10PPdz32vy7xO57VDyzXacw36Fd0SE+jDXpC96MNOoge0+Nom740",
"6EG0FW1Em/Qk2jDoiO4bbYc8CFodGKhtwI+GdC/apnvRVvQ5HdKXRrQZPWKj0GM6ij6NthvNhsXe8lFI",
"vPVGs+GYPdKYb+AYjWbD76ySnslmFaz32Td+4FnOSuPhw2bjHSewgvUbXfYtDNI3g9VkDKvbaDY88lFo",
"eaTbmA+8kMjjLbtezwwa840whCez479v9awgHjw1Qxu+lAfskmUztIPG/JW5ZqNnPrB6Ya8x/9oc+8ty",
"8K/L8XssJyArxIMXLbqh1yGTW8gv+rZrdhcDMwj9dy07ILpt/jZ6RAfRo2gr2oy2jegxHcL27Rt0nw7o",
"abQD3z1tNBvkQd92u6Qxv2zaPtFvoA8vU+hjBaQHnPbXHlluzDf+ajZhx1l8zJ+Vp9p4GC/G9Dxznf3t",
"B+s2+4Atmv39C594kyLcQ/Zjv+86PoF5L3jukk167L8d1wmIA8xh9vu21TEZGWf7+MTf/IPPaPqx9K6i",
"NYtx4Y2pXfk6+oyO6DN6QAfG1K13rxs/eePKj6dhV/nP2ejXuj3LYaSAY+25feIFFk664xEzIN2WGShL",
"7poBuRRYPZJdd7PRtfy+ba63kIwfN5zQts0lRnckYeYHVrcCPZsNy28t2W7nHulKB3rJdW1iOux72/SD",
"lu2uWE7RdEtnY7sd0yYamdFs9EI/aHVWTWeFtDoe6RInsEzb109Hfrhv+v5918uZuOfi+4p2+RZ75mGz",
"EfrEE4TNHtWEW3+J7Bs/ntoW/tJ4tQp1c+aev/6mzCfpjbgb09hd+gfSCdgqYo5bMFdIluvis17p0Cfs",
"qznx0p1SgRfTJITXq4Nol2Mvhb3sMkwvsPygdPrw1C2yzEbquGvEa3Vc2/VUAmR4Mb1Q/GXo2ZWOXDf0",
"QOa0lNHjW6XymQysIOesBJ7ZuVd9E2+zx3XrWidm0c7JF2GG+3F2fAyZRClCN8VWqYSJF5G752zXsjLz",
"gvbw3Ft0EaTNJd1i2OuZ3voP6tTUcyhaHTfEyz977C6Y2+X5lO3U+3xXziWq5Z3PbIdW+mqn5Zj2um/5",
"XNHLshD7/lfEa60Rr5o4dB2i35Bl07IVhUP67qOQhHnfeaHjsME1X6aWyUdJfsKnE7+7qa5HT5Cu51rd",
"W8Qmpq+5ULvufYdpxoLTs5qN5bT8sN93vQBXlFIkv2T6fLRJR3RkMOOLvqBDg57QZ9EOKPjH0Tb8SY/p",
"Cd2lo+hR9CTajHYaTQ1tHDcgfqune8//iR7RE4OPckKf0xNj6qbp3WMLQCsRzIpD9l86mNadPn/VfO3K",
"m9pV+taviHKCLSd48w3tHNeI51uohpMHZq8PZsPczJszl3Xv5E+3OmDYlG65GDz1Q4kyfK7xaprqFqY3",
"TMsTsRxNHQ52BotkUEUpl6OFlgo5nejiOqk8tQrCKb4CslLpXEvInaF2FmHXCq51Atc78zTOqtPnTucd",
"J9DerJ0gw9JssBnU3nVTM8XCTNv+YLkx/8sS+Z4Q4+HdtDXKpLDxl43fGUyORJvRIzqkR3TAjvUhHRnR",
"Ft2jA3oYfUGPhY/olJ7Qw+gJ3Qd5MICfHEZPMmbcw+aZLNWK+9M319nJAzJ0uxZbjmkvSKTFWyXnlkn2",
"JjC9FRK09G8tvaP4r/HzsS0ZuEZgY5qCD9Qh5eklS1bomstvtVhxCeO+MjMuDFZvu/eIo1MpOh3i+62A",
"fa2VeuRB3/KI37IcrXdsg57QA4O+gIvrmI4MHPASu+/oAR3SYzpospMxpAfRFj2me+Dd1CgWZNkj/moy",
"k9Sr/o0fmQN2j+7Rw2jHaHdsizi4z/OGibpCW6u5skFjFiMOs2p/2XiLmJ6ieKiiq2xnb5LMjijUVF6r",
"EJKPr9urt1w38APP7F9P/BC3yEch0V15Drmvc8FIFwG536ouhZWnm+rourleB4/JAn8kd5ad0PPYNpXO",
"tOABzUyLpwaccTu14ffJEmidwCjafb8OUuEXPvFyl5N2A/bMB+8TZyVYbcy/+YZmyMl4waR9ghdoiYAi",
"Tu8BDUiv73qmt66QPXXovoFDPKD70Ta7p+gwesS0X6YS79ERPTYgaLHfOOMJkjxcmuWxUbOT1C30nTXi",
"BG+ZQWc1u06yJgI7lcT1gm2uw3BgPZgPbuCPrszNlVh1/D3F07tFfIiF6KRwP8izuboh+tJJjk/LI+xN",
"WvvmW6aCRJ8xqUmPIMxEB9FGtE2HxtSdcG7udWK8Psfk6TEd0pfTsc6yacAnTKjv0iH8jqkuRwbEqYY4",
"XqPUfo/XpSxCmrGOXO9axO6+43k6zTNliCTstsx+VH5s8LEmjqN7+XvEtIPVPAPcjz8XIsW9p5EjqZfy",
"X2lfZ/mB660zLjuzms2k0ZgKIpggFV2XOo0reaUYq2BxdWhRMp1elRr1nusHb1ue3l/UtcZ10UFQrpRh",
"eegOhtdN6n1ricnHnONCxMea82KTVkUG84jdyplts+FDlLbaUJnDiHOQB5He1uTTL1t2HfylkPFVMRif",
"RK73z14K88IaJvcclt64qofxofCZ5gwLOwS7UPBAzlc9y/ctZ6VV8Ahue/VdwowAnHueKEjiM9n33TfX",
"SlWxD801cpMEntXxs9cZp1VT7EX8OkGJ9LJVEiYrlnaMT0vLEO6K5eTr1Yn1U7YoSR2GYPqa1SGxAptJ",
"bBlAjsqIHtFhtGF89+frq57bI8as8aHldN37/neHTeO7Py9YD4ht/O13h42mrANfljQl2dVQoPufSeuV",
"wrcyIXRkvEm+3wkA3/PIvNXqe2S5PEfFWoDHJhnMj+dyvlC+jkk+cJZc0+vy2IYudKfKqYnERtNpLvQZ",
"PaQn9AUkukSbdECPePSAPkN/SPSZ+GAA7hH2MTofo010M0JOGToYd6r4416dm1z+5Zih1zRVV4jjkaIN",
"q2n6mplXmV4yRPZ0pt4Q/78pXT98ecV8fH3VtTokj5tbVlelT+mOy9ZwJYqX3grJiK+XmdfSpAuXv2AG",
"qwtmEBDP0eiEfos4jPnzxCJZIQ+yM58rU/Dwd015/Jy5sXnpDYg+fjmGp0JaacnuZG0KfFXBJBc8smaR",
"++fVq/kwecpa6PTMoLOqd3zkaNHJb8qnn6s7TZLazYbPo1DnyX6tvmGxx0pzKzkBszlQpJceR/F42k8O",
"+inXeeUsC5+YXmcVxLNtExF8WUVTvcHzCLRu1mXP7ZWkaKlX4S9+cePttR8b9CR6ZNADiJMP6XH0iA4g",
"8sY+ip5Gjw06onsQfjtlj/JnjukJeLBGTB0vk3JuB/zV42mG7vKybSmpFpJU4c6SvOX2Xd8qTFnDy6Vq",
"jk5q70Cb8gPTC7jbpkUcsLjvWX3YQnIPlOnQB32L+CGs0Lbugb7l8P90LT/+SPxft7GMVVo+8SH4fxaf",
"QPKItHB1V/IOwZLZuXcdWTjPl5ZVvc0lYhfoaR/XtMTcCTNjluSe3OoR6jQBHt7VxJK54YeTLlUESxkT",
"z/dYOkRaUsIQLcvp4r2r8233iRm0etzvG7tdl5cZVzgol2wtL/qr4fKynXMoiw5VKWXCfrfMeCwdY821",
"Q0UFdcLekkb2S4dA3hBBfZWEsQxvJMtXaRi/WOYGZUV3y7j1htMPg0KWHYtRU2xZ7kpQ2TK+Ruea9bBo",
"YdAnw7HFr/9ecm/CebFCcrmpW0gdHKnlJkkpzHdZl8snM1htQUDHz09hybfAk/lw9SrZ1IxH7IT7wqJN",
"SNg5pQO6G31Gh2B5R5/RATfUIdkhzgeCfAgRKs3fGYVfCjztXhw8LI3a3CSBCUx0z+r386ydwFw5F+Ey",
"RlDst1cJmkxD3TM+g3hhRf5+qchIHxBMbdkf6ABKhOKMqwO2D0x/FIVDI5FFYl3+W6fJ9kp1e7bNMFid",
"sZw107a6sitJm2DSJYFp2foIerQTbUI09ZhpreK1wC/PGTsZU+zb+PNTEXOng7xEsafaBNHET59bLTUC",
"DRpUZDbqcbQdfcJfAnWIvKhtgynMdMAs2iq2kRSw1ca5PLPnlyWZZXY8CbVqNGKum8XWVcNccsNgfsk2",
"nXuNiuHYgiDwLWJ2AtK9LaKk53cdnyPgqpQg5Qdb5TnXERFTaHDuiFizEbiBaY9h5uPz5UEzmKdI/+T3",
"a8po0V2v4mc5yownjVpKJfZcVhryL3Lm3M0N83VWCQ9g5Qlm9Xi33XttkbfRXjYtu21MoSSLHoMERBMZ",
"EvLiBLpdIX+GkJsXbUBy+2ZO6nlKnvyRDul+tAXuZ6iONXgl9C6MBGKGSbAXdI+eCKmC4zPpmhjjBYde",
"yq9g71/xzC7pVk61aAoq6qkPmYa5vqFxMhF5gcJVg62dPmOyFAuE9+gg+iJ6ZCR5kIzoHde9Z5FqFuIt",
"HqMRlDC7PUgdZMqkZ2LGayqNMNmxxY7pLHjuikd8XSS5283LNaqufhWHhR3LXz2nhbTshk7OLJdD286J",
"gWH4NSck7a7lrbvvuR3i+9XLXqR3gntl3AvBbfFX6l/IDbIKAjMprwGqKNNRd0KQVHm7vPQm54zk9YJm",
"CWHjjS9S1xj75Z4vsXkZTQlODhNavOb+iUF3mXQyol/TAX1JD6PtpsG0IqYuqfIMCmsgrY0JnxHdZ1oc",
"PY5z7ofJHsiBluzEwaWJaXqFKRg1lIU1tXHFysWB2UCi50FSXQtBB7IU/udEuYMbYC/aiLZAB30JiZzR",
"JrhWB3SPSaymQYfRJlwrTEOEiGL0CZgzjNpH0RYYONFjbgahEnsMivchCPknVU75mfQIvZb7KejYB4xD",
"TugzgNYA++wxXD2HGEIV9tseV8hf4gmommdTlC2Tn3pSOY9EGzyspeJ4jFQWTuIsS5VrY4vEWyPeDWfZ",
"1RyevtXSlp+taWvPlkQmfEuOlmqFxi7aeLvIngbs7hHoPiOBrMLEBfuXMfXn0RecW1ExGUYb0WdMVMSx",
"BBFeMCDe/oIOQcdJDDMUTuyDPTg07MODaEsjZLCijaect9L1dBmrFeZMj3gwH21X/qPrbpcIfeMSN1Ux",
"OQAYOdppgpXLF3rClgcTZJwebUCFBk/hTlS+YbQBlQl8Jy7Pzf3kJ9rKRh4tT/asb/XDS73QtzraWkVR",
"wdfC1A1f+TG7s9hrQTmunikh8U5eFWe0o6yNDoypRdL7r8SblpfZmJu5PDNXaify2H5S1CgzsG6NBXut",
"ZWf9AfLFImvK9lLN1TTlQFk9inbi0wNmfzUdhhd2aNPOh9EmbkgTy1sV1nzJdgmcEHTAFGPJ66A9QqmE",
"tdryrkw/aIX+WJqbLlwlT0/NRtMBiIgXJvQr4IM6itMFS529Lh3zLvPKFvxWCPBEukv5GxBTQqaBGB3x",
"6OwG/rFv0JMYVmkPy7AHBliOz0A6H8aes6mfvbuIEvmYGZCgvcQVKQNmzMIXJ/QolvJiOOAwuA2eTGtZ",
"bIJllrDrfsesAb4ndg5r6nT2QK055fqPCHvHuV9P2N/G+zfeunXt1n9vvffB4u0mM9u3wBg/wZsPSz2e",
"gPXOdHD0EVTS4vLSqZxlv0UeMEFZ6eSyx11Ylp87nA86RqXhGNWrx1AVo1kbQIVdZPeit2barZ7lhIHO",
"JUN/S3eREYGOoCODnjBC0ccUhmMpUJDyzTDGBjw5BBYrDxskbpOq2dPJr1o94vvcX1ihVnel1fcs17OC",
"9VI91FxZEI9KPtvyGYqL675nBaQVkF7f5jHyFJX/bxJ3QVZ+hOcdTMHok9hsRCd71+34s1xSzfS6f/Xj",
"aTVUw9XBPbY35SxflMaIkl9iVeUYqEyuHOwUgfP4rZkRvBliNSWoOXWbU/IodUPBgcm/AnK8pZp7QANj",
"pgiwXAlSGv9VJco5JYhWYmhPeBwfvVLlRL7igyIR8Up5+qLMt/l7X4sqgorEeTWRBX2pZ7k68i26hTCy",
"hioH2kg8f4zrKD97d/FS9IkA5IQSys+Z/uHdh7r26DFesonKMdArFVKyqToRgIhEwcNuavF2gJcxdG+O",
"yy5HvIiTi7kdof4cg/YErvVoi54y03G6aTD2xNeoy+Qv2uSANAPt7H/gB1al+FwetSGUX3ZXN2o+47Ve",
"bVfTely0RZ+hzyN6MmMAUi2aw+hPgDAMcC09QMgS+FX7jddeMyDaDUBxM2J2LR4Db99xok2jjeHcGY+Y",
"vuu0ZwxQ8ZPQD/e9ocdFivbwGzZGVpK00oEIfQNormB+7tTAbRsyXf40W4xutIGSl8RcZyHs056546hF",
"STlCMEe8LGaiUH3idNH53XNDBx3j6P2GKGM65VuKw6Qr1s5a5FuPEZGrpteuKF+MRlqghVXWf0pUntvE",
"D3LL9sOu5SYFjpnL5kBW6wcABDSiJ4paCnknL7k3+oRHbtXjFAt8tIZHKhIDOz+X5+bmprUySiJlNmn5",
"nl5RQ+E0jlmAv8DxUkT4E4IfYTwA/e/7EBwF2ZugI3Fp3J4Bx6I41X7QZndfFvolddVfNTDSzYQ3KPXT",
"+achr9AGIs0Jl8h7W8AdqXRrZzlW6fXyIPCI2ft5aNr80oiju2HgNpoN17NWLAcSH1atlVWYUdeCtHvb",
"va8dUr5apAEDc8VvLVseZOrztCv2h3YIAbOxIFUNVoELKT6OFdE7ctJsTAE+Wxph4wGxWqNp5pppce7J",
"8PQ/gpb1xEgrXOxu5WfbmNJocSklDE58M/kJXoVHHG5jqFcpu5bfaTluFSzNCmi4y8QMQk/Qu4roh816",
"V/wsC4ZGfyvEHA9R7IPgYorrFLj9NukhIwXKM4F3iLc8T4AbCH9h9CniMu5iGWO0Df48pgBtgCw85rIQ",
"FQX6HCTnEXiaRYZJ5mLSlKmVBh4qwyQkOUPViJlkD+nvUCYrckpDhccPknboMQg/pkFFm9E2PcSbA/gp",
"vnaMqcuvGdHj6auy3wmtiqz5MKziAylHhK3GqEWgrypucSpqmZQCJgdDerV8ihVySnslnYFc2fSudEpU",
"GbXU7xUsUORPM0OQeCvrlR613bDrEN9v2SHWXJf8IkU8NqP4fenRcld4o+vrNFO/xsz5zCYXTKcOV0O1",
"MHi+pyHJo86/mbKwkgD0mSA9Zx/I/4oxcDWxfhYZVnJSq734jCjOccGgQp7kDIuzHp9ljvSMM+OU0e2R",
"gBzI7lBvySJKAbcS6nF9f9nskpavFEG82Syp5ui5S5ZNWlDP58m6FpSnZ4E8JLuLHR2bo/RqMvFXSU9R",
"I7umdw+q8FZWwURZ95ntmFtwt2Z6lonLjfMmvSUY4KMwB+nuPlnSrGQJCG/5tunkZFtmNwFSxW6SyoB5",
"mRDhFscl2xF9cMDOF5Cqz3g6OnfXjOgR4BiUwe4VgGqMjWiRv+yxkAIvZuFl7USqY3/ol80MLgCz4/CC",
"mluRfVu1YDW2nvOQNTCcnlO/WQB99y+i4uNRXE7BCCsiQFiktBzadjP+LPSJ1/oodAMz9UXgui3b9FZI",
"8lGMZyf93ElyQvi6q+hQ56nlCkK/Mso/TrNVgPw1nrcl2ZnM2MnEpC2SllqOkZFWawRLCYa5W8yb+aBJ",
"bq/Hb4YykIcxebPcD551mL925UoJ3lomyVET1+eY0tEWz5mNgxsgXD4HQ/MYM/FFisAwA9o0NyHI+QKm",
"EZ46BRFet7EpxbJnOfzPyyVsU8Yr71q2Lq0lDFbdUvBSFP3gPEDulFir9CyOJSKXrKDVJX1kiHItbckK",
"PB5VKH+Y74fMcqWT77hd0qn25FlQtoRgbdlkDYvzhXYSNw3okcDsmoGJCe4rxOt7lhM0mmxCd5tV2gSJ",
"d7jLrXMJ4JSLpZzg4g31pbFxOJQWatc69AzAdONOZpAPSn4PHcLF+N2f6TPwFUCWVjr/6sSgB0xLgY6F",
"oLbsRlvRU5AukAouwU9FWwAKNzYt44UIrJ8zriTxqCWrGAl/efEqdKhZT8+0FsGhlZ1BKJGwclfnDhIj",
"tkjXCvKUO+E/buVGevqu71tLNmnFZyAPC6lDrDXSbS2t84Bqhe4iWNh9zrofRM9pVZdh7AIZa5rVolPp",
"pos8kISRz6rpUucsTee9Cwo0gm+Saz/H5Wyo0WZjKpX1dMr9fsz8GNFDdkyaRqqCkO4ayvKnx+jsUIgQ",
"O46GW9QRgEfxow04xXGwWJIFuwL+Gpp50iFX2zGsWwfkR4XYpKTGqjpDUygdUuBSPcvZq1ph/MyBjWWW",
"uK8TxUA9YrJ+kekThwX60nWblkI6qa25kvKu3Ox1r5VQGl7Kno30CVXFUSrRTdrPhMeKFcU6qqoltfNV",
"oQxL90y+Z7Mkd4L77hJvZvnz1R8d0xFayfaK3Z+lb79od2ixN3QcZyjsa05qnKyOVFVCqgnwSh5CmJlH",
"IDsnvy60xNou8J2PZ4iXhkXrabWW53IvsIjHNoXPZf0mO6JPbzmfa06vaGZyZEQ9JrSdUDI9jtCBgRnZ",
"WMWbUu85XgvTgJpS1R/00os+Ywq8NoaeAOnE2ReaPhVQCMndfloPumw3ppeFAAgHbHZKywy5AvXQmKJ7",
"0RZ9xpYsEBxAG/sEyvoALEZDhOnx7ZEClovBd1RYp/TWje2xK2O+n4eu7gaCBvJjKfM98wH6Hcf5UZ94",
"0OqoVfa+8lsA6rnGeTe8d8xfpYgrz1qZQc7Ksi/N0C1/p24VQ6l2ScfyUwAsZr/vuWsk9v7qQUDRWXy+",
"IHOBLxCi/L62YPQb1bjhWcEA4JXNO90FUNFR9EiyMiq4T/XNLiBVQVCsnOZ6wZzVOXMw1CpiR4pO+zq3",
"Q0UnlD6ZUGcDQZ5dPnTEGcP13FgnQWA5K76OSwEq6pzG6KuWNRi0GldG6hrzpsRDjtRIrbepIWP5ZsQq",
"aeoQ/itkecG5Os7HOtuBLNEcz0a0BeWgh1C5wc7tUxWB56nw+gGOCH3OPpT9H7vRdqocYDZdCkZPpmcy",
"EBFabsrpCkqf0SHdzy5hkLeEs7lOSxmzCG86n0nzFpWlHCTzVdhPeXl1c35R/kYaU1vP8WMxeKZOQHYp",
"cVgd/gcWELQ4qnpT3JBsc61e3/UCfA7/L33ciptly4opL8lQo6v3rWC165n3nbjZpD7zQwSuvg9tfT80",
"18hbedVcgbXGk5rLsaJM217ihrsGrewAETzpMaaeY+BUm/UoqqUGvMgL9PVn/OgO2GlFoILPEVoIUvYP",
"wR45oKOmwU4EfQZGCYIRZeMJdKTP7O25bjVc5rGwpycEHyPNoZnsFF+E3BVJ7Eve5ucCdo+1/RVp9zBn",
"Eu8S0l3SJqG7YdBx1dwsRjOboBuVg6jfs/otYnr2eoKcXoQ9OAagez70rphZHmFFDysdyswQTNIRIvVJ",
"2dX7dGD8OO4Gmbn1ltYByVjvix8a2Dv/SB0dkKN25S77R5C5PIyt3Tg7Z2rtsjFrXHt/0Zg1+IF7QUdY",
"DhN9zgac5qlRcf53jKnKNHc8hsdVUUuBSGw5cbevrAEBm9qC/S0JR0kJvNa9JHiVUXlQUojKH+Py3Jzs",
"H9jNvwvlF/gBcVrBqueGK6vVJwZFlDn4dvw8VwGv4sPo55ElmUwQ6T3NhJtyOVjenKyNc96tmRidYVWt",
"3HrV3F1I0TkZpnkemudR92daqbssScLKpygWn+VtaLRT+aAfaCGPrI7WeP894hWN4MQcg5i5aQbEs0zb",
"WFzvLbmAXZYh/D2yrkJYJcCJxUKX/bCJk7lbuAI/9xazxmhyKFFEI5HYTVfPWJlWvfFMxVvyVrsI7UQu",
"5MaGDiXdM6uq6W4oIoiSamVTCivLIT201oCWqz0rILd5wP36KtGpFj3LxzDpGNspD3uT/149dZc1LQu0",
"hZlfQgw8ri3HRt6JrsvUgaSQ+xg7zjE7NgHXFACKzED/y+f/bvxk7j8p7m3sIw13WwJndyQD/ypZyaHT",
"bQWe1c+5oMweSWMRyLGWiujS4HwS0NLSO+UXNOWtuVtpd3Odo2cH5Yh/WTqDmBHO0UshaaOgqTM7VLEF",
"wbKBbX0e/QYgBE6jbZ1/g2OKnkLNLy/3i8FbB/TgqtHmnQLaSTqKwkEnUg+FY8RGwuK/F0L1a4yDIFiQ",
"RvNHhUuldNq4Wnugr/lzuiQHmlIk5pymekREWxngIU0KzthBHSkPIp5VvLFFHleFmRbDlRXi6+9jmZnP",
"zLwg0juhZwXri0yscdOCmB7xroW4N/jXu0K6/5cPb0OdPlbJ8G+T3VgNgn7jIRvY4rij2Z0A2o8AVIvt",
"7q13Fm8b1xZuGAtWPzRuhr7VmTHo7xFDgz3FtAtEvH0EYBnbEkaFAi/pmn3rUsftkhXiTN9xEPo8aU8G",
"yFNunzjsMTZfnNj0jAGhz+eMr6CRSBJ0AJwqs2/N9LqIY8HjyI1krmzqEjKlALRkoh5f1ZhvvD4zN/M6",
"L/0GErMxZ9cuzwKO+KwZdi2QVysE/mH7DEHxG93GfON9yw+uwRO8fwMJiOfnBhKSR2bft3oW46rSB69j",
"hsrDuxCC7LuOj5zw2txc3E0Ik6nNPjq+LNeZ/Qcez8EbsbTCmy0B0naAPdJBWkSGBdJ/SgcGfRFtRRtc",
"UA0Qd5L3l9C/Jp53EkEB7FPEe55v0H9KBjTA5n2JOadwa07BRkw3ZfDqaJPbyp8gmCzkDwro97tsdHUX",
"bewYPptgsedup9zh3P9hb2um+Xul3RWtX07oQR17+78lSHL1asRrbEsBvuBeQxnbnO+/tM18N4s2etX1",
"g0tdq2Sv33P94G1Lt815d1QeXiQsbYMe85RQBS0Sv2Jy7gm4pBvzjRgmGtzDDZGWF+9q+taYJJdwIkAt",
"sY5BVMzMUR08kRoynZ0+UOA3kzjKM0ZIQ/4d3jVQwnhJo1idiXWSpGct3/yUCBGxKBJQJ32ARWq19vQ+",
"jr6IHkGIHigyirG4ou06tupbDhJxEu0IH+IzEA+HANHziIOtIaWbRtx0oyLRsTXqrEfinmJ919dQ/RY+",
"sCg6qaZI/lp+nwTIXWB3ClPAASoj2hFIylu86GtQD1NjLdlmYg8K8PMR7EnSeSGZUbStYVLeL1ZLLgwV",
"z0rQfXlcmor0T5BNU2/S8elXzMLhivvLusRIZtA0RHKWtIJud7GdnTbe/ifeE+OJUDjZnsVBWwQMGaET",
"fx8O2g4osLy3Q6rRg7jfoh2oF+KmTMwKbIDnANC8jwt4Qfd4mp7IzJfXhFfQJsQinmHHPIzczzSaKQ7g",
"dddZJgAL/C23uz6h/cc0hoeqqcOTZP7DseDvYycPD+4fn4cns+cdouvFSswif2aC1JWATbWXEMfXOaEH",
"GfdGHUT+97Eu9mZ8haT6f4Ga0JZhPiUgcE2JnKKEqPbsVaPtLPt8rAzQuuEHrmeukEvmCnGCmTtOZgVD",
"BcMRZQOvJ9pDr43aVCYNSRpjmab053mldodrAydxlxeUZahPoS67C2mzQzSgVd5C2AKORDsZwSGDJVcS",
"GJdrfrWWnfWbhXB8THbXpwGX49mOo8ByYTH7sdV9iOwP4XdNEFpg5DaljlGKdRZ9YtBnAJA1yjTEi37D",
"ewdk59uUrrskp4y3tVA0/Ox99TbMVmI3ZePfKIBMHNa1KV9nQW8zwItjblIzVz3LW+ncK2HxSYjpMsqM",
"51nhsqILLpM81e33qZgLAOjtcbV8YEBfjLh+ILYiiyCus/udp2pdgKR8JarVRbNRWqWqUyzO8jhKoeEU",
"0xqfnCBp+TtyFSsJ3ZoJaMgRwpYwO4xRo6066J1+CQQ6h4njdQKnNwwKuy8PDJ6IeApBnh2Ob6kD3046",
"aCP2tiFBb4PHoM1keBqMO3OCF0Ldrtd/jDMbfnHHuIzXvpX00bgbpBqPq8Wh9M90oB7vz87Cf9VO+Wyf",
"5xHPf3wuftW6pHh3/ovkG/5KEUV/NRzEJ6Floj9CcgT308YuW2zHIKF516JBc4tadGE/YtprtCESNJ6J",
"NoYJP4kCAx16/tl5TSDC18BfGlx0aPs2onvQI5kp37/G4quropX0c1RueG8Cumu0f/rObUPr0m7PGPRP",
"aFuKEVGkzv3E4E/OANw7T/jSyMnFjulMVs+RGuI+5Pyd9fTW9qoEnF8rEQ/ktnvcWVyXDBRBorjFCL7s",
"7IwYiMSaugXdbeJfmLUidRHQy5eyjup1iRYx5EhqYaogko/OpYfq2nFMRoqkOqRAITi4t3eS3ggHxhUp",
"vXqgs+0HzUwO3DNIOpeKx7jbO5V7FO3QI8xNkrLksqIF8sOUFJsJiZj8rLQLvk81yY86nv89RhcuiNNl",
"dYzXp2n61Iwbli/ifR8zqSbD/V+m8+C0nC3l0u1C0GfI7AhISMFMiEMIBgFIgWiSDd0zJeVC0XY4LtSQ",
"7gLlPpcbkewqNJ6549CvsnkQU+ySjx6prfmE6yJzwOLsOPRj7EK/Zd7yajqxm8QlH3csCp0Y+L2tcwHz",
"JLfsqbyIEyFl2GnTErj+l7R25p0lJOLW56mVQ7rqEYmbTkH6oh4y7yXHLY626AG45jfGvTzK4r3MrPtF",
"3ORwvHMkF2m+a9kBZAp/rE1UScC34v0sLczSjwSwXnUMpEXBymTSSJX2P6wcrhSsVsUMLingWI9jXIlA",
"5zceHie2yb+aBQCUOGgxHuu+4wRWsF7iFC5F/uN5G9zrvy33jeJ2VrxMdIo8AlVnJLdLhl9JdfRp9B2l",
"n3O+Exm3e6Jx+lcYn0c8Nw0L8yTBulQaAWT6RA+OpAdKOCfzznok8NbPy8I5yU+Bt44UvNHjvXFf8U5J",
"PTEN6YIc1nfncgAZCdU/6Yae9EdSiv/PtoWSizCH+uz75HKd3Mm89Sq9exo8nWJrBLNemRa6h2DGSTC5",
"lrjE1zFXxYgjMv7QSKk60ebRFW2+T5TcYI0zfANTOoDJDwQKCY9fj+hxtDVjtD9qGyg9OFcq3xtC6g+x",
"OIe7H7cwZRPrfuAe4QlDmqA4qHUw1YxSp9OFPlJUn+IGGHn6FLS9aFZkGtH/4gdW7sBYgJG1ukqlxWiB",
"4v+axF128JKkWlHrkGv6fkMHwL2A4PEcg+JwmESWkpzSZAiEg33VZDV+9CNMM0S0wAHd/9GPrsYqzSkf",
"AGzhXYDj24PUJZ7/GG3RF5AeD/75OObDbN4/4LPcsXASbcg9L79gs5PTTtrsvLYV7EJwdwOKiKizO2FE",
"y89kYhs+IfGdvGAs4X255gl0YYl5OfxZiKEnSmJTTdnictvSfGSj0oodENCaVCZd0lC8s1VThqIv6lnu",
"n5TUoFxgruzNJB3e8Ux1n3gl1s4fxZGXvLypHK74EEF1nej0Lk6QgXfVIaQfKxfgEX3OlT7eKQVv5P3E",
"+MEXHaCllEsQdv6/ESocR1F5jhM7AC/jLrzmuTJXzLvGipt9ZmKdohpA9xMV8VDYZOnpIz/G0S5grhno",
"bww7oXV+JY2rJqb0dc8iNSZwD2plxtf0Gd/twzgh4GXenj6ZSDI1tPJqGuIaa2r4slr9XyJN2CxIcElu",
"znv2I6i/eL/i6f3gFGYq3pHkngTIrPha1ty46D9Q79yrHJgPr+xNgRCUCwS4DxbwMNqIPkucElnt8hYj",
"BepBnBoTZLZs72Qd031ZSLB6rqhUvzh5/GJWspfCXnIn5aWPXeOYGZM7tvACHfV+m/SWAUgJKaJXjyOw",
"+AUJ9TpmYNruyhluOMkdIpO+3581na7nWt3ZrnvfYXbd7Me8rjt/N97mj17Dn17r38uxo3gBJjeBRL14",
"WhbLVhFPMWrMN/7HnTvdv7lzZ0b6Z+qS1+H/nf7Pf63xZY9n3aw53Rm++pm+2bkHpQVeZ9VaIyrXxC70",
"JcsxwahLvzjDM9cW/r4Gxri28PcqetkUPYh+A9Di3NpImvArlRTDpoitozHwImnbb9wynRUSd7vew3Io",
"gBscKbpUv586pxKz2KZIi8gz9BMTCDpEHxm8I/cAAn5iLj+1AmIavPIKwcqFooPWz8hIr1gqsBAYZgaq",
"MkmBl8ilj7ZEbD4JZ42ip3ccDOyhIQWtnCQYRoMe4LxmDPpV/Jq4+mgovhaaEZxV3kmcnV62aVwlesMw",
"+/0Z7PVRGhVkMg7J+z5Sd5KyDl90i9jE9EmebSNpkiP6UlpoTdZ5Mn60A959wenRjsFneEmzfzvGFBQ/",
"SIQ/obuCl0RIZV85EcWcjW29y68g0StjcvuCb9B7CLOtwepRD7UDT/DSkajNL/9iIBB4/ho+OOn7fxHp",
"kpvOK93UzBLT9WvbMaaEQOOVYpi/cMxryaNN4clR7/npmjWJ+ud3QTwRuP1LCWpsCV/cdvu3BdprBVcu",
"oC0rftiY4pfnmkkz7itzxYjVE3WkJk3odSwol5nB1SqgWw6jLcihGAr0X+5H5LBnNXlUTyFPcAdQOTBq",
"K+aTy268woLbBzhN1AF4Hhu3w6JPCjzCE2W/MFidtd0Vy5HjVWkbig7pM2OqzbuXMI6YN+6Tpfa0mPVW",
"XN7kkWWP+KuXEnXGoLtGx3XvWcRo961+2PKC9h1nqv1eEPQ/cOz1q8Yi6YQeuWosmj2yaAXk7xYDz+oE",
"V40FM1j9O3mq7emmuB/jqlMk+9Bo81e3AvceAQ8MU2K4WshNvziYIldtSd6AAQdIw6RmxdhmG6n1BMmG",
"JV7AKFVkH7aAHImx8O847TfmLhtsTTO81KTV8UiXOIFl2pBZ/Q3P99pG1AAF5v0U2HEbiQxyS65K4/lZ",
"P8HhA9dt9UxnvcUMjF4/8LU62PvAA5PxS8HYr8olFQartxlD6FOy/wTndhh9JvaQb9jZZQaHWGvM//Ku",
"IkG+5Jygj+olbPhU1tfCII3awc+rixVP+gDz+/h9JQf2t8L/k3bzgA+3mTm5BgdTqhHn5MtoWz4lsQcY",
"02IOeJ78S8VXVZVIl0zbLiPUNduuRqsvz+oyG9ZV65SQijcH5ZcHpv4jRgRHOhtEn5QTiYvMfNl/SyPO",
"n8GqvpCrsTNcMgU+/WfTsZwFswWvtIExxWU4oPFB3v0GplrGb7njIO4AdxjHTYTjezYhOZe/R1fjMOGp",
"lO3Bfj8UybN70f+MdpQEMznegBbvbrQZPYVNpkfRE/Zf2TDf1clOTiQuZvTaGFJIyjJESpWDdNUvjvl0",
"C6tiLkwAf8XPzY6QgYMUweuXxH+ETRfITVmFBQFHcw9Ox12TI4k5biDI4Dtk0suIPuXWOjaiiwPSbRip",
"FXp2W+nizQ5IjCcyAk0XRHFcbgbn/IAO5iGVnnsIsGeGIeWlHRnttTbGuBLo4GM6mL56x5GxMpXUSx7f",
"a/vWrwjTQkR6FtBMzNxZsZwHssPhv1261ukQ+9It0rU80glyvDzXXURVP6vmmpNhslboWK2YpuJbK/UM",
"8yuit7Jen5trCjzsN99osj8vvzY3d/dMJpbVM1fI7H2y1D+3w5b+C8hfgFtlXFT/gftaYdI0q39Ilhb0",
"Jo506Mgae3GBjfKNKvNFG+1NaE4RFx8jlNo2JGLtGFPw/CPsn2F1pwUAjpy3m2Tg7sEd8YLuRduaem/X",
"D97BOU5GaMPgb72KTNvkzQVZfNDXTyCX1WNwpxoBwaYhPnSONW1MMUFmXJmbk9mpb5vr2AhH5ifsLJPn",
"7rw50aKUmySXhCfRrwGhoBb35r/F2vOoKM6ekKpHCmHsOCK50Q6tVt8jy37bQEQp5ZxEmyoUlYj0H9HR",
"1VRZkZJCfcpHx0d4rkl+qBlzHW6SiaZS3CSvyGrNYZG8DIq6OSc3b2IHdOWDphaWbgSgZcw2+DUiLiuK",
"FDCXcgJnl1w38APP7F+SXB8FMv7f5JwjzBhs90I/aHVWTWeFZBwocmLEHniJeMyL6TKISj5IwM9OMNso",
"sTif05OZO07Sr4HjCGdMwFTKEodd2422hb4kQpUlmq62YI4EbwkqXZeINBmu173q++q3wZxnLjwUSbIf",
"A4KA3d2sQPr64Fp5no6o8N+kBxmITtkJmUrfSZLT4hxurtOUHCXe9awQhuht8cwPGoP8FjE7AelCpKBq",
"unUd2/s7MKLkjoOl6PEd17ZJB+aU3q9Vyw9crO7JS0W9bhPTe48/V8k/9bXkmTupq0xvJIFK8D+wiuYp",
"RNNkfZojOys6M7ZuQGyFuK1DKuFCoVM+wF1Cix8w+/JFVC8UkEheE8KstIc7ueo0fXlODi8VR+//f1l0",
"1i3815qkkOssuaYHbXOZ1hVqNmrRXCMfJI9NRu9IXnB91bUENmCZsvFGTp2VDMV0UhdkfVzeySt/k+4B",
"J/SYR/wgwxbaDUzF4UW42xVNgOP0iC075WDuikS8b65l73glxViPSNwJPY84QUs82kawjR2mj8QB5eOm",
"QQ8wQJmEzx+DeqSo08kgU3Eck7cVVQOjPN0sCWFNJ3pzqap8VVW6oewvE81g49ejUF+HtSn5yRMo31Fe",
"8v3UoBXrKPYPM7PyohTmb1UbU582rdF1hUfnkh/wnlh5V8wCf3IxmDDOiPqiPDREpe3F1Uxbl1M4nVA7",
"N4w+j76QGuJzAQKu081op/YsRI5Qk23NEc+JCTWR8MJTbPfhQhuBtau0FMhxvzX1N8xCqNmnCSAeyu8Y",
"AyH8oplE8ZtB8DK7L5O51Er33zb94BKCLd23HL+Kl3XWYyqS5Tr+LCa2lVbggdkDGtUt/tOqiQw1ep/5",
"YIwo2EXzADj9ab7Rch6MBq3eRYIsESYRC8bhxzgQF6Z2cS03TiHYky1wKUWzmq4rN9rOzay8ub6Y9Mme",
"HDIhvqNa+4tEfyqwyGrJn41LJcX4ieI2pUswmS6+pgXBS0/8LbLm3iMx6StV1FjdwmBtGfTT3Rpyo+rC",
"7RSDxkJYvPQpL2/Jkn5YQHnVptNmJ7QF4aTeJQeAwMjLrDMmzXxu+MiQexNxZ8InvNrw0BBtZ1U7iKkx",
"mGyJ2dgAd6f4ObCW5THioAFwXU5GQco0ndCBld6irTEEzXUE1/YjSD6f0J39dXpfDKxYjbM+dXnIvAD7",
"BUxvg01P129NVEUlqIODQnuUtxTLZTF24v7OtKX2OLiR2LdMRHEwfUTGX0VcwgQGBgFA6YiZfnEn7FM6",
"QA86ZvHQEWa0QFN6yIJ9JLD7mEQ7hSkIfzr6YaJtAHZ3yIOg1QFPkR6vULSHq4iVki+RJOyU17Q9qPWD",
"8v7nmnSShglpi6LBOv7FqwlEo3Vox481Ane1r/whOdtwLzAHwa8G5ltTx0Iddgs6bQYlTfZ4N0IJkVSf",
"LvaPUmsmYfGBWzba0rSAAxzRl9Dx75ieNFO4K1L2I0/minF0DboHxYTP2V0iuoQfgEq1x2x7g//Bzhu2",
"fNqItu84U9/9eWV1qRs43x0af/n0S+O7P/PaNKhF/O5wOhfsBeWRjHcnYtbSGgFkvON6HukEpNuCE1Bw",
"GjmM50UcylfLzgAUyu/L/boAer+MtkF87oEHDlNDYi8kUzhl7kbZvUdPjNf1V8uQKaWvqyldJ3S3aVxJ",
"9PMTult6Srw14l0STcT1R4RNfF/FUozvgwFHzcWmab+GCBO/GeYNzERmLCoqR2TgY8wxkSswn6ZaszXv",
"OBzO94jjsMqoxUrT8YGRtFdLlR+LYiWeQQE69W/gtgJEeA0ceLQznaP0LAK9bjByTZRB47fojZRd8B3F",
"iMEnqULs+vMIS18ZazGpTvAy/637Aeml+C/wiNmrI6MXR8KU3sRAVdN5i4B9QHPE9F7j8mtG9Hj6qqGm",
"6h5hObCatYumQ/sjKHo6W8buvMF1b65XgfJO9zXo1nH1N78VuUq3x8T4B/3QF/3ZoHh9oJXjQCVwb9Sf",
"DByeyzDMGZQ86Fcb1nKCN99oaFJ6J5t1/FFlgD2k/c9D02YnrNIVF3Ytd/ZH588z/i1Ako/O4xzKkwzx",
"0LpTlwMEUe6+5B7LJZEPqS8i+in31PlvcQTqSfjp4A03uv5F+6wrFuSKQkzRSCyJW2ByweBq0sjxCynn",
"M/YKfF5fjZQ0KURQFyHWaMuwulCZxC5vubxIqQeSoK5L09JTPu28OJQQdZPdpqItqpGwE6xGlkDpc1JA",
"f4fX5GmSbg69RZWuBlD+GoQ+XsNTQegbl2fmZuaaKmrde+9ce9uYNRau3b7+HkTE0Tc1QoX3zTcM+gf6",
"xTRCoeCNneh/u5AqMwQl9C8bv7vjtPnUZz4KSUhay6Ftt41ZI/449InX+ih0AxO/azJTbAu9MDhh8WSM",
"Ot+OK37iF4u2wrvouCmApoTBJimQpDe8IohKaQYcrTI3zi0VEoimu4OJoFPCq3hJ7z54BYd0n2uoKnRg",
"vKcnxlQMx9o0qkEO65Hu89rzyozOZaKEEvRC6WkabYkklQ06ir6gR8nR4oDF7DhNAaDiELsqHnNF8AAY",
"9IM+ca4t3JjOZux/aAWrXc+8LyHSV8hofIReBRTVTZlsSt/6F8IbUlfbXv5W4ZNnJ+6LuIxUBuVBqwNd",
"maK/v4JImuxgrUJytmc5pCSYVXMDj//obS50u1sLHvMfEHJGM360c47j3/dI0ucrDzoy5TtMiySEFpsx",
"Ft+7dum1K28a2J+bd6OQLU/RzkK0MUZss6mu2/HFhGZ63b96Q3PyF/g8LwLyXrzrlYLeJ5P4HsDeK30t",
"425fg0QBgfDbnnA8Pwee2EcX1ZlZE1SdIpUYCfVzeGzi+4Gv0W3DvwpMFQzbAQV4HgkvQ8Pbpo6N+Beu",
"t6Ub6hT1vxl7A+6ba2TWhfUVZl9/aK6RD/hjk2zAJb2mQui0rpg6buEIPWopEENRKxo35hdeQ+vy3zq8",
"xQKPJZYl5QKx5cQSvYdgMTA9IPikGhCaawTeocdyuFzri6Qa5KJ0CQnMP04jfXy+8vJki7+CwVA5++7P",
"7HqNnmIA6pAeR1vfHVbftdLslHecbrx1pfrqP7+KJBGx7u3sqpu1tL/SVd5OmJ2vs4EeXHwHzUIGT2GV",
"1MbOqUTo5A5iV8CsRn7xGLDBboroUWyzxZZu0oIM7hToqiqCZmMejVmHbcMkmlD9jDyYtFD8GWehV8cy",
"39TLJ5CuzbtAqnwIdfeiExCgaUuJq0zB43hR20qtbAJhqGeLVWLaweqvihSJ9/gjk6yZg1egOVoAnfAp",
"71uOrv7igME/QTQgDuKeygOoWR1yvNAjZne9kBq38InJ1px11wtowcvV93lgcDOJVnPXpkB7Fv72K3Ov",
"X9jkvoZigziEqMkI0058iAw8zE6/aI//F2jbJ9j8Q+z0CX2GFU8iIJTIXJFys21MVSTftJZR1Dl93Fgi",
"pke8a2GwyqbIBCImPKAgDT27Md+YBUHJx/o4jg7imA+bH8v9UuW/e0T5FnoMSB+IBrDSR8JnL33EczKk",
"T+J4mPzDJLda+hSEhTyFfr/x8O7D/xcAAP//h/5WG9w3AQA=",
}
// GetSwagger returns the content of the embedded swagger specification file
@@ -103,7 +103,8 @@ func TestLibraryAndUploads(t *testing.T) {
assert.True(t, src.AcceptsUploads)
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
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
id, err := scanner.ScanFile(ctx, src, root, rel)
@@ -114,6 +115,15 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, err)
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)
require.NoError(t, err)
@@ -198,6 +198,7 @@ func (fp *Fingerprinter) chromaprint() bool {
func (fp *Fingerprinter) Run(ctx context.Context) {
withFP := fp.chromaprint()
roots := map[uuid.UUID]string{}
unavailable := map[uuid.UUID]bool{} // каталог источника пуст или не открывается — диск не смонтирован
var after uuid.UUID
for ctx.Err() == nil {
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
@@ -213,6 +214,9 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
return
}
after = f.ID
if unavailable[f.SourceID] {
continue
}
root, ok := roots[f.SourceID]
if !ok {
if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
@@ -220,12 +224,31 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
continue
}
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)
}
}
}
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) {
sum := f.SHA256
if sum == nil {
+7 -1
View File
@@ -308,9 +308,15 @@ func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRe
},
Wave: gen.WaveMetrics{
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 {
dto := h.sourceDTO(s)
out.Sources = append(out.Sources, gen.SourceStatusItem{
+17
View File
@@ -119,6 +119,15 @@ type walked struct {
hash []byte
}
func hasPresent(files map[string]FileRow) bool {
for _, f := range files {
if !f.Missing {
return true
}
}
return false
}
// job — файл, который нужно прочитать.
type job struct {
walked
@@ -135,6 +144,11 @@ type probed struct {
// ErrScanRunning — скан источника уже идёт.
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.
func (sc *Scanner) Start(src Source, root string, full bool) error {
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 {
return err
}
if len(found) == 0 && hasPresent(existing) {
return ErrSourceEmpty
}
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
var (
+47 -9
View File
@@ -624,10 +624,36 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
type WaveMetrics struct {
Served, Sessions int
Served, Sessions int
WaveRates
ByModel []WaveModelMetrics
}
// WaveRates — доли по показам с известным исходом (nil — исходов ещё нет).
type WaveRates struct {
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 считает метрики по показам с известным исходом.
func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
var (
@@ -635,20 +661,32 @@ func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
judged, through, early, liked int
)
err := st.pool.QueryRow(ctx, `
SELECT count(*), count(DISTINCT wave_session_id), count(outcome),
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
count(*) FILTER (WHERE outcome = 'like')
SELECT count(*), count(DISTINCT wave_session_id), `+waveOutcomes+`
FROM wave_impressions WHERE served_at > now() - interval '7 days'`).
Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked)
if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err)
}
if judged > 0 {
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
m.WaveRates = rates(judged, through, early, liked)
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 — файл, которому нужны отпечатки.
+5
View File
@@ -77,6 +77,7 @@ func (m *Meili) wait(ctx context.Context, t taskRef) error {
Status string `json:"status"`
Error *struct {
Message string `json:"message"`
Code string `json:"code"`
} `json:"error"`
}
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":
msg := task.Status
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
}
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
catalog *library.Store
indexer *Indexer
aff *Affinity
log *slog.Logger
}
// NewHandler создаёт обработчик поиска.
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, dto library.DTO, log *slog.Logger) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, dto: dto, log: log}
func NewHandler(
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 }
@@ -150,10 +153,16 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
// SearchSuggest реализует gen.StrictServerInterface.
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 {
return nil, err
}
f = h.personal(ctx, f, want)
out, err := h.hydrate(ctx, f, corrected)
if err != nil {
return nil, err
@@ -161,6 +170,23 @@ func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestReques
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.
func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) {
typ := gen.All
@@ -3,6 +3,8 @@ package search
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
@@ -20,3 +22,14 @@ func TestSwitchLayout(t *testing.T) {
assert.Equal(t, "кино", SwitchLayout("rbyj"))
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)
}
// write копирует staging во временный файл рядом с целью, fsync, сверяет SHA-256 и создаёт итоговое имя
// жёсткой ссылкой (не перезаписывает существующий файл, в отличие от rename), затем убирает временный.
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека:
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
// каталоги удаляются — агент убирает только то, что создал сам.
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
target := filepath.Join(root, filepath.FromSlash(rel))
dir := filepath.Dir(target)
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)
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
created, err := a.mkdirs(root, dir)
if err != nil {
return WriteResult{}, err
}
if !bytes.Equal(got, want) {
return WriteResult{}, fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
fail := func(err error) (WriteResult, error) {
removeEmpty(created)
return WriteResult{}, err
}
ext := path.Ext(rel)
@@ -172,24 +164,39 @@ func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, erro
if n > 1 {
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 {
syncDir(dir)
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
var got []byte
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) {
return WriteResult{}, fmt.Errorf("link: %w", err)
if err != nil {
_ = 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).
func (a *agent) mkdirs(root, dir string) error {
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID) и возвращает созданные.
func (a *agent) mkdirs(root, dir string) ([]string, error) {
rel, err := filepath.Rel(root, dir)
if err != nil || strings.HasPrefix(rel, "..") {
return errBadPath
return nil, errBadPath
}
var created []string
cur := root
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
if seg == "." || seg == "" {
@@ -200,24 +207,30 @@ func (a *agent) mkdirs(root, dir string) error {
if os.IsExist(err) {
continue
}
return fmt.Errorf("mkdir: %w", err)
removeEmpty(created)
return nil, fmt.Errorf("mkdir: %w", err)
}
created = append(created, 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)
if err != nil {
_ = dst.Close()
return 0, fmt.Errorf("staging: %w", err)
}
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)
if err == nil {
err = dst.Sync()
@@ -228,7 +241,6 @@ func (a *agent) copyTemp(staging, tmp string) (int64, error) {
if err != nil {
return 0, fmt.Errorf("copy: %w", err)
}
a.chown(tmp)
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) {
var req WriteTestRequest
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()})
return
}
p := filepath.Join(root, ".pipu-write-test")
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 {
if err := canWrite(root); err != nil {
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
return
}
@@ -9,6 +9,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -74,6 +75,7 @@ func TestWriteFile(t *testing.T) {
assert.True(t, res.Renamed)
entries, _ := os.ReadDir(dir)
assert.Len(t, entries, 2)
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
// Local-источник пишется в LIBRARY_HOST.
local := "music"
@@ -103,8 +105,21 @@ func TestWriteFileRejects(t *testing.T) {
other := sha256.Sum256([]byte("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)
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) {
@@ -120,7 +135,8 @@ func TestWritableMount(t *testing.T) {
var res WriteTestResult
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
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-точка снимается.
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
FROM python:3.12-slim
# ffmpeg декодирует аудио для анализатора.
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu
# ffmpeg декодирует аудио для анализатора; libgomp1 нужен LightGBM (ранжировщик волны).
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
ARG VERSION=0.0.0-dev
ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1
+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))
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)
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
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.ranker import train_and_save
from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores
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]
return {"skipped": "another training is running"}
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)
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()
offline = offline_metrics(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),
"items": len(model.items),
"interactions": len(interactions),
"bandit_users": bandit_users,
"train_s": round(time.monotonic() - started, 1),
}
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)),
)
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)
log.info("als %s activated: %s", version, metrics)
return {"version": version, "activated": True, **metrics}
return {"version": version, "activated": True, **metrics, **ranker}
finally:
conn.execute("SELECT pg_advisory_unlock(%s)", (ADVISORY_LOCK,))
@@ -214,7 +229,8 @@ class NightlyTrainer:
try:
result = train_all(self.dsn)
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()
except Exception:
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
import math
import time
from dataclasses import dataclass, field
from typing import Protocol
import numpy as np
from numpy.typing import NDArray
@@ -93,6 +96,9 @@ class Profile:
rediscover: set[str] # любимые, не игравшие > 60 дней
exploration: float = 0.15
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)
@@ -111,8 +117,60 @@ class Pick:
id: str
source: str
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).
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
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)
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 "")
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]:
idx = np.nonzero(mask)[0]
if len(idx) <= k:
@@ -197,6 +311,8 @@ def build_batch(
exclude: set[str] | None = None,
seed_ids: list[str] | None = None,
rng: np.random.Generator | None = None,
ranker: Ranker | None = None,
ctx: Context | None = None,
) -> list[Pick]:
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
rng = rng or np.random.default_rng()
@@ -273,7 +389,7 @@ def build_batch(
add(rng.permutation(np.nonzero(allowed)[0])[:40].tolist(), "explore")
cand = np.array(sorted(sources))
# 3. Оценка v1.
# 3. Оценка v1 (она же — признак и запасной вариант для v2).
score = (
0.35 * sim_session[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])
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. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
f = np.ones(len(cand))
if rules:
f = fit({k: v[cand] for k, v in index.features.items()}, rules)
# Нужен запас для разнообразия, но не больше 50 и не больше четверти пула.
@@ -294,11 +419,33 @@ def build_batch(
keep = f >= threshold
if keep.sum() >= enough or threshold == 0.0:
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 # контекст важнее вкуса, когда выбран явно
if len(cand) == 0:
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; доля «нового».
picks: list[Pick] = []
chosen: list[int] = []
@@ -333,7 +480,14 @@ def build_batch(
i = int(cand[best_j])
chosen.append(i)
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 — заменяем ими слабейшие выборы.
fresh_left = sorted(
@@ -348,5 +502,7 @@ def build_batch(
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):
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
+32 -2
View File
@@ -2,18 +2,22 @@
from __future__ import annotations
import logging
from typing import Literal
import numpy as np
import psycopg
from pydantic import BaseModel, Field
from pipu_rec.train.ranker import RankerHolder
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
MAX_SERVED_MEMORY = 300
log = logging.getLogger(__name__)
Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"]
@@ -55,6 +59,12 @@ class WaveService:
self.index = IndexHolder(dsn)
self.sessions = SessionStore(redis_url)
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]:
"""Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется."""
@@ -74,6 +84,7 @@ class WaveService:
update_session(
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(
index,
profile,
@@ -83,12 +94,31 @@ class WaveService:
exclude=set(req.exclude_ids),
seed_ids=seeds,
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:]
artists = [index.artist[index.pos[p.id]] for p in picks]
session.recent_artists = (session.recent_artists + artists)[-10:]
self.sessions.put(req.session_id, session)
return BatchResponse(
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(
"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()
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
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:
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
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},
exploration=exploration,
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
bandit=bandit,
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]:
"""Треки, от которых запущена волна: трек, альбом или исполнитель."""
queries = {
+1
View File
@@ -11,6 +11,7 @@ dependencies = [
"redis>=6.2,<7",
"numpy>=2.2,<3",
"pyyaml>=6.0,<7",
"lightgbm>=4.5,<5",
]
[project.scripts]
@@ -3,6 +3,7 @@
Локально: TEST_DATABASE_URL=postgresql://… uv run pytest tests/test_db_integration.py
"""
import json
import os
import time
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
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0))
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"
+93
View File
@@ -116,3 +116,96 @@ def test_cf_vectors_are_padded_to_column_size() -> None:
v = np.ones((3, 5), dtype=np.float32)
assert pad(v).shape == (3, CF_DIM)
assert pad(np.ones((2, 80), dtype=np.float32)).shape == (2, CF_DIM)
def test_bandit_params_and_exploration() -> None:
from pipu_rec.train.bandit import compute, exploration
now = 100 * DAY
rows = [("u", "fresh", "complete", now)] * 8 + [("u", "fresh", "skip", now)] * 2
rows += [("u", "audio_sim", "complete", now)] * 2 + [("u", "audio_sim", "skip_early", now)] * 8
rows += [("u", "cf", "complete", now - 69 * DAY)] # затухание: ~0.5
p = compute(rows, now)["u"]
assert p["fresh"] == [9.0, 3.0]
assert p["audio_sim"] == [3.0, 9.0]
assert 1.4 < p["cf"][0] < 1.6
assert exploration(p) > 0.15 # «новое» заходит лучше остального — исследования больше
assert exploration({"fresh": [1.0, 9.0], "audio_sim": [9.0, 1.0]}) == 0.05
def test_bandit_favours_successful_source() -> None:
n = 60
ids = [f"t{i}" for i in range(n)]
index = CatalogIndex.build(
ids=ids, artist=[f"a{i}" for i in range(n)], raw_emb=[None] * n, features={}, plays=[0] * n
)
good = Profile(
scores={},
disliked=set(),
disliked_artists=set(),
recent=set(),
played=set(),
liked_recent=set(),
rediscover=set(),
exploration=0.0,
bandit={"popular": [50.0, 1.0], "explore": [1.0, 50.0]},
)
picks = build_batch(index, good, Session(), [], n=5, rng=np.random.default_rng(1))
assert sum(p.source == "popular" for p in picks) >= 4
def test_ranker_learns_what_v1_misses() -> None:
from pipu_rec.train.ranker import V1_COLUMN, should_activate, train
from pipu_rec.wave.engine import FEATURE_NAMES
rng = np.random.default_rng(0)
now = 200 * DAY
rows = []
col = FEATURE_NAMES.index("sim_session")
for g in range(400):
ts = now - (1 if g >= 320 else 30) * DAY # последние сессии — отложенная неделя
for _ in range(8):
x = rng.random(len(FEATURE_NAMES)).tolist()
# Исход определяется sim_session, а оценка v1 в признаках — случайный шум.
outcome = "like" if x[col] > 0.85 else "complete" if x[col] > 0.5 else "skip_early"
x[V1_COLUMN] = float(rng.random())
rows.append((f"s{g}", x, outcome, ts))
booster, m = train(rows, now, min_rows=100)
assert booster is not None, m
assert m["ndcg@10"] > m["ndcg@10_v1"] + 0.1, m
assert should_activate(m, None)
assert not should_activate({"ndcg@10": 0.5, "ndcg@10_v1": 0.6}, None)
assert not should_activate({"ndcg@10": 0.7, "ndcg@10_v1": 0.6}, {"ndcg@10": 0.8})
_, few = train(rows[:50], now)
assert "skipped" in few
def test_engine_uses_ranker_and_logs_features() -> None:
from pipu_rec.wave.engine import FEATURE_NAMES
n = 40
ids = [f"t{i}" for i in range(n)]
index = CatalogIndex.build(
ids=ids, artist=[f"a{i}" for i in range(n)], raw_emb=[None] * n, features={}, plays=[0] * n
)
profile = Profile(
scores={},
disliked=set(),
disliked_artists=set(),
recent=set(),
played=set(),
liked_recent=set(),
rediscover=set(),
exploration=0.0,
)
class LastFirst:
version = "fake-ranker"
def predict(self, x: np.ndarray) -> np.ndarray:
return -x[:, FEATURE_NAMES.index("popularity")] + np.arange(len(x)) * 1e-3
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0), ranker=LastFirst())
assert len(picks) == 5
assert all(p.features is not None and len(p.features) == len(FEATURE_NAMES) for p in picks)
assert model_version(index, profile, LastFirst()) == "fake-ranker"
+50
View File
@@ -176,6 +176,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572, upload-time = "2026-09-29T00:53:04.815Z" },
]
[[package]]
name = "lightgbm"
version = "4.7.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "narwhals" },
{ name = "numpy" },
{ name = "scipy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/8e/4db5e29290d7e619c307fdb8dab0a0514090af2ce3ec483050e024ec6126/lightgbm-4.7.0.tar.gz", hash = "sha256:f8e20f682c9aabd000bcf4a7ed8aa6f473c1adfecccae34ec24e823d156f4af0", size = 1792896, upload-time = "2026-07-18T21:00:56.139Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/05/7213965863cba1ed0150ad045bceed6276a1afaaaedbaeff4699ec4f0ccb/lightgbm-4.7.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:dfc1cfe8e760387be1e7ba7a214688be21fdff96e4ed9749188f83e1877c2477", size = 1877851, upload-time = "2026-07-18T21:00:35.225Z" },
{ url = "https://files.pythonhosted.org/packages/b2/86/f4fe714f2e0bf3941705a20d7f6849dc476276d71236e82ea6b0d6539b86/lightgbm-4.7.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:129535462686f274df179133643118c5c5c5667167fe6c3a28d955f0b3c8e868", size = 1498914, upload-time = "2026-07-18T21:00:36.549Z" },
{ url = "https://files.pythonhosted.org/packages/c6/a3/b29580948b92e8c2f84dea70118ac702ff067dc52ec4ffb5d73c953536a5/lightgbm-4.7.0-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4529acec5c6fefe4768302a529707d0ead90f6a6f42df694b856212e09695b8", size = 3349492, upload-time = "2026-07-18T21:00:37.943Z" },
{ url = "https://files.pythonhosted.org/packages/15/eb/837ea3b40cc36e22eeebb9785c01e42b2c255d033eea1d2d9ee8e2540e55/lightgbm-4.7.0-py3-none-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d23e922acd891e77212e4d0fbcee9ba973c96dee479491341d05ba595357ebb7", size = 3476028, upload-time = "2026-07-18T21:00:39.331Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0b/c5c17d862b12ce292f24cd85d40f2f8f8981668fbdbd43fdc2625eccbc79/lightgbm-4.7.0-py3-none-win_amd64.whl", hash = "sha256:f42d1e5b32b6f170e606d7c689c6165671da98d7bf37f1addec2623efc8740c9", size = 1360833, upload-time = "2026-07-18T21:00:40.865Z" },
]
[[package]]
name = "mypy"
version = "1.20.2"
@@ -207,6 +225,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "narwhals"
version = "2.27.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/21/f64d6b2dbea7bf3f8c38cdc786dcc6ef012ca3d173ad208c782c9a7bedf6/narwhals-2.27.1.tar.gz", hash = "sha256:aed93076a3ea42d9c32c88e4eb5ea422a21937011cbe1f480f9572a523c82094", size = 735013, upload-time = "2026-10-10T06:52:18.113Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl", hash = "sha256:d057df13f5852b8e157596e82eb5e955fad267425df5e420e0ee9863da483b31", size = 483211, upload-time = "2026-10-10T06:52:16.32Z" },
]
[[package]]
name = "numpy"
version = "2.5.4"
@@ -262,6 +289,7 @@ version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "fastapi" },
{ name = "lightgbm" },
{ name = "numpy" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic-settings" },
@@ -282,6 +310,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "fastapi", specifier = ">=0.115,<1" },
{ name = "lightgbm", specifier = ">=4.5,<5" },
{ name = "numpy", specifier = ">=2.2,<3" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
{ name = "pydantic-settings", specifier = ">=2.9,<3" },
@@ -490,6 +519,27 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/df/c9/0f2a4dea06a5e12c1191ee3d96b1e1233797d0d5e18f97a2b909a95a9836/ruff-0.17.1-py3-none-win_arm64.whl", hash = "sha256:42400b9a6ff8515ff27796f07f92e0195be011c86e81e381606f58ea5502fef4", size = 9853542, upload-time = "2026-10-15T15:23:16.05Z" },
]
[[package]]
name = "scipy"
version = "1.18.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7e/74/66de6258867beb2ef08f35f9f2ac017a52cacd5081714d239ff1a442d458/scipy-1.18.1.tar.gz", hash = "sha256:52c4b7422442aba924d03ad4019852b08a92e64ea187b933135687bfe2747307", size = 30781235, upload-time = "2026-08-21T23:28:50.599Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/f7/240c110c08693826b4513a52f5717d62ec7c7af72f2920821247c03b17b3/scipy-1.18.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:457fd7a2a8edeb044ab6ffbc0aa03ff6cd18491356e5e0c834d76ce621b916d1", size = 31111061, upload-time = "2026-08-21T23:23:44.522Z" },
{ url = "https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:e708533e8b2ae2497d65346538a7dcc92814410b25b81432eac66de0f2af8265", size = 28733332, upload-time = "2026-08-21T23:23:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a5/f6/a5b82f8abbe14d134691b8b903696f701d25a081353a29dc655c364d9e62/scipy-1.18.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:7bbf207c4453ce1ad2e00b17313852b33310b83090c2311bdaf97f93c0380d12", size = 20475078, upload-time = "2026-08-21T23:23:54.138Z" },
{ url = "https://files.pythonhosted.org/packages/23/22/0858a0bbd6b3e825ceb8cd9baf9eaf3b2f2b1d77727eb6be40500bcdc92f/scipy-1.18.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:78c0665edead396b1abb4897c41a5c1d9bf090c8a637a4c20a61678e0a264e66", size = 23108904, upload-time = "2026-08-21T23:23:57.824Z" },
{ url = "https://files.pythonhosted.org/packages/75/9a/2e71719f31eaefe0e3a1706c4a1ded94e664bfd95ffca2b219a671faee01/scipy-1.18.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c085faa2cfa879c5141df483f836f4d691045a078224a670fa570fa01612d89", size = 34025113, upload-time = "2026-08-21T23:24:02.209Z" },
{ url = "https://files.pythonhosted.org/packages/df/64/ff35eb9e54894cf471ff4716abd3c81eb0a0626869217ce3e6ba4ccf17d7/scipy-1.18.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f55fa87b6c612ecd6b058f167c53231b1d14e412efe361d3d6e38b3631c73218", size = 35344199, upload-time = "2026-08-21T23:24:07.844Z" },
{ url = "https://files.pythonhosted.org/packages/d3/af/c5538be1792f7034c12c7db6ee67cace58253c7b87b122d68253eaf5de89/scipy-1.18.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c35d74ce0e193ff740c2f2be2ac913ddc232fe6c1ff40b26cfecb9c670c63314", size = 35639587, upload-time = "2026-08-21T23:24:13.05Z" },
{ url = "https://files.pythonhosted.org/packages/91/4c/075e4f66471bac101141ac739e9e135549be1bae584571bd03a530c056e1/scipy-1.18.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d2924a03db38dc2e848bca2fe9f077dafb891480b91a00a0963a8cf86dfc31c1", size = 37480330, upload-time = "2026-08-21T23:24:19.608Z" },
{ url = "https://files.pythonhosted.org/packages/39/e7/979fd14e75008623df31ba70d6bb144700f68feadcea042021c06a05bf82/scipy-1.18.1-cp312-cp312-win_amd64.whl", hash = "sha256:5e4d44984abc0020154ea81b247adeddcc3ac5527b975ff798bd1ba0adc513c2", size = 36658278, upload-time = "2026-08-21T23:24:25.463Z" },
{ url = "https://files.pythonhosted.org/packages/c7/0b/e1525354ff9d7d5feb6d1b31af6d14072e5c91e9607b421fa1ec889660b3/scipy-1.18.1-cp312-cp312-win_arm64.whl", hash = "sha256:d65d448389b8436493abcf629cc94ad0cf32aecaf06e1acca1de53cc795f2f12", size = 24400588, upload-time = "2026-08-21T23:24:30.579Z" },
]
[[package]]
name = "starlette"
version = "1.9.0"
+115
View File
@@ -0,0 +1,115 @@
// Нагрузочный smoke перед релизом (docs/testing.md#нагрузочный-smoke): 100 одновременных слушателей волны
// и 20 запросов подсказок поиска в секунду в течение 3 минут; пороги — цели из docs/api.md и
// docs/recommendations.md. Запуск (k6 ≥ 0.50):
// k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
// Пользователь — обычный (role=user), заведённый для теста: его история и лайки засоряются.
import { check, sleep } from 'k6';
import http from 'k6/http';
const BASE = (__ENV.BASE_URL || 'http://localhost:8080').replace(/\/$/, '');
const DURATION = __ENV.DURATION || '3m';
const LISTENERS = Number(__ENV.LISTENERS || 100);
const SUGGEST_RPS = Number(__ENV.SUGGEST_RPS || 20);
export const options = {
scenarios: {
listeners: { executor: 'constant-vus', vus: LISTENERS, duration: DURATION, exec: 'listen' },
suggest: {
executor: 'constant-arrival-rate',
rate: SUGGEST_RPS,
timeUnit: '1s',
duration: DURATION,
preAllocatedVUs: 10,
maxVUs: 40,
exec: 'suggest',
},
},
thresholds: {
'http_req_duration{name:suggest}': ['p(95)<50'],
'http_req_duration{name:wave_next}': ['p(95)<150'],
'http_req_duration{name:wave_start}': ['p(95)<300'],
'http_req_duration{name:stream}': ['p(95)<300'], // первые 64 КБ — время до начала звука
'http_req_duration{name:events}': ['p(95)<100'],
http_req_failed: ['rate<0.01'],
checks: ['rate>0.99'],
},
};
const json = (token) => ({
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
});
export function setup() {
const res = http.post(
`${BASE}/api/v1/auth/login`,
JSON.stringify({
username: __ENV.PIPU_USER,
password: __ENV.PIPU_PASSWORD,
client_type: 'android',
device_name: 'k6 load smoke',
}),
{ headers: { 'Content-Type': 'application/json' } },
);
if (res.status !== 200) throw new Error(`login: ${res.status} ${res.body}`);
return { token: res.json('access_token') };
}
function uuid() {
// v4 — для id событий (дубли отбрасываются сервером, поэтому уникальность важна)
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
// Слушатель: волна → по каждому треку начало потока и события → следующая пачка с отзывами.
export function listen(data) {
const p = json(data.token);
const start = http.post(`${BASE}/api/v1/wave/sessions`, JSON.stringify({}), { ...p, tags: { name: 'wave_start' } });
if (!check(start, { 'wave started': (r) => r.status === 200 || r.status === 201 })) {
sleep(5);
return;
}
const session = start.json('session_id');
let tracks = start.json('tracks') || [];
for (let batch = 0; batch < 3 && tracks.length > 0; batch++) {
const feedback = [];
for (const t of tracks) {
const s = http.get(`${BASE}${t.stream_url}`, { headers: { Range: 'bytes=0-65535' }, tags: { name: 'stream' } });
check(s, { 'stream starts': (r) => r.status === 206 || r.status === 200 });
const now = new Date();
const outcome = Math.random() < 0.3 ? 'skip_early' : 'complete';
const played = outcome === 'complete' ? 180000 : 8000;
const ev = http.post(
`${BASE}/api/v1/events`,
JSON.stringify({
events: [
{ id: uuid(), type: 'play_start', track_id: t.id, occurred_at: new Date(now - played).toISOString(), position_ms: 0, context_type: 'wave', context_id: session },
{ id: uuid(), type: 'play_end', track_id: t.id, occurred_at: now.toISOString(), position_ms: played, played_ms: played, context_type: 'wave', context_id: session },
],
}),
{ ...p, tags: { name: 'events' } },
);
check(ev, { 'events accepted': (r) => r.status === 200 });
feedback.push({ track_id: t.id, outcome });
sleep(1 + Math.random() * 2); // «слушает» — запросы реального клиента редкие
}
const next = http.post(`${BASE}/api/v1/wave/sessions/${session}/next`, JSON.stringify({ feedback }), {
...p,
tags: { name: 'wave_next' },
});
if (!check(next, { 'wave next': (r) => r.status === 200 })) break;
tracks = next.json('tracks') || [];
}
}
const QUERIES = ['ки', 'кино', 'mo', 'mozart', 'the', 'love', 'ghbdtn', 'a', 'нау', 'rock', 'би', 'queen', 'ле', 'jazz'];
export function suggest(data) {
const q = QUERIES[Math.floor(Math.random() * QUERIES.length)];
const r = http.get(`${BASE}/api/v1/search/suggest?q=${encodeURIComponent(q)}`, {
headers: { Authorization: `Bearer ${data.token}` },
tags: { name: 'suggest' },
});
check(r, { 'suggest ok': (x) => x.status === 200 });
}