Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:31:24 +03:00
93 changed files with 3563 additions and 377 deletions

No files matched your search

+43
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:
@@ -51,6 +54,23 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
run: |
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
free=$(df -Pm / | awk 'NR==2 {print $4}')
echo "свободно на раннере: ${free} МБ"
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
for svc in postgres:5432 redis:6379 meilisearch:7700; do
host=${svc%:*}; port=${svc#*:}
for i in $(seq 1 60); do
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
echo "$svc готов"; break
fi
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
sleep 2
done
done
- name: Go — миграции и запросы store
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/
@@ -64,3 +84,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:
+137 -2
View File
@@ -1006,6 +1006,24 @@ paths:
$ref: "#/components/schemas/SearchResults"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/library/analysis/retry:
post:
operationId: retryAnalysis
tags: [library]
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
responses:
"200":
description: Сколько задач снова в очереди
content:
application/json:
schema:
type: object
required: [requeued]
properties:
requeued:
type: integer
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/search/reindex:
post:
operationId: reindexSearch
@@ -1256,6 +1274,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
@@ -1347,6 +1397,7 @@ paths:
get:
operationId: getAndroidLatest
tags: [app]
security: [] # публично: страница /android раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1364,6 +1415,7 @@ paths:
get:
operationId: downloadAndroidApk
tags: [app]
security: []
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
parameters:
- name: version
@@ -1442,6 +1494,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]
@@ -1909,6 +2014,10 @@ components:
web_player:
type: string
enum: [bar, island]
search_position:
type: string
enum: [center, left, right]
description: Где в топбаре веба поиск (по умолчанию center)
mobile_player:
type: string
enum: [cover, wave]
@@ -2996,7 +3105,7 @@ components:
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
AnalysisStatus:
type: object
required: [queued, running, done, failed, analyzer_ver]
required: [queued, running, done, failed, analyzer_ver, top_error]
properties:
queued:
type: integer
@@ -3009,9 +3118,13 @@ components:
analyzer_ver:
type: string
nullable: true
top_error:
type: string
nullable: true
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
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 +3141,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,
}}
+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());
+5
View File
@@ -21,6 +21,7 @@ import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage';
@@ -59,6 +60,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError,
});
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
@@ -233,6 +237,7 @@ const adminSettingsRoute = createRoute({
});
const routeTree = rootRoute.addChildren([
androidAppRoute,
loginRoute,
setupAdminRoute,
setupPasswordRoute,
+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)}
+70
View File
@@ -0,0 +1,70 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
*/
export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']);
const session = useSession();
const [copied, setCopied] = useState(false);
const latest = useQuery({
queryKey: ['android-latest'],
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
staleTime: 10 * 60_000,
});
const server = location.origin;
const copy = () =>
void navigator.clipboard?.writeText(server).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
const r = latest.data;
return (
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
<span className={s.badge}>Android</span>
<h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? null : r ? (
<>
<a className={s.download} href={r.download_url} download>
<Icon src={downloadIcon} size={22} />
<span>
{t('app.download')}
<span className={s.downloadMeta}>
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
</span>
</span>
</a>
<ol className={s.steps}>
<li>{t('app.step_install')}</li>
<li>
{t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} />
</button>
</li>
<li>{t('app.step_updates')}</li>
</ol>
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{r.sha256}</code>
</details>
</>
) : (
<div className={s.hint}>{t('app.unavailable')}</div>
)}
</AuthLayout>
);
}
+23 -2
View File
@@ -1,5 +1,5 @@
import { unwrap } from '@pipu/core';
import { 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();
+9 -1
View File
@@ -37,7 +37,15 @@ export function LoginPage() {
};
return (
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
<AuthLayout
footer={
info.data ? (
<>
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
</>
) : undefined
}
>
<h1>{t('login.title')}</h1>
<p className={s.lead}>{t('login.subtitle')}</p>
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
+113
View File
@@ -0,0 +1,113 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useMutation } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { Cover } from '../entities/catalog';
import { useErrorText } from '../shared/lib/errors';
import { checkIcon } from '../shared/ui/icons';
import { Button, cx, FormError, Icon } from '../shared/ui/ui';
import s from './onboarding.module.css';
type Options = components['schemas']['Onboarding'];
/** Минимум исполнителей, чтобы волне было от чего оттолкнуться. */
const MIN_ARTISTS = 3;
/**
* Онбординг вкуса (docs/recommendations.md#7): исполнители и жанры библиотеки — начальный вкус «Моей волны»,
* пока своей истории почти нет. Пропуск тоже запоминается: второй раз не спросим.
*/
export function Onboarding({ options, onDone }: { options: Options; onDone: () => void }) {
const { t } = useTranslation();
const session = useSession();
const errorText = useErrorText();
const [artists, setArtists] = useState<Set<string>>(new Set());
const [genres, setGenres] = useState<Set<string>>(new Set());
const save = useMutation({
mutationFn: async (skip: boolean) =>
unwrap(
await session.client.PUT('/api/v1/me/onboarding', {
body: skip ? { artist_ids: [], genres: [] } : { artist_ids: [...artists], genres: [...genres] },
}),
),
onSuccess: onDone,
});
// Функциональное обновление: быстрые нажатия подряд не теряют выбор.
const toggle = (update: (fn: (prev: Set<string>) => Set<string>) => void, key: string) =>
update((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
const enough = artists.size >= MIN_ARTISTS || (artists.size > 0 && genres.size > 0);
return (
<div className={s.page}>
<header className={s.head}>
<h1>{t('onboarding.title')}</h1>
<p>{t('onboarding.subtitle')}</p>
</header>
<section aria-label={t('onboarding.artists')}>
<h2 className={s.label}>{t('onboarding.artists')}</h2>
<div className={s.artists}>
{options.artists.map((a) => {
const on = artists.has(a.id);
return (
<button
key={a.id}
type="button"
className={cx(s.artist, on && s.on)}
aria-pressed={on}
onClick={() => toggle(setArtists, a.id)}
>
<span className={s.avatar}>
<Cover album={{ id: a.id, cover_url: a.cover_url, cover_colors: a.cover_colors }} size={96} round />
{on && (
<span className={s.check}>
<Icon src={checkIcon} size={22} />
</span>
)}
</span>
<span className={s.name}>{a.name}</span>
</button>
);
})}
</div>
</section>
{options.genres.length > 0 && (
<section aria-label={t('onboarding.genres')}>
<h2 className={s.label}>{t('onboarding.genres')}</h2>
<div className={s.genres}>
{options.genres.map((g) => (
<button
key={g.name}
type="button"
className={s.genre}
aria-pressed={genres.has(g.name)}
onClick={() => toggle(setGenres, g.name)}
>
{g.name}
</button>
))}
</div>
</section>
)}
<FormError>{save.error ? errorText(save.error) : null}</FormError>
<div className={s.actions}>
<Button variant="ghost" busy={save.isPending && save.variables} onClick={() => save.mutate(true)}>
{t('onboarding.skip')}
</Button>
<Button variant="primary" busy={save.isPending && !save.variables} disabled={!enough} onClick={() => save.mutate(false)}>
{enough ? t('onboarding.done') : t('onboarding.pick_more', { count: MIN_ARTISTS - artists.size })}
</Button>
</div>
</div>
);
}
+13
View File
@@ -126,6 +126,19 @@ function AppearanceSection() {
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.search_position')}</span>
<Segmented<'left' | 'center' | 'right'>
label={t('settings.search_position')}
value={user.ui_prefs.search_position ?? 'center'}
onChange={(v) => void setPrefs({ search_position: v })}
options={[
{ value: 'left', label: t('settings.search_left') },
{ value: 'center', label: t('settings.search_center') },
{ value: 'right', label: t('settings.search_right') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'>
+52 -4
View File
@@ -1,5 +1,6 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
@@ -31,6 +32,11 @@ export function LibraryPage() {
const reindex = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
});
const queryClient = useQueryClient();
const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
});
const d = status.data;
const tiles = d
? ([
@@ -76,7 +82,19 @@ export function LibraryPage() {
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
})}
/>
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
{d.analysis.failed > 0 && (
<div className={l.retryRow}>
<span className={l.scanError}>
{t('library.analysis_failed', { count: d.analysis.failed })}
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span>
{admin && (
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
)}
</div>
)}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
</section>
)}
@@ -99,6 +117,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 +178,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>
@@ -118,6 +118,20 @@
color: var(--danger);
}
.retryRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.errorDetail {
display: block;
margin-top: 4px;
color: var(--text3);
word-break: break-word;
}
.bar {
height: 6px;
border-radius: 3px;
+77
View File
@@ -161,6 +161,83 @@
color: var(--text3);
}
.footer a {
color: var(--text2);
}
/* Страница Android-приложения (/android). */
.download {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
border-radius: var(--radius-lg);
background: var(--accent-fill);
color: #fff;
font-weight: 700;
text-decoration: none;
box-shadow: var(--glow);
transition:
background 0.2s,
transform 0.15s;
}
.download:hover {
background: var(--accent-fill2);
}
.download:active {
transform: scale(0.98);
}
.downloadMeta {
display: block;
margin-top: 2px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
}
.steps {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--text2);
font-size: 14px;
line-height: 1.5;
}
.server {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 6px;
padding: 2px 10px;
border-radius: 8px;
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
cursor: pointer;
}
.details {
font-size: 12px;
color: var(--text3);
}
.details summary {
cursor: pointer;
}
.details code {
display: block;
margin-top: 6px;
word-break: break-all;
font-family: var(--font-mono);
}
.link {
align-self: center;
}
+2 -1
View File
@@ -7,6 +7,7 @@
gap: 28px;
padding: 24px 0;
text-align: center;
user-select: none; /* главная — только элементы управления волной */
}
.orbLabel {
@@ -86,7 +87,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>
+2
View File
@@ -23,6 +23,8 @@ export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.sv
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
+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;
}
+21
View File
@@ -41,6 +41,19 @@ button {
cursor: pointer;
}
/* Элементы управления не выделяются при кликах и двойных кликах — текст остаётся выделяемым на страницах. */
button,
[role='button'],
[role='tab'],
[role='option'],
[role='menuitem'],
[role='listitem'],
nav a,
label {
user-select: none;
-webkit-user-select: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
@@ -53,6 +66,14 @@ button {
}
}
/* Выезжающие справа панели (очередь, docs/design.md#4). */
@keyframes pipuSlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+20 -4
View File
@@ -27,12 +27,15 @@
padding-bottom: 132px;
}
/* Над контентом: выпадающие подсказки поиска и меню профиля перекрывают страницу, а не уходят под неё. */
.top {
grid-area: top;
display: flex;
z-index: 30;
/* Три зоны: логотип, середина, профиль; поиск — в одной из них (настройка «Расположение поиска»). */
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 24px;
justify-content: space-between;
gap: 16px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--bar-a);
@@ -51,9 +54,22 @@
}
}
.topLeft {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topCenter {
display: flex;
justify-content: center;
}
.topRight {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
@@ -155,7 +171,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;
+12 -4
View File
@@ -22,6 +22,7 @@ import {
dislikeFillIcon,
dislikeIcon,
settingsIcon,
androidIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
@@ -92,6 +93,8 @@ function Shell() {
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island';
const searchAt = user.ui_prefs.search_position ?? 'center';
const search = <SearchBox align={searchAt} />;
const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin');
@@ -99,11 +102,15 @@ function Shell() {
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<header className={s.top}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
<SearchBox />
<div className={s.topLeft}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
{searchAt === 'left' && search}
</div>
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
<div className={s.topRight}>
{searchAt === 'right' && search}
<Menu
label={t('profile.menu')}
triggerClassName={s.profileButton}
@@ -122,6 +129,7 @@ function Shell() {
}
items={[
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
]}
/>
+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,
);
+47 -6
View File
@@ -1,12 +1,17 @@
.box {
position: relative;
flex: 1;
max-width: 520px;
flex: none;
width: 40px;
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.expanded {
width: min(520px, 46vw);
}
.icon {
position: absolute;
left: 14px;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--text3);
@@ -16,13 +21,36 @@
.input {
width: 100%;
height: 40px;
padding: 0 40px 0 42px;
padding: 0 40px 0 40px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s;
}
/* Свёрнутое поле — круглая кнопка: без подсказки и курсора ввода. */
.box:not(.expanded) .input {
padding: 0;
cursor: pointer;
color: transparent;
}
.box:not(.expanded) .input::placeholder {
color: transparent;
}
.box:not(.expanded) .input:hover {
background: var(--surface3);
}
.box:not(.expanded) .kbd {
display: none;
}
.input:focus {
@@ -56,8 +84,7 @@
.popover {
position: absolute;
left: 0;
right: 0;
width: min(520px, calc(100vw - 32px));
top: calc(100% + 8px);
z-index: 45;
max-height: min(70vh, 560px);
@@ -70,6 +97,20 @@
animation: pipuIn 0.15s ease-out;
}
/* Подсказки раскрываются в сторону свободного места: слева — вправо, справа — влево, по центру — от середины. */
.left .popover {
left: 0;
}
.right .popover {
right: 0;
}
.center .popover {
left: 50%;
transform: translateX(-50%);
}
.corrected {
margin: 4px 10px 8px;
font-size: 13px;
+20 -5
View File
@@ -23,8 +23,11 @@ type Item = {
go: () => void;
};
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
export function SearchBox() {
/**
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
*/
export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'right' }) {
const { t } = useTranslation();
const session = useSession();
const navigate = useNavigate();
@@ -34,6 +37,10 @@ export function SearchBox() {
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [focused, setFocused] = useState(false);
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
const expanded = focused || q !== '';
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
@@ -141,7 +148,7 @@ export function SearchBox() {
const optionId = (i: number) => `${listId}-${i}`;
return (
<div className={s.box}>
<div className={cx(s.box, expanded && s.expanded, s[align])}>
<Icon src={searchIcon} size={18} className={s.icon} />
<input
ref={inputRef}
@@ -160,8 +167,16 @@ export function SearchBox() {
setOpen(true);
setActive(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(close, 120)}
onFocus={() => {
// Вернулись в поле раньше, чем сработало закрытие по blur, — подсказки не прячем.
clearTimeout(blurTimer.current);
setFocused(true);
setOpen(true);
}}
onBlur={() => {
setFocused(false);
blurTimer.current = setTimeout(close, 120);
}}
onKeyDown={onKeyDown}
/>
<kbd className={s.kbd} aria-hidden>
+4 -2
View File
@@ -45,6 +45,8 @@ SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0
# --- Самообновление Android (M5) ---
GITEA_URL=https://git.example.lan
GITEA_REPO=owner/pipu-music
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
GITEA_URL=
GITEA_REPO=
GITEA_TOKEN=
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Резервные копии PostgreSQL (docs/deployment.md#бэкапы). Работает в контейнере backup (compose.yaml) и в CI;
# подключение — стандартные PGHOST, PGUSER, PGPASSWORD, PGDATABASE.
# backup.sh loop — каждый день в BACKUP_HOUR (UTC): копия и ротация; раз в неделю — проверка восстановления
# backup.sh once — копия сейчас; печатает путь к файлу
# backup.sh verify FILE — восстановить FILE во временную базу и сверить таблицы и число строк
# backup.sh restore FILE — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены);
# прежняя база остаётся рядом как <db>_before_restore_<время>
set -euo pipefail
DIR="${BACKUP_DIR:-/backups}"
DB="${PGDATABASE:-pipu}"
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
log() { echo "$(date -u +%FT%TZ) backup: $*" >&2; }
# Число строк в каждой таблице базы $1: «таблица<TAB>строк», по алфавиту.
counts() {
psql -X -d "$1" -Atq -F $'\t' -c "
SELECT table_name,
(xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name),
false, true, '')))[1]::text
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY 1"
}
rotate() {
# shellcheck disable=SC2012 # имена файлов наши, без пробелов
ls -1t "$DIR"/daily/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_DAILY + 1)) | xargs -r rm -f --
# shellcheck disable=SC2012
ls -1t "$DIR"/weekly/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_WEEKLY + 1)) | xargs -r rm -f --
find "$DIR/daily" "$DIR/weekly" -name '*.counts' | while read -r c; do
[ -f "${c%.counts}.dump" ] || rm -f -- "$c"
done
}
once() {
mkdir -p "$DIR/daily" "$DIR/weekly"
local name file
name="pipu-$(date -u +%Y%m%d-%H%M%S)"
file="$DIR/daily/$name.dump"
pg_dump -Fc -d "$DB" -f "$file.part"
pg_restore -l "$file.part" >/dev/null # оглавление читается — архив не обрезан
counts "$DB" >"$file.counts" # для verify: что было в базе в момент копии
mv "$file.part" "$file"
# Еженедельная копия — первая за 7 дней (не зависит от того, в какой день сервер был выключен).
if ! find "$DIR/weekly" -name 'pipu-*.dump' -mtime -7 | grep -q .; then
cp "$file" "$DIR/weekly/$name.dump"
cp "$file.counts" "$DIR/weekly/$name.dump.counts"
fi
rotate
log "$file ($(du -h "$file" | cut -f1))"
echo "$file"
}
verify() {
local file="$1" tmpdb="${DB}_verify" got want
[ -f "$file" ] || { log "нет файла $file"; return 1; }
dropdb --if-exists "$tmpdb"
createdb "$tmpdb"
if ! pg_restore --no-owner --exit-on-error -d "$tmpdb" "$file"; then
dropdb --if-exists "$tmpdb"
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file не восстанавливается"
return 1
fi
got="$(counts "$tmpdb")"
dropdb "$tmpdb"
want="$(cat "$file.counts" 2>/dev/null || counts "$DB")"
# Таблица пропала или опустела — копия негодна. Расхождение в числе строк — рабочая база менялась во время
# копии (предупреждение); VERIFY_EXACT=1 (CI, база в покое) — тоже ошибка.
local bad=0 table n m
while IFS=$'\t' read -r table n; do
m="$(printf '%s\n' "$got" | awk -F'\t' -v t="$table" '$1 == t { print $2 }')"
if [ -z "$m" ]; then
log "в копии нет таблицы $table"
bad=1
elif [ "$m" = 0 ] && [ "$n" != 0 ]; then
log "в копии пуста таблица $table (было $n)"
bad=1
elif [ "$m" != "$n" ]; then
log "$table: $m строк в копии, $n при копировании"
[ "${VERIFY_EXACT:-}" = 1 ] && bad=1
fi
done <<<"$want"
if [ "$bad" = 1 ]; then
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file"
return 1
fi
log "проверено: $file восстанавливается ($(printf '%s\n' "$got" | wc -l) таблиц)"
date -u +%FT%TZ >"$DIR/.last-verify"
}
restore() {
local file="$1" old
verify "$file"
old="${DB}_before_restore_$(date -u +%Y%m%d%H%M%S)"
psql -X -d postgres -Atqc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '$DB' AND pid <> pg_backend_pid()" >/dev/null
psql -X -d postgres -qc "ALTER DATABASE \"$DB\" RENAME TO \"$old\""
createdb "$DB"
if ! pg_restore --no-owner --exit-on-error -d "$DB" "$file"; then
dropdb "$DB"
psql -X -d postgres -qc "ALTER DATABASE \"$old\" RENAME TO \"$DB\""
log "восстановление не удалось — рабочая база не тронута"
return 1
fi
log "база $DB восстановлена из $file; прежняя сохранена как $old (удалить: dropdb $old)"
}
loop() {
local hour="${BACKUP_HOUR:-3}" now next file
log "ежедневная копия в $hour:30 UTC, хранится $KEEP_DAILY ежедневных и $KEEP_WEEKLY еженедельных"
while true; do
now="$(date -u +%s)"
next="$(date -u -d "today $hour:30" +%s)"
[ "$next" -gt "$now" ] || next="$(date -u -d "tomorrow $hour:30" +%s)"
sleep $((next - now))
if ! file="$(once)"; then
log "КОПИЯ НЕ СДЕЛАНА"
continue
fi
if [ -z "$(find "$DIR" -maxdepth 1 -name .last-verify -mtime -7)" ]; then
verify "$file" || true
fi
done
}
case "${1:-}" in
loop) loop ;;
once) once ;;
verify) verify "${2:?укажите файл копии}" ;;
restore) restore "${2:?укажите файл копии}" ;;
*)
sed -n '2,9p' "$0" >&2
exit 2
;;
esac
+23
View File
@@ -20,11 +20,13 @@ x-library-ro: &library-ro
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только 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
+33
View File
@@ -76,6 +76,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi
done
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
fill_gitea() {
local url repo origin host path
url="$(sed -n 's/^GITEA_URL=//p' .env)"
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
case "$url" in "" | *example.*) url="" ;; esac
case "$repo" in "" | owner/*) repo="" ;; esac
[ -n "$url" ] && [ -n "$repo" ] && return 0
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || return 0
case "$origin" in
http://* | https://*)
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
ssh://*)
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
*:*)
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
path="${origin#*:}" ;;
*) return 0 ;;
esac
path="${path%.git}"
[ -n "$url" ] || url="$host"
[ -n "$repo" ] || repo="$path"
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
done
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
}
fill_gitea
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
if [ -n "$LIBRARY_HOST" ]; then
if grep -q '^LIBRARY_HOST=' .env; then
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Восстановление базы из резервной копии (docs/deployment.md#бэкапы).
# ./restore.sh — список копий
# ./restore.sh daily/pipu-20261020-033000.dump
# Сервисы, пишущие в базу, останавливаются на время восстановления; прежняя база сохраняется рядом.
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
DATA="$(sed -n 's/^PIPU_DATA=//p' .env)"
DATA="${DATA:-/srv/pipu}"
if [ -z "${1:-}" ]; then
echo "Копии в $DATA/backups (новые сверху):"
(cd "$DATA/backups" && ls -1t daily/*.dump weekly/*.dump 2>/dev/null) || echo " копий нет"
exit 0
fi
rel="${1#"$DATA/backups/"}"
[ -f "$DATA/backups/$rel" ] || { echo "нет файла $DATA/backups/$rel" >&2; exit 1; }
echo "→ восстановление из $rel: api, рекомендации и анализ будут остановлены на время восстановления"
read -r -p "Продолжить? [y/N] " yes
[ "$yes" = y ] || [ "$yes" = Y ] || exit 1
docker compose stop api recommender analyzer storage-agent
if docker compose run --rm backup restore "/backups/$rel"; then
status=0
else
status=1
fi
docker compose up -d
[ "$status" = 0 ] || { echo "восстановление не удалось — база не изменена" >&2; exit 1; }
echo "готово. Поиск переиндексируется при старте api (или вручную: «Администрирование → Библиотека»)."
+48
View File
@@ -26,6 +26,48 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi
done
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
fill_gitea() {
local url repo origin host path
url="$(sed -n 's/^GITEA_URL=//p' .env)"
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
case "$url" in "" | *example.*) url="" ;; esac
case "$repo" in "" | owner/*) repo="" ;; esac
[ -n "$url" ] && [ -n "$repo" ] && return 0
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || return 0
case "$origin" in
http://* | https://*)
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
ssh://*)
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
*:*)
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
path="${origin#*:}" ;;
*) return 0 ;;
esac
path="${path%.git}"
[ -n "$url" ] || url="$host"
[ -n "$repo" ] || repo="$path"
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
done
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
}
fill_gitea
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
echo "LIBRARY_HOST=$LIBRARY_HOST пуст или не существует — диск с музыкой не смонтирован?" >&2
echo " проверьте: findmnt $LIBRARY_HOST; смонтируйте (sudo mount -a) и повторите." >&2
echo " Если папка и должна быть пустой: PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh ${1:-}" >&2
exit 1
fi
REF="${1:-}"
git fetch --tags --prune --quiet origin
@@ -53,6 +95,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) | Этапы разработки и критерии готовности |
+139
View File
@@ -0,0 +1,139 @@
# Руководство администратора
Повседневная работа с сервером после установки ([install-guide.md](install-guide.md)): пользователи, музыка,
очередь на добавление, «Моя волна», обновления, бэкапы и диагностика. Все команды — из каталога
`/opt/pipu-music/deploy`. Справочник по сервисам и переменным — [deployment.md](deployment.md).
## 1. Первый вход
1. Логин `admin`. Пароль — `BOOTSTRAP_ADMIN_PASSWORD` из `.env`, а если он не задан — сгенерированный, его
печатает лог один раз: `docker compose logs api | grep -i bootstrap`.
2. Сервер сразу попросит сменить логин и пароль администратора (пароль ≥ 12 символов). До этого остальные
разделы недоступны.
3. Подключите хранилище с музыкой (раздел 3) — после первого скана появятся библиотека, поиск и «Моя волна».
## 2. Пользователи и роли
«Администрирование → Пользователи».
| Роль | Что может |
|---|---|
| Пользователь | Слушать, искать, собирать коллекцию, «Моя волна» |
| Модератор | Плюс: заводить пользователей и сбрасывать им пароли, смотреть статус библиотеки, предлагать треки |
| Администратор | Всё: хранилища, сканы, очередь на добавление, настройки сервера, роли, аудит |
Полная матрица прав — [auth-and-roles.md](auth-and-roles.md#матрица-прав).
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
раз, передайте его человеку. При первом входе пароль придётся сменить.
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
- Последнего администратора нельзя понизить, заблокировать или удалить.
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
паролей, изменения хранилищ и настроек, решения по очереди.
## 3. Хранилища и библиотека
«Администрирование → Хранилища».
- **Папка на сервере** — каталог хоста из `LIBRARY_HOST` в `.env` (например, смонтированный диск `/mnt/music`);
в форме указывается путь внутри него. **NFS** — сервер и экспорт; монтирует `storage-agent`.
- «Проверить» — доступ к хранилищу, число найденных аудиофайлов.
- **Шаблоны путей** («Шаблоны»): как из пути файла получить исполнителя, альбом, год и номер трека, если в тегах
пусто. «Предпросмотр» показывает разбор реальных путей до сохранения. Что важнее — теги или путь, задаётся
в карточке хранилища (по умолчанию теги).
- **Скан:** обычный — только новые и изменённые файлы (по расписанию — интервал в карточке хранилища — и после
изменения настроек), «Полный» — перечитать всё. Удалённые с диска файлы помечаются отсутствующими, а не стираются из истории.
- «Администрирование → Библиотека» — счётчики, прогресс скана и аудиоанализа, файлы с ошибками чтения
(битые файлы, неизвестные форматы) и «Переиндексировать поиск» — если поиск выдаёт устаревшее (обычно не нужно:
индекс обновляется сам и пересобирается при старте api).
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
Не удалось проанализировать часть треков (например, пока диск не был смонтирован) — «Повторить» под счётчиком
ошибок возвращает их в очередь; рядом показана самая частая ошибка. Пропавшие файлы анализатор не считает
попыткой и ждёт, пока диск вернётся.
## 4. Очередь на добавление
Модераторы и администраторы предлагают треки («Предложить треки»), администратор решает, что попадёт
в библиотеку. Подробно — [uploads.md](uploads.md).
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
получит те же исполнителя, альбом и номер трека. Хранилище по умолчанию — в «Настройках сервера».
- **Рассмотрение** («Очередь на добавление»): прослушать, поправить метаданные (в том числе сразу для всего
альбома), одобрить или отклонить с причиной — автор её увидит. Итоговый путь пересчитывается при правке.
Отметка «возможный дубль» — со ссылкой на похожий трек библиотеки; точные копии отсекаются ещё до загрузки.
- **Импорт:** одобренный файл записывается в хранилище только под окончательным именем, с проверкой контрольной
суммы; кроме самих аудиофайлов (и папок для них) на диск ничего не пишется.
- **Квота** («Настройки сервера»): общий лимит очереди (10 ГБ по умолчанию), лимит на одного модератора
(выключен) и максимальный размер файла (2 ГБ). Отклонённые файлы и дубли место не занимают; снижение лимита ниже
занятого не удаляет загрузки, просто новые не принимаются.
## 5. «Моя волна»
- «Администрирование → Библиотека → Моя волна за 7 дней»: сколько треков выдано, доля дослушанных до конца,
ранних скипов и лайков на 100 треков. Ориентир — ранних скипов меньше 30%.
- Таблица по **версиям модели**: `heuristic-v1` — эвристика, `heuristic-v1+cf` — она же с коллаборативной
фильтрацией (ALS), `lgbm-…` — обученный ранжировщик. По ней видно, стало ли лучше после смены модели.
- Обучение — каждую ночь в 3:00 UTC: ALS по прослушиваниям, бандит по источникам
кандидатов, ранжировщик по показам волны. Новая модель включается, только если по проверке на последней неделе
не хуже текущей. Ранжировщику нужно ~20 тыс. показов с известным исходом — до этого работает эвристика.
- Новые пользователи с почти пустой историей проходят **онбординг вкуса**: выбирают исполнителей и жанры
библиотеки. Пропуск тоже запоминается.
## 6. Обновление
```bash
./update.sh
```
Скрипт делает копию базы, переключается на последний релиз, собирает образы, перезапускает сервисы и ждёт
готовности; миграции применяются при старте api. Конкретная версия — `./update.sh v1.0.0`; откат на старую версию
требует `PIPU_ALLOW_DOWNGRADE=1` (миграции не откатываются — сначала проверьте совместимость). Перед обновлением
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
## 7. Бэкапы и восстановление
- Каждую ночь (`BACKUP_HOUR`, по умолчанию 3:30 UTC) контейнер `backup` делает копию базы в
`/srv/pipu/backups/daily` (14 последних) и раз в неделю — в `weekly` (8 последних).
- Раз в неделю копия **проверяется восстановлением** во временную базу. Результат:
`docker compose logs backup | tail`; при ошибке в логе — «ПРОВЕРКА НЕ ПРОЙДЕНА».
- Копия сейчас: `docker compose run --rm backup once`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/<файл>` — восстановить. Прежняя база
остаётся рядом как `pipu_before_restore_<время>`; убедившись, что всё в порядке, удалите её:
`docker compose exec postgres dropdb -U pipu pipu_before_restore_<время>`.
- В копию входит всё, что нельзя пересоздать: пользователи, история, лайки, состояние плеера, очередь на добавление,
результаты аудиоанализа и модели волны. Поиск, обложки и транскоды восстанавливаются сами.
- **Сама музыка не бэкапится** приложением — это ваш диск или NFS. Каталог бэкапов тоже лежит на сервере:
копируйте `/srv/pipu/backups` на другую машину, например `rsync` по cron.
## 8. Наблюдение
| Что | Как |
|---|---|
| Сервер жив и готов | `curl -s https://music.example.com/readyz` → все зависимости `ok` |
| Состояние сервисов | `docker compose ps` |
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`, `backup`) |
| Место на диске | `df -h /srv/pipu`; больше всего занимают `cache/transcode` (чистится сам) и `backups` |
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
## 9. Частые ситуации
| Ситуация | Что делать |
|---|---|
| Ни один трек не играет («Не удалось воспроизвести трек»), в логе api `no such file or directory` | Диск с музыкой не смонтирован: `findmnt <LIBRARY_HOST>`, `sudo mount -a`. Контейнеры увидят диск сами; скан на пустой папке прерывается с ошибкой «is the disk mounted?» и ничего не помечает пропавшим |
| Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» |
| Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан |
| Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» |
| Волна однообразная | Идёт ли аудиоанализ («Библиотека → Аудиоанализ»); чем больше прослушиваний, тем точнее волна |
| Загрузка застряла в «Обработке» | `docker compose logs api | grep upload`; повторный импорт — «Одобрить» ещё раз |
| Пользователь не может войти | Не заблокирован ли; «Сбросить пароль» |
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
+16 -2
View File
@@ -77,7 +77,7 @@
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
| `TRANSCODE_CONCURRENCY` | `2` | |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | `https://git.home.lan`, `owner/pipu-music`, read-only токен | Для самообновления Android |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `LOG_LEVEL` | `info` | |
@@ -97,7 +97,21 @@
## Бэкапы
- `pg_dump` ежедневно (cron-контейнер `backup`), хранить 14 дней + еженедельные 8 недель → `/srv/pipu/backups`.
- Контейнер `backup` (`deploy/backup.sh loop`): каждый день в `BACKUP_HOUR`:30 UTC (по умолчанию 3:30) —
`pg_dump -Fc` → `/srv/pipu/backups/daily/`, хранится `BACKUP_KEEP_DAILY` = 14 последних; первая копия за неделю
копируется в `weekly/` (`BACKUP_KEEP_WEEKLY` = 8). Рядом с копией — `*.counts`: число строк в каждой таблице.
- Раз в неделю копия **проверяется восстановлением**: разворачивается во временную базу `pipu_verify`, сверяются
таблицы и число строк, база удаляется; время последней успешной проверки — `backups/.last-verify`, ошибки —
в `docker compose logs backup` («ПРОВЕРКА НЕ ПРОЙДЕНА»). Тот же цикл копия → проверка → восстановление
выполняется в CI (`ci / db`) на базе после сквозного сценария.
- `update.sh` перед обновлением делает внеочередную копию.
- Вручную: `docker compose run --rm backup once` — копия сейчас; `docker compose run --rm backup verify /backups/daily/<файл>`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/pipu-….dump` — останавливает api, рекомендации и
анализ, проверяет копию, переименовывает текущую базу в `pipu_before_restore_<время>` (страховка; удалить —
`docker compose exec postgres dropdb -U pipu <имя>`), восстанавливает и запускает сервисы; поиск переиндексируется
при старте api. Redis (сессии волны, очереди) не восстанавливается — пользователи просто начнут новую волну.
- Копии лежат на том же сервере: для защиты от потери диска синхронизируйте `/srv/pipu/backups` на другую машину
(например, `rclone sync` или `rsync` по cron).
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
- Признаки анализа хранятся в 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` | Успех |
+17 -4
View File
@@ -250,8 +250,10 @@ curl -s https://music.example.com/readyz
приложение попросит обновиться.
Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Для этого в `.env` сервера нужно заполнить
`GITEA_URL`, `GITEA_REPO` и `GITEA_TOKEN` (токен только на чтение).
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
ссылка `https://<ваш-домен>/android`.
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
@@ -314,12 +316,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 +350,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` |
+4 -2
View File
@@ -161,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`, без даунтайма).
+2
View File
@@ -82,6 +82,8 @@
## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play).
**Поток:**
+19
View File
@@ -152,6 +152,16 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%.
Пока размеченных показов меньше ~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`. Запускать с машины в той же сети, что и сервер, —
иначе в цифрах будет задержка канала.
+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 выключены, волна замедлена.
+157
View File
@@ -880,6 +880,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/library/analysis/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Вернуть в очередь треки, которые не удалось проанализировать (admin) */
post: operations["retryAnalysis"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/search/reindex": {
parameters: {
query?: never;
@@ -1066,6 +1083,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 +1235,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;
@@ -1435,6 +1509,11 @@ export interface components {
wave_variant?: "orb" | "liquid";
/** @enum {string} */
web_player?: "bar" | "island";
/**
* @description Где в топбаре веба поиск (по умолчанию center)
* @enum {string}
*/
search_position?: "center" | "left" | "right";
/** @enum {string} */
mobile_player?: "cover" | "wave";
ambient?: boolean;
@@ -1955,6 +2034,8 @@ export interface components {
done: number;
failed: number;
analyzer_ver: string | null;
/** @description Самая частая ошибка среди неудавшихся — подсказка, что чинить */
top_error: string | null;
};
/** @description Метрики волны за 7 дней */
WaveMetrics: {
@@ -1964,6 +2045,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 */
@@ -3289,6 +3379,29 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
retryAnalysis: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Сколько задач снова в очереди */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
requeued: number;
};
};
};
default: components["responses"]["Problem"];
};
};
reindexSearch: {
parameters: {
query?: never;
@@ -3599,6 +3712,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)",
+4 -1
View File
@@ -158,7 +158,10 @@
"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",
"analysis_retry": "Retry"
},
"write_template": {
"title": "Uploads and path template",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "At least {{min}} characters",
"submit": "Save and continue",
"logout": "Sign out"
},
"app": {
"link": "Android app",
"title": "pipu for Android",
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
"download": "Download APK",
"version": "version {{version}}",
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
"step_server": "Launch pipu and enter the server address:",
"copy_server": "Copy the server address",
"step_updates": "Sign in with your account. The app offers new versions by itself.",
"checksum": "SHA-256 checksum",
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
"to_login": "Sign in to the web app"
}
}
+21 -3
View File
@@ -39,7 +39,8 @@
"profile": {
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out"
"logout": "Sign out",
"android_app": "Android app"
},
"roles": {
"admin": "Administrator",
@@ -81,7 +82,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",
@@ -113,7 +115,11 @@
"player_island": "Island",
"ambient": "Ambient background",
"on": "On",
"off": "Off"
"off": "Off",
"search_position": "Search position",
"search_left": "Left",
"search_center": "Center",
"search_right": "Right"
},
"form": {
"show_password": "Show password",
@@ -278,5 +284,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"
}
}
+4 -1
View File
@@ -158,7 +158,10 @@
"wave_sessions": "Сессий",
"wave_listen": "Дослушано ≥ 50%",
"wave_early_skip": "Ранние скипы",
"wave_likes": "Лайков на 100"
"wave_likes": "Лайков на 100",
"wave_model": "Версия модели",
"wave_by_model": "Сравнение версий модели волны",
"analysis_retry": "Повторить"
},
"write_template": {
"title": "Приём загрузок и шаблон пути",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "Не короче {{min}} символов",
"submit": "Сохранить и продолжить",
"logout": "Выйти"
},
"app": {
"link": "Приложение для Android",
"title": "pipu для Android",
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
"download": "Скачать APK",
"version": "версия {{version}}",
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"step_server": "Запустите pipu и введите адрес сервера:",
"copy_server": "Скопировать адрес сервера",
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
"checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
"to_login": "Войти в веб-версию"
}
}
+21 -3
View File
@@ -39,7 +39,8 @@
"profile": {
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти"
"logout": "Выйти",
"android_app": "Приложение для Android"
},
"roles": {
"admin": "Администратор",
@@ -81,7 +82,8 @@
"source_wave": "Моя волна",
"source_queue": "Очередь",
"queue": "Очередь",
"source_wave_seed": "Волна по исполнителю · {{label}}"
"source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
},
"settings": {
"title": "Настройки",
@@ -113,7 +115,11 @@
"player_island": "Остров",
"ambient": "Живой фон",
"on": "Вкл.",
"off": "Выкл."
"off": "Выкл.",
"search_position": "Расположение поиска",
"search_left": "Слева",
"search_center": "По центру",
"search_right": "Справа"
},
"form": {
"show_password": "Показать пароль",
@@ -278,5 +284,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),
File diff suppressed because it is too large. Load diff
+3 -1
View File
@@ -100,8 +100,10 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
}
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != ""
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
}
func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
+1
View File
@@ -565,5 +565,6 @@ func TestPublicRoutes(t *testing.T) {
e := newEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
}
+9 -7
View File
@@ -241,13 +241,14 @@ func MeDTO(u User) gen.Me {
func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
SearchPosition: (*gen.UiPrefsSearchPosition)(p.SearchPos),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
@@ -256,6 +257,7 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer),
SearchPos: (*string)(p.SearchPosition),
MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
+4 -1
View File
@@ -55,6 +55,8 @@ var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
"GET /api/v1/app/android/latest": true,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
@@ -90,7 +92,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/"))
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
return
+1
View File
@@ -37,6 +37,7 @@ type UIPrefs struct {
Theme *string `json:"theme,omitempty"`
WaveVariant *string `json:"wave_variant,omitempty"`
WebPlayer *string `json:"web_player,omitempty"`
SearchPos *string `json:"search_position,omitempty"`
MobilePlayer *string `json:"mobile_player,omitempty"`
Ambient *bool `json:"ambient,omitempty"`
CrossfadeS *int `json:"crossfade_s,omitempty"`
+9 -4
View File
@@ -86,10 +86,11 @@ var commonPasswords = func() map[string]struct{} {
}()
var uiPrefEnums = map[string][]string{
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"mobile_player": {"cover", "wave"},
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"search_position": {"center", "left", "right"},
"mobile_player": {"cover", "wave"},
}
// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда.
@@ -107,6 +108,7 @@ func ValidateUIPrefs(p UIPrefs) error {
}
for name, v := range map[string]*string{
"theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer,
"search_position": p.SearchPos,
} {
if err := check(name, v); err != nil {
return err
@@ -130,6 +132,9 @@ func MergeUIPrefs(base, patch UIPrefs) UIPrefs {
if patch.WebPlayer != nil {
base.WebPlayer = patch.WebPlayer
}
if patch.SearchPos != nil {
base.SearchPos = patch.SearchPos
}
if patch.MobilePlayer != nil {
base.MobilePlayer = patch.MobilePlayer
}
@@ -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 {
+17 -2
View File
@@ -292,6 +292,15 @@ func (h *Handler) ScanSource(ctx context.Context, req gen.ScanSourceRequestObjec
return gen.ScanSource202JSONResponse(progressDTO(p)), nil
}
// RetryAnalysis реализует gen.StrictServerInterface.
func (h *Handler) RetryAnalysis(ctx context.Context, _ gen.RetryAnalysisRequestObject) (gen.RetryAnalysisResponseObject, error) {
n, err := h.svc.RetryAnalysis(ctx)
if err != nil {
return nil, err
}
return gen.RetryAnalysis200JSONResponse{Requeued: n}, nil
}
// GetLibraryStatus реализует gen.StrictServerInterface.
func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) {
st, err := h.svc.Status(ctx)
@@ -304,13 +313,19 @@ func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRe
MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{},
Analysis: gen.AnalysisStatus{
Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done,
Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer,
Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, TopError: st.Analysis.TopError,
},
Wave: gen.WaveMetrics{
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 (
+9
View File
@@ -587,6 +587,15 @@ func (s *Service) previewOne(ctx context.Context, root, rel string, patterns []m
}
// StartScan запускает скан источника в фоне.
// RetryAnalysis возвращает в очередь треки, которые анализатор не смог обработать (например, пока диск
// не был смонтирован).
func (s *Service) RetryAnalysis(ctx context.Context) (int, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return 0, err
}
return s.store.RetryFailedAnalysis(ctx)
}
func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Progress{}, err
+63 -12
View File
@@ -605,7 +605,7 @@ func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string
// AnalysisCounts — состояние очереди аудиоанализа.
type AnalysisCounts struct {
Queued, Running, Done, Failed int
AnalyzerVer *string
AnalyzerVer, TopError *string
}
// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора.
@@ -614,20 +614,59 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
err := st.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer)
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
(SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL
GROUP BY error ORDER BY count(*) DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
if err != nil {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
}
return c, nil
}
// RetryFailedAnalysis возвращает неудавшиеся задачи анализа в очередь с нуля попыток.
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
tag, err := st.pool.Exec(ctx, `
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
WHERE status = 'failed'`)
if err != nil {
return 0, fmt.Errorf("library: retry analysis: %w", err)
}
return int(tag.RowsAffected()), nil
}
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
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 +674,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), "без вкуса — порядок релевантности")
}
+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
@@ -20,6 +20,7 @@ log = logging.getLogger(__name__)
MAX_ATTEMPTS = 3
MAX_DURATION_S = 15 * 60
IDLE_SLEEP_S = 10.0
MISSING_SLEEP_S = 60.0 # файлы пропали целиком — ждём, пока смонтируют диск
@dataclass(frozen=True)
@@ -168,6 +169,18 @@ class Worker:
)
conn.commit()
def postpone(self, conn: psycopg.Connection[Any], track_ids: list[str]) -> None:
"""Файла нет на диске (не смонтирован?) — обратно в очередь, попытка не засчитывается."""
conn.execute(
"""
UPDATE analysis_jobs SET status = 'queued', attempts = greatest(attempts - 1, 0),
error = 'file not found — is the disk mounted?', updated_at = now()
WHERE track_id = ANY(%s::uuid[])
""",
(track_ids,),
)
conn.commit()
def requeue_stale(self, conn: psycopg.Connection[Any]) -> None:
"""Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь."""
conn.execute(
@@ -189,6 +202,16 @@ class Worker:
self.requeue_stale(conn)
while True:
jobs = self.claim(conn, self.workers * 2)
missing = [j for j in jobs if not Path(j.path).exists()]
if missing:
self.postpone(conn, [j.track_id for j in missing])
jobs = [j for j in jobs if j not in missing]
if not jobs:
log.warning(
"analysis: %d files not found, waiting — is the disk mounted?", len(missing)
)
time.sleep(MISSING_SLEEP_S)
continue
if not jobs:
time.sleep(IDLE_SLEEP_S)
continue
+1 -1
View File
@@ -30,7 +30,7 @@ def _serve(settings: Settings) -> None:
wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path))
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 });
}