Compare commits
No files matched your search
@@ -4,16 +4,19 @@
|
|||||||
name: ci / db
|
name: ci / db
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- 'services/api/**'
|
- 'services/api/**'
|
||||||
- 'services/recommender/**'
|
- 'services/recommender/**'
|
||||||
|
- 'deploy/backup.sh'
|
||||||
- '.gitea/workflows/ci-db.yml'
|
- '.gitea/workflows/ci-db.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'services/api/**'
|
- 'services/api/**'
|
||||||
- 'services/recommender/**'
|
- 'services/recommender/**'
|
||||||
|
- 'deploy/backup.sh'
|
||||||
- '.gitea/workflows/ci-db.yml'
|
- '.gitea/workflows/ci-db.yml'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -51,6 +54,23 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
|
||||||
|
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
|
||||||
|
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
|
||||||
|
run: |
|
||||||
|
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
|
||||||
|
free=$(df -Pm / | awk 'NR==2 {print $4}')
|
||||||
|
echo "свободно на раннере: ${free} МБ"
|
||||||
|
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
|
||||||
|
for svc in postgres:5432 redis:6379 meilisearch:7700; do
|
||||||
|
host=${svc%:*}; port=${svc#*:}
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
|
||||||
|
echo "$svc готов"; break
|
||||||
|
fi
|
||||||
|
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
done
|
||||||
- name: Go — миграции и запросы store
|
- name: Go — миграции и запросы store
|
||||||
working-directory: services/api
|
working-directory: services/api
|
||||||
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
run: go test -tags integration -count=1 -v ./internal/dbtest/
|
||||||
@@ -64,3 +84,26 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
uv sync --frozen
|
uv sync --frozen
|
||||||
uv run pytest -q tests/test_db_integration.py
|
uv run pytest -q tests/test_db_integration.py
|
||||||
|
# База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление.
|
||||||
|
- name: Бэкап, проверка и восстановление (deploy/backup.sh)
|
||||||
|
env:
|
||||||
|
PGHOST: postgres
|
||||||
|
PGUSER: pipu
|
||||||
|
PGPASSWORD: pipu
|
||||||
|
PGDATABASE: pipu
|
||||||
|
BACKUP_DIR: /tmp/backups
|
||||||
|
VERIFY_EXACT: "1"
|
||||||
|
run: |
|
||||||
|
SUDO=$(command -v sudo || true)
|
||||||
|
$SUDO install -d /usr/share/postgresql-common/pgdg
|
||||||
|
$SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
|
||||||
|
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null
|
||||||
|
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null
|
||||||
|
export PATH=/usr/lib/postgresql/17/bin:$PATH
|
||||||
|
before=$(psql -Atc "SELECT count(*) FROM tracks")
|
||||||
|
file=$(bash deploy/backup.sh once)
|
||||||
|
bash deploy/backup.sh verify "$file"
|
||||||
|
bash deploy/backup.sh restore "$file"
|
||||||
|
after=$(psql -Atc "SELECT count(*) FROM tracks")
|
||||||
|
echo "tracks: $before → $after"
|
||||||
|
test "$before" = "$after"
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
name: ci / mobile
|
name: ci / mobile
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ jobs:
|
|||||||
android:
|
android:
|
||||||
name: android (APK в релиз)
|
name: android (APK в релиз)
|
||||||
needs: release
|
needs: release
|
||||||
|
# Обычно 10–15 минут; дольше — значит, сборка упёрлась в память, лучше упасть, чем держать раннер.
|
||||||
|
timeout-minutes: 60
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
# Постоянные кэши на хосте раннера: дистрибутив и зависимости Gradle, его build cache, Android SDK и NDK
|
||||||
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
# переживают job (−4 мин на релиз). Работают, если в config.yaml раннера разрешены тома
|
||||||
@@ -60,7 +62,7 @@ jobs:
|
|||||||
image: docker.gitea.com/runner-images:ubuntu-latest
|
image: docker.gitea.com/runner-images:ubuntu-latest
|
||||||
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
|
# Жёсткие пределы контейнера: сборка не должна забирать всю VM раннера (а через неё — хост гипервизора).
|
||||||
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
|
# Своп запрещён (memory-swap = memory): лучше OOM внутри контейнера, чем подвисший хост.
|
||||||
options: --cpus=3 --memory=6g --memory-swap=6g
|
options: --cpus=3 --memory=7g --memory-swap=7g
|
||||||
volumes:
|
volumes:
|
||||||
- /opt/ci-cache/gradle:/root/.gradle
|
- /opt/ci-cache/gradle:/root/.gradle
|
||||||
- /opt/ci-cache/android:/root/.android
|
- /opt/ci-cache/android:/root/.android
|
||||||
@@ -106,6 +108,8 @@ jobs:
|
|||||||
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
|
# CMake / ninja нативной части React Native по умолчанию берут все ядра — ограничиваем.
|
||||||
CMAKE_BUILD_PARALLEL_LEVEL: "2"
|
CMAKE_BUILD_PARALLEL_LEVEL: "2"
|
||||||
NINJAFLAGS: "-j2"
|
NINJAFLAGS: "-j2"
|
||||||
|
# JS-бандл (Metro) собирается внутри Gradle отдельным node — не даём ему разрастись.
|
||||||
|
NODE_OPTIONS: "--max-old-space-size=1536"
|
||||||
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
# Только arm64-v8a: C++-часть React Native собирается под каждую ABI, и четыре ABI
|
||||||
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
# вместе с NDK (~2.5 ГБ) не помещаются на диск раннера. arm64 — все устройства с 2017+.
|
||||||
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
# Память: на раннере 8 ГБ, и без ограничений Gradle + демон Kotlin + Metro вызывают OOM killer.
|
||||||
@@ -120,7 +124,7 @@ jobs:
|
|||||||
cd android
|
cd android
|
||||||
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
|
# nice / ionice: сборка уступает CPU и диск всему остальному на машине
|
||||||
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
gradle=(nice -n 19 ionice -c 3 ./gradlew --max-workers=2 --build-cache -PreactNativeArchitectures=arm64-v8a
|
||||||
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2560m -XX:MaxMetaspaceSize=512m")
|
-Pkotlin.compiler.execution.strategy=in-process -Dorg.gradle.jvmargs="-Xmx2048m -XX:MaxMetaspaceSize=512m")
|
||||||
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
|
# Без New Architecture (app.config.ts) CMake приложения не собирается — отдельный codegen не нужен.
|
||||||
if grep -q '^newArchEnabled=true' gradle.properties; then
|
if grep -q '^newArchEnabled=true' gradle.properties; then
|
||||||
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
"${gradle[@]}" generateCodegenArtifactsFromSchema
|
||||||
|
|||||||
+133
-2
@@ -1006,6 +1006,24 @@ paths:
|
|||||||
$ref: "#/components/schemas/SearchResults"
|
$ref: "#/components/schemas/SearchResults"
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/admin/library/analysis/retry:
|
||||||
|
post:
|
||||||
|
operationId: retryAnalysis
|
||||||
|
tags: [library]
|
||||||
|
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Сколько задач снова в очереди
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [requeued]
|
||||||
|
properties:
|
||||||
|
requeued:
|
||||||
|
type: integer
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/admin/search/reindex:
|
/api/v1/admin/search/reindex:
|
||||||
post:
|
post:
|
||||||
operationId: reindexSearch
|
operationId: reindexSearch
|
||||||
@@ -1256,6 +1274,38 @@ paths:
|
|||||||
description: Снято
|
description: Снято
|
||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/Problem"
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/onboarding:
|
||||||
|
get:
|
||||||
|
operationId: getOnboarding
|
||||||
|
tags: [wave]
|
||||||
|
summary: Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
||||||
|
description: |
|
||||||
|
`required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Варианты и состояние
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Onboarding"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
|
/api/v1/me/onboarding:
|
||||||
|
put:
|
||||||
|
operationId: saveOnboarding
|
||||||
|
tags: [wave]
|
||||||
|
summary: Сохранить выбор онбординга (или пропуск — пустые списки)
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/OnboardingChoice"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Сохранено
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/Problem"
|
||||||
/api/v1/wave/options:
|
/api/v1/wave/options:
|
||||||
get:
|
get:
|
||||||
operationId: getWaveOptions
|
operationId: getWaveOptions
|
||||||
@@ -1347,6 +1397,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: getAndroidLatest
|
operationId: getAndroidLatest
|
||||||
tags: [app]
|
tags: [app]
|
||||||
|
security: [] # публично: страница /android раздаёт приложение без входа
|
||||||
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
|
||||||
description: |
|
description: |
|
||||||
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
|
||||||
@@ -1364,6 +1415,7 @@ paths:
|
|||||||
get:
|
get:
|
||||||
operationId: downloadAndroidApk
|
operationId: downloadAndroidApk
|
||||||
tags: [app]
|
tags: [app]
|
||||||
|
security: []
|
||||||
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
|
||||||
parameters:
|
parameters:
|
||||||
- name: version
|
- name: version
|
||||||
@@ -1442,6 +1494,59 @@ components:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Problem"
|
$ref: "#/components/schemas/Problem"
|
||||||
schemas:
|
schemas:
|
||||||
|
Onboarding:
|
||||||
|
type: object
|
||||||
|
required: [required, artists, genres]
|
||||||
|
properties:
|
||||||
|
required:
|
||||||
|
type: boolean
|
||||||
|
artists:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [id, name, track_count, cover_url, cover_colors]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
track_count:
|
||||||
|
type: integer
|
||||||
|
cover_url:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Обложка самого большого альбома исполнителя
|
||||||
|
cover_colors:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
genres:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
required: [name, track_count]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
track_count:
|
||||||
|
type: integer
|
||||||
|
OnboardingChoice:
|
||||||
|
type: object
|
||||||
|
required: [artist_ids, genres]
|
||||||
|
properties:
|
||||||
|
artist_ids:
|
||||||
|
type: array
|
||||||
|
maxItems: 50
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
genres:
|
||||||
|
type: array
|
||||||
|
maxItems: 30
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
maxLength: 100
|
||||||
UserRef:
|
UserRef:
|
||||||
type: object
|
type: object
|
||||||
required: [id, username]
|
required: [id, username]
|
||||||
@@ -2996,7 +3101,7 @@ components:
|
|||||||
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
|
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
|
||||||
AnalysisStatus:
|
AnalysisStatus:
|
||||||
type: object
|
type: object
|
||||||
required: [queued, running, done, failed, analyzer_ver]
|
required: [queued, running, done, failed, analyzer_ver, top_error]
|
||||||
properties:
|
properties:
|
||||||
queued:
|
queued:
|
||||||
type: integer
|
type: integer
|
||||||
@@ -3009,9 +3114,13 @@ components:
|
|||||||
analyzer_ver:
|
analyzer_ver:
|
||||||
type: string
|
type: string
|
||||||
nullable: true
|
nullable: true
|
||||||
|
top_error:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
|
||||||
WaveMetrics:
|
WaveMetrics:
|
||||||
type: object
|
type: object
|
||||||
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions]
|
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
|
||||||
description: Метрики волны за 7 дней
|
description: Метрики волны за 7 дней
|
||||||
properties:
|
properties:
|
||||||
served:
|
served:
|
||||||
@@ -3028,6 +3137,28 @@ components:
|
|||||||
description: Лайков на 100 треков
|
description: Лайков на 100 треков
|
||||||
sessions:
|
sessions:
|
||||||
type: integer
|
type: integer
|
||||||
|
by_model:
|
||||||
|
type: array
|
||||||
|
description: Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/WaveModelMetrics'
|
||||||
|
WaveModelMetrics:
|
||||||
|
type: object
|
||||||
|
required: [model_ver, served, listen_through_rate, early_skip_rate, like_rate]
|
||||||
|
properties:
|
||||||
|
model_ver:
|
||||||
|
type: string
|
||||||
|
served:
|
||||||
|
type: integer
|
||||||
|
listen_through_rate:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
early_skip_rate:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
|
like_rate:
|
||||||
|
type: number
|
||||||
|
nullable: true
|
||||||
AndroidRelease:
|
AndroidRelease:
|
||||||
type: object
|
type: object
|
||||||
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function Chips({
|
|||||||
height: 38,
|
height: 38,
|
||||||
paddingHorizontal: 14,
|
paddingHorizontal: 14,
|
||||||
borderRadius: 999,
|
borderRadius: 999,
|
||||||
backgroundColor: on ? colors.accent : colors.surface2,
|
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: on ? colors.accent : colors.line,
|
borderColor: on ? colors.accent : colors.line,
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ class Controller {
|
|||||||
private lastSave = 0;
|
private lastSave = 0;
|
||||||
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
private waveFeedback: { track_id: string; outcome: WaveOutcome }[] = [];
|
||||||
private waveLoading = false;
|
private waveLoading = false;
|
||||||
|
private neutralSwitch = false; // следующая смена трека — из-за нового контекста волны
|
||||||
private setupDone: Promise<void> | null = null;
|
private setupDone: Promise<void> | null = null;
|
||||||
private listeners = new Set<() => void>();
|
private listeners = new Set<() => void>();
|
||||||
private state: PlayerState = {
|
private state: PlayerState = {
|
||||||
@@ -189,6 +190,14 @@ class Controller {
|
|||||||
if (!a) return;
|
if (!a) return;
|
||||||
this.attempt = null;
|
this.attempt = null;
|
||||||
const pos = Math.round(lastPositionS * 1000);
|
const pos = Math.round(lastPositionS * 1000);
|
||||||
|
if (this.neutralSwitch) {
|
||||||
|
this.neutralSwitch = false;
|
||||||
|
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||||
|
this.events?.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events?.flush();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const kind = ended ? 'play_end' : a.outcome(pos);
|
const kind = ended ? 'play_end' : a.outcome(pos);
|
||||||
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
this.events?.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
void this.events?.flush();
|
void this.events?.flush();
|
||||||
@@ -316,6 +325,13 @@ class Controller {
|
|||||||
);
|
);
|
||||||
await this.replaceUpcoming(res.tracks);
|
await this.replaceUpcoming(res.tracks);
|
||||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
|
// Новый контекст звучит сразу; прерванный трек — не скип для волны (как в вебе).
|
||||||
|
if (res.tracks.some(playable)) {
|
||||||
|
this.neutralSwitch = true;
|
||||||
|
await TrackPlayer.skipToNext().catch(() => {
|
||||||
|
this.neutralSwitch = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private leaveWave() {
|
private leaveWave() {
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export function Button({
|
|||||||
}) {
|
}) {
|
||||||
const s = useStyles();
|
const s = useStyles();
|
||||||
const colors = useColors();
|
const colors = useColors();
|
||||||
const bg = { primary: colors.accent, secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
|
const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
|
||||||
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
|
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -282,7 +282,7 @@ export function Segmented<T extends string>({
|
|||||||
onPress={() => onChange(o.value)}
|
onPress={() => onChange(o.value)}
|
||||||
accessibilityRole="tab"
|
accessibilityRole="tab"
|
||||||
accessibilityState={{ selected: value === o.value }}
|
accessibilityState={{ selected: value === o.value }}
|
||||||
style={[s.segment, value === o.value && { backgroundColor: colors.accent }]}
|
style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
|
||||||
>
|
>
|
||||||
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
|
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|||||||
@@ -250,7 +250,7 @@ describe('player', () => {
|
|||||||
const alice = makeUser('alice', 'user');
|
const alice = makeUser('alice', 'user');
|
||||||
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
|
||||||
|
|
||||||
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
|
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
|
||||||
await user.click(row);
|
await user.click(row);
|
||||||
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
|
||||||
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
|
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { AlbumPage } from '../pages/AlbumPage';
|
|||||||
import { ArtistPage } from '../pages/ArtistPage';
|
import { ArtistPage } from '../pages/ArtistPage';
|
||||||
import { CollectionPage } from '../pages/CollectionPage';
|
import { CollectionPage } from '../pages/CollectionPage';
|
||||||
import { HomePage } from '../pages/HomePage';
|
import { HomePage } from '../pages/HomePage';
|
||||||
|
import { AppDownloadPage } from '../pages/AppDownloadPage';
|
||||||
import { LoginPage } from '../pages/LoginPage';
|
import { LoginPage } from '../pages/LoginPage';
|
||||||
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
import { SearchPage, type SearchType } from '../pages/SearchPage';
|
||||||
import { SettingsPage } from '../pages/SettingsPage';
|
import { SettingsPage } from '../pages/SettingsPage';
|
||||||
@@ -59,6 +60,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
|
|||||||
errorComponent: RouteError,
|
errorComponent: RouteError,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
|
||||||
|
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
|
||||||
|
|
||||||
const loginRoute = createRoute({
|
const loginRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/login',
|
path: '/login',
|
||||||
@@ -233,6 +237,7 @@ const adminSettingsRoute = createRoute({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
|
androidAppRoute,
|
||||||
loginRoute,
|
loginRoute,
|
||||||
setupAdminRoute,
|
setupAdminRoute,
|
||||||
setupPasswordRoute,
|
setupPasswordRoute,
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ export function TrackList({
|
|||||||
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
|
||||||
let lastDisc: number | null = null;
|
let lastDisc: number | null = null;
|
||||||
return (
|
return (
|
||||||
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
|
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
|
||||||
{tracks.map((tr, i) => {
|
{tracks.map((tr, i) => {
|
||||||
const disc = tr.disc_no ?? 1;
|
const disc = tr.disc_no ?? 1;
|
||||||
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
const header = numbered && discs.size > 1 && disc !== lastDisc;
|
||||||
@@ -145,7 +145,7 @@ export function TrackList({
|
|||||||
<Fragment key={`${tr.id}-${i}`}>
|
<Fragment key={`${tr.id}-${i}`}>
|
||||||
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
|
||||||
<div
|
<div
|
||||||
role="row"
|
role="listitem"
|
||||||
tabIndex={tr.available ? 0 : -1}
|
tabIndex={tr.available ? 0 : -1}
|
||||||
aria-current={isCurrent || undefined}
|
aria-current={isCurrent || undefined}
|
||||||
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import { cx, FormError, Icon } from '../shared/ui/ui';
|
import { cx, FormError, Icon } from '../shared/ui/ui';
|
||||||
import { WaveButton } from '../widgets/wave/WaveButton';
|
import { WaveButton } from '../widgets/wave/WaveButton';
|
||||||
import s from './home.module.css';
|
import s from './home.module.css';
|
||||||
|
import { Onboarding } from './Onboarding';
|
||||||
|
|
||||||
/** Иконки Material Symbols по именам из /wave/options. */
|
/** Иконки Material Symbols по именам из /wave/options. */
|
||||||
const ICONS: Record<string, string> = {
|
const ICONS: Record<string, string> = {
|
||||||
@@ -44,8 +45,28 @@ const WAVE_COLORS: [string, string, string] = ['#2F80FF', '#7C5CFF', '#35D3FF'];
|
|||||||
|
|
||||||
type Tr = (key: string) => string;
|
type Tr = (key: string) => string;
|
||||||
|
|
||||||
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
|
/** Главная: для нового пользователя без истории — сначала онбординг вкуса, затем волна. */
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
|
const session = useSession();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const onboarding = useQuery({
|
||||||
|
queryKey: ['onboarding'],
|
||||||
|
queryFn: async () => unwrap(await session.client.GET('/api/v1/onboarding')),
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
if (onboarding.data?.required) {
|
||||||
|
return (
|
||||||
|
<Onboarding
|
||||||
|
options={onboarding.data}
|
||||||
|
onDone={() => queryClient.setQueryData(['onboarding'], { ...onboarding.data, required: false })}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <WaveHome />;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Главная (раскладка Center): кнопка «Моя волна» и под ней выбор занятия и настроения. */
|
||||||
|
function WaveHome() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tr = t as unknown as Tr;
|
const tr = t as unknown as Tr;
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
|
|||||||
@@ -37,7 +37,15 @@ export function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
|
<AuthLayout
|
||||||
|
footer={
|
||||||
|
info.data ? (
|
||||||
|
<>
|
||||||
|
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
|
||||||
|
</>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<h1>{t('login.title')}</h1>
|
<h1>{t('login.title')}</h1>
|
||||||
<p className={s.lead}>{t('login.subtitle')}</p>
|
<p className={s.lead}>{t('login.subtitle')}</p>
|
||||||
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useId } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession, useUser } from '../../app/session';
|
import { useSession, useUser } from '../../app/session';
|
||||||
@@ -31,6 +32,11 @@ export function LibraryPage() {
|
|||||||
const reindex = useMutation({
|
const reindex = useMutation({
|
||||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
|
||||||
});
|
});
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const retry = useMutation({
|
||||||
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
|
||||||
|
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
|
||||||
|
});
|
||||||
const d = status.data;
|
const d = status.data;
|
||||||
const tiles = d
|
const tiles = d
|
||||||
? ([
|
? ([
|
||||||
@@ -76,7 +82,19 @@ export function LibraryPage() {
|
|||||||
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
|
{d.analysis.failed > 0 && (
|
||||||
|
<div className={l.retryRow}>
|
||||||
|
<span className={l.scanError}>
|
||||||
|
{t('library.analysis_failed', { count: d.analysis.failed })}
|
||||||
|
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
|
||||||
|
</span>
|
||||||
|
{admin && (
|
||||||
|
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
|
||||||
|
{t('library.analysis_retry')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
@@ -99,6 +117,33 @@ export function LibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{d.wave.by_model.length > 1 && (
|
||||||
|
<div className={s.tableWrap}>
|
||||||
|
<table className={s.table}>
|
||||||
|
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{t('library.wave_model')}</th>
|
||||||
|
<th scope="col">{t('library.wave_served')}</th>
|
||||||
|
<th scope="col">{t('library.wave_listen')}</th>
|
||||||
|
<th scope="col">{t('library.wave_early_skip')}</th>
|
||||||
|
<th scope="col">{t('library.wave_likes')}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{d.wave.by_model.map((m) => (
|
||||||
|
<tr key={m.model_ver}>
|
||||||
|
<td>{m.model_ver || '—'}</td>
|
||||||
|
<td>{m.served.toLocaleString()}</td>
|
||||||
|
<td>{pct(m.listen_through_rate)}</td>
|
||||||
|
<td>{pct(m.early_skip_rate)}</td>
|
||||||
|
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{d && d.sources.length > 0 && (
|
{d && d.sources.length > 0 && (
|
||||||
@@ -133,10 +178,13 @@ const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%
|
|||||||
|
|
||||||
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
|
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
|
||||||
const value = total > 0 ? Math.round((done / total) * 100) : 0;
|
const value = total > 0 ? Math.round((done / total) * 100) : 0;
|
||||||
|
const id = useId();
|
||||||
return (
|
return (
|
||||||
<div className={l.scan}>
|
<div className={l.scan}>
|
||||||
<div className={l.scanText}>{label}</div>
|
<div className={l.scanText} id={id}>
|
||||||
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
|
{label}
|
||||||
|
</div>
|
||||||
|
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
|
||||||
<span style={{ width: `${value}%` }} />
|
<span style={{ width: `${value}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
|
import { useId } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { formatDateTime } from '../../shared/lib/format';
|
import { formatDateTime } from '../../shared/lib/format';
|
||||||
@@ -16,14 +17,15 @@ export function StatusBadge({ status }: { status: SourceStatus }) {
|
|||||||
/** Прогресс идущего скана или итог последнего. */
|
/** Прогресс идущего скана или итог последнего. */
|
||||||
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
|
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
|
||||||
const { t, i18n } = useTranslation('admin');
|
const { t, i18n } = useTranslation('admin');
|
||||||
|
const id = useId();
|
||||||
if (scan?.running) {
|
if (scan?.running) {
|
||||||
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
|
||||||
return (
|
return (
|
||||||
<div className={s.scan}>
|
<div className={s.scan}>
|
||||||
<div className={s.scanText}>
|
<div className={s.scanText} id={id}>
|
||||||
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
|
||||||
</div>
|
</div>
|
||||||
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
|
||||||
<span style={{ width: `${pct}%` }} />
|
<span style={{ width: `${pct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,6 +118,20 @@
|
|||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.retryRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorDetail {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--text3);
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
height: 6px;
|
height: 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
|
|||||||
@@ -161,6 +161,83 @@
|
|||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer a {
|
||||||
|
color: var(--text2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Страница Android-приложения (/android). */
|
||||||
|
.download {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--accent-fill);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
box-shadow: var(--glow);
|
||||||
|
transition:
|
||||||
|
background 0.2s,
|
||||||
|
transform 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download:hover {
|
||||||
|
background: var(--accent-fill2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.download:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadMeta {
|
||||||
|
display: block;
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.server {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: 6px;
|
||||||
|
padding: 2px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--line2);
|
||||||
|
background: var(--surface2);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details summary {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details code {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
word-break: break-all;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
}
|
||||||
|
|
||||||
.link {
|
.link {
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
|
|
||||||
.chipOn,
|
.chipOn,
|
||||||
.chipOn:hover {
|
.chipOn:hover {
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: var(--glow);
|
box-shadow: var(--glow);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { components } from '@pipu/api-client';
|
import type { components } from '@pipu/api-client';
|
||||||
import { unwrap } from '@pipu/core';
|
import { unwrap } from '@pipu/core';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useId } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { useSession } from '../../app/session';
|
import { useSession } from '../../app/session';
|
||||||
@@ -47,18 +48,19 @@ export function useUploadQuota() {
|
|||||||
export function QuotaMeter() {
|
export function QuotaMeter() {
|
||||||
const { t, i18n } = useTranslation('admin');
|
const { t, i18n } = useTranslation('admin');
|
||||||
const quota = useUploadQuota();
|
const quota = useUploadQuota();
|
||||||
|
const id = useId();
|
||||||
if (!quota.data) return null;
|
if (!quota.data) return null;
|
||||||
const q = quota.data;
|
const q = quota.data;
|
||||||
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
|
||||||
const fmt = (n: number) => formatBytes(n, i18n.language);
|
const fmt = (n: number) => formatBytes(n, i18n.language);
|
||||||
return (
|
return (
|
||||||
<div className={u.quota}>
|
<div className={u.quota}>
|
||||||
<span className={u.quotaText}>
|
<span className={u.quotaText} id={id}>
|
||||||
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
|
||||||
{q.per_user_limit_bytes !== null &&
|
{q.per_user_limit_bytes !== null &&
|
||||||
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
|
||||||
</span>
|
</span>
|
||||||
<div className={u.progress} role="progressbar" aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
|
||||||
<span style={{ width: `${ratio * 100}%` }} />
|
<span style={{ width: `${ratio * 100}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -186,7 +186,10 @@ export class Player {
|
|||||||
void this.load(true);
|
void this.load(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Сменить занятие / настроение на лету: текущий трек доигрывает, дальше — новая пачка. */
|
/**
|
||||||
|
* Сменить занятие / настроение на лету: сразу играет первый трек новой пачки (docs/recommendations.md#6.7).
|
||||||
|
* Прерванный трек — не скип: волне сигнал не уходит, в историю он попадает, только если играл ≥ 30 с.
|
||||||
|
*/
|
||||||
async setWaveContext(activity: string | null, mood: string | null) {
|
async setWaveContext(activity: string | null, mood: string | null) {
|
||||||
const wave = this.state.wave;
|
const wave = this.state.wave;
|
||||||
if (!wave) return this.startWave(activity, mood);
|
if (!wave) return this.startWave(activity, mood);
|
||||||
@@ -197,10 +200,17 @@ export class Player {
|
|||||||
body: { activity, mood },
|
body: { activity, mood },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const fresh = res.tracks.filter((t) => t.available && t.stream_url);
|
||||||
this.queue.dropUpcoming();
|
this.queue.dropUpcoming();
|
||||||
this.queue.append(res.tracks.filter((t) => t.available && t.stream_url));
|
this.queue.append(fresh);
|
||||||
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
this.update({ wave: { ...wave, activity: res.activity, mood: res.mood, fallback: res.fallback } });
|
||||||
this.syncQueue();
|
if (fresh.length && this.queue.next()) {
|
||||||
|
this.finishAttempt('skip', true);
|
||||||
|
this.syncQueue();
|
||||||
|
void this.load(true);
|
||||||
|
} else {
|
||||||
|
this.syncQueue();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stopWave() {
|
stopWave() {
|
||||||
@@ -395,11 +405,19 @@ export class Player {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Завершает текущую попытку: событие с реально прослушанным временем. */
|
/** Завершает текущую попытку: событие с реально прослушанным временем. */
|
||||||
private finishAttempt(type: 'play_end' | 'skip') {
|
/** neutral — трек прерван сменой контекста волны: не отрицательный сигнал и без короткого «скипа» в событиях. */
|
||||||
|
private finishAttempt(type: 'play_end' | 'skip', neutral = false) {
|
||||||
const a = this.attempt;
|
const a = this.attempt;
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
this.attempt = null;
|
this.attempt = null;
|
||||||
const pos = Math.round(this.audio.currentTime * 1000);
|
const pos = Math.round(this.audio.currentTime * 1000);
|
||||||
|
if (neutral) {
|
||||||
|
if (a.playedMs >= EARLY_SKIP_MS) {
|
||||||
|
this.events.push({ type: 'skip', track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
|
void this.events.flush();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
|
const kind = type === 'skip' ? a.outcome(pos) : 'play_end';
|
||||||
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
this.events.push({ type: kind, track_id: a.track.id, position_ms: pos, played_ms: Math.round(a.playedMs) });
|
||||||
void this.events.flush();
|
void this.events.flush();
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.sv
|
|||||||
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
|
||||||
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
|
||||||
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
|
||||||
|
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
|
||||||
|
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
|
||||||
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
|
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
|
||||||
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
|
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
|
||||||
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
|
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
|
||||||
|
|||||||
@@ -53,13 +53,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.primary {
|
.primary {
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
box-shadow: var(--glow);
|
box-shadow: var(--glow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.primary:hover:not(:disabled) {
|
.primary:hover:not(:disabled) {
|
||||||
background: var(--accent2);
|
background: var(--accent-fill2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.secondary {
|
.secondary {
|
||||||
@@ -186,7 +186,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.segmented button[aria-pressed='true'] {
|
.segmented button[aria-pressed='true'] {
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,14 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Выезжающие справа панели (очередь, docs/design.md#4). */
|
||||||
|
@keyframes pipuSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -27,8 +27,10 @@
|
|||||||
padding-bottom: 132px;
|
padding-bottom: 132px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Над контентом: выпадающие подсказки поиска и меню профиля перекрывают страницу, а не уходят под неё. */
|
||||||
.top {
|
.top {
|
||||||
grid-area: top;
|
grid-area: top;
|
||||||
|
z-index: 30;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
@@ -155,7 +157,7 @@
|
|||||||
height: 20px;
|
height: 20px;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
background: var(--accent);
|
background: var(--accent-fill);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
dislikeFillIcon,
|
dislikeFillIcon,
|
||||||
dislikeIcon,
|
dislikeIcon,
|
||||||
settingsIcon,
|
settingsIcon,
|
||||||
|
androidIcon,
|
||||||
storageFillIcon,
|
storageFillIcon,
|
||||||
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
|
||||||
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
|
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
|
||||||
@@ -102,8 +103,8 @@ function Shell() {
|
|||||||
<Link to="/" className={s.logoLink}>
|
<Link to="/" className={s.logoLink}>
|
||||||
<Logo />
|
<Logo />
|
||||||
</Link>
|
</Link>
|
||||||
<SearchBox />
|
|
||||||
<div className={s.topRight}>
|
<div className={s.topRight}>
|
||||||
|
<SearchBox />
|
||||||
<Menu
|
<Menu
|
||||||
label={t('profile.menu')}
|
label={t('profile.menu')}
|
||||||
triggerClassName={s.profileButton}
|
triggerClassName={s.profileButton}
|
||||||
@@ -122,6 +123,7 @@ function Shell() {
|
|||||||
}
|
}
|
||||||
items={[
|
items={[
|
||||||
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
|
||||||
|
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
|
||||||
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -246,7 +246,15 @@ a.source:hover {
|
|||||||
background: var(--glass);
|
background: var(--glass);
|
||||||
backdrop-filter: blur(28px) saturate(1.3);
|
backdrop-filter: blur(28px) saturate(1.3);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
animation: pipuIn 0.2s ease-out;
|
animation: pipuSlideIn 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawerNote {
|
||||||
|
margin: 0;
|
||||||
|
padding: 12px 18px 16px;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
color: var(--text3);
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawerIsland {
|
.drawerIsland {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, type CSSProperties } from 'react';
|
import { useCallback, useEffect, useId, useRef, useState, type CSSProperties } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -35,6 +35,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const [queueOpen, setQueueOpen] = useState(false);
|
const [queueOpen, setQueueOpen] = useState(false);
|
||||||
|
const queueButton = useRef<HTMLButtonElement>(null);
|
||||||
|
const queueId = useId();
|
||||||
|
const closeQueue = useCallback(() => {
|
||||||
|
setQueueOpen(false);
|
||||||
|
queueButton.current?.focus(); // фокус — туда, откуда открыли
|
||||||
|
}, []);
|
||||||
const track = st.track;
|
const track = st.track;
|
||||||
|
|
||||||
if (!track) {
|
if (!track) {
|
||||||
@@ -167,10 +173,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
|
ref={queueButton}
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(s.icon, queueOpen && s.accent)}
|
className={cx(s.icon, queueOpen && s.accent)}
|
||||||
aria-label={t('player.queue')}
|
aria-label={t('player.queue')}
|
||||||
aria-expanded={queueOpen}
|
aria-expanded={queueOpen}
|
||||||
|
aria-controls={queueOpen ? queueId : undefined}
|
||||||
onClick={() => setQueueOpen((v) => !v)}
|
onClick={() => setQueueOpen((v) => !v)}
|
||||||
>
|
>
|
||||||
<Icon src={queueIcon} size={20} />
|
<Icon src={queueIcon} size={20} />
|
||||||
@@ -195,23 +203,33 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
|
|||||||
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />}
|
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
|
||||||
function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose: () => void }) {
|
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const player = usePlayer();
|
const player = usePlayer();
|
||||||
const st = usePlayerState();
|
const st = usePlayerState();
|
||||||
const upcoming = st.queue.slice(st.index, st.index + 40);
|
const upcoming = st.queue.slice(st.index, st.index + 40);
|
||||||
|
const close = useRef<HTMLButtonElement>(null);
|
||||||
|
// Клавиатура: при открытии фокус в панель, Escape закрывает.
|
||||||
|
useEffect(() => {
|
||||||
|
close.current?.focus();
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => document.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<aside className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
|
||||||
<div className={s.drawerHead}>
|
<div className={s.drawerHead}>
|
||||||
<span>{t('player.queue')}</span>
|
<span>{t('player.queue')}</span>
|
||||||
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
|
||||||
<Icon src={closeIcon} size={20} />
|
<Icon src={closeIcon} size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,6 +239,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
|
|||||||
key={`${tr.id}-${k}`}
|
key={`${tr.id}-${k}`}
|
||||||
type="button"
|
type="button"
|
||||||
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
|
||||||
|
aria-current={k === 0 || undefined}
|
||||||
onClick={() => player.jump(st.index + k)}
|
onClick={() => player.jump(st.index + k)}
|
||||||
>
|
>
|
||||||
<Cover album={tr.album} size={40} />
|
<Cover album={tr.album} size={40} />
|
||||||
@@ -232,6 +251,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
|
||||||
</aside>,
|
</aside>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
.box {
|
.box {
|
||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: none;
|
||||||
max-width: 520px;
|
width: 40px;
|
||||||
|
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded {
|
||||||
|
width: min(520px, 46vw);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 14px;
|
left: 11px;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
color: var(--text3);
|
color: var(--text3);
|
||||||
@@ -16,13 +21,36 @@
|
|||||||
.input {
|
.input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
padding: 0 40px 0 42px;
|
padding: 0 40px 0 40px;
|
||||||
border-radius: var(--radius-pill);
|
border-radius: var(--radius-pill);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
transition:
|
||||||
|
border-color 0.2s,
|
||||||
|
box-shadow 0.2s,
|
||||||
|
background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Свёрнутое поле — круглая кнопка: без подсказки и курсора ввода. */
|
||||||
|
.box:not(.expanded) .input {
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box:not(.expanded) .input::placeholder {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.box:not(.expanded) .input:hover {
|
||||||
|
background: var(--surface3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.box:not(.expanded) .kbd {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
@@ -56,8 +84,8 @@
|
|||||||
|
|
||||||
.popover {
|
.popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
|
||||||
right: 0;
|
right: 0;
|
||||||
|
width: min(520px, calc(100vw - 32px));
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
z-index: 45;
|
z-index: 45;
|
||||||
max-height: min(70vh, 560px);
|
max-height: min(70vh, 560px);
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ type Item = {
|
|||||||
go: () => void;
|
go: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
|
/**
|
||||||
|
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
|
||||||
|
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
|
||||||
|
*/
|
||||||
export function SearchBox() {
|
export function SearchBox() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const session = useSession();
|
const session = useSession();
|
||||||
@@ -34,6 +37,9 @@ export function SearchBox() {
|
|||||||
const [debounced, setDebounced] = useState('');
|
const [debounced, setDebounced] = useState('');
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [active, setActive] = useState(-1);
|
const [active, setActive] = useState(-1);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
|
||||||
|
const expanded = focused || q !== '';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
||||||
@@ -141,7 +147,7 @@ export function SearchBox() {
|
|||||||
const optionId = (i: number) => `${listId}-${i}`;
|
const optionId = (i: number) => `${listId}-${i}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.box}>
|
<div className={cx(s.box, expanded && s.expanded)}>
|
||||||
<Icon src={searchIcon} size={18} className={s.icon} />
|
<Icon src={searchIcon} size={18} className={s.icon} />
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -160,8 +166,14 @@ export function SearchBox() {
|
|||||||
setOpen(true);
|
setOpen(true);
|
||||||
setActive(-1);
|
setActive(-1);
|
||||||
}}
|
}}
|
||||||
onFocus={() => setOpen(true)}
|
onFocus={() => {
|
||||||
onBlur={() => setTimeout(close, 120)}
|
setFocused(true);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
setFocused(false);
|
||||||
|
setTimeout(close, 120);
|
||||||
|
}}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
/>
|
/>
|
||||||
<kbd className={s.kbd} aria-hidden>
|
<kbd className={s.kbd} aria-hidden>
|
||||||
|
|||||||
Executable
+139
@@ -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
|
||||||
@@ -20,11 +20,13 @@ x-library-ro: &library-ro
|
|||||||
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
|
||||||
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
|
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
|
||||||
# (docs/library-and-storage.md#запись-в-хранилище).
|
# (docs/library-and-storage.md#запись-в-хранилище).
|
||||||
|
# rslave: диск, смонтированный на хосте уже после старта контейнеров, становится виден внутри без перезапуска.
|
||||||
x-library-host-ro: &library-host-ro
|
x-library-host-ro: &library-host-ro
|
||||||
type: bind
|
type: bind
|
||||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||||
target: /library-host
|
target: /library-host
|
||||||
read_only: true
|
read_only: true
|
||||||
|
bind: { propagation: rslave }
|
||||||
|
|
||||||
x-restart: &restart
|
x-restart: &restart
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -96,6 +98,7 @@ services:
|
|||||||
- type: bind
|
- type: bind
|
||||||
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
|
||||||
target: /library-host-rw
|
target: /library-host-rw
|
||||||
|
bind: { propagation: rslave }
|
||||||
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
|
||||||
|
|
||||||
recommender:
|
recommender:
|
||||||
@@ -142,6 +145,26 @@ services:
|
|||||||
interval: 5s
|
interval: 5s
|
||||||
retries: 20
|
retries: 20
|
||||||
|
|
||||||
|
# Ежедневная копия базы и еженедельная проверка восстановления (deploy/backup.sh, docs/deployment.md#бэкапы).
|
||||||
|
backup:
|
||||||
|
<<: *restart
|
||||||
|
image: pgvector/pgvector:0.8.7-pg17
|
||||||
|
entrypoint: ["bash", "/backup.sh"]
|
||||||
|
command: ["loop"]
|
||||||
|
environment:
|
||||||
|
PGHOST: postgres
|
||||||
|
PGUSER: pipu
|
||||||
|
PGPASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
PGDATABASE: pipu
|
||||||
|
BACKUP_HOUR: ${BACKUP_HOUR:-3}
|
||||||
|
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
|
||||||
|
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
|
||||||
|
volumes:
|
||||||
|
- ./backup.sh:/backup.sh:ro
|
||||||
|
- ${PIPU_DATA:-/srv/pipu}/backups:/backups
|
||||||
|
depends_on:
|
||||||
|
postgres: { condition: service_healthy }
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
<<: *restart
|
<<: *restart
|
||||||
image: redis:7.4-alpine
|
image: redis:7.4-alpine
|
||||||
|
|||||||
Executable
+34
@@ -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 (или вручную: «Администрирование → Библиотека»)."
|
||||||
@@ -26,6 +26,15 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
|
||||||
|
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
|
||||||
|
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
|
||||||
|
echo "LIBRARY_HOST=$LIBRARY_HOST пуст или не существует — диск с музыкой не смонтирован?" >&2
|
||||||
|
echo " проверьте: findmnt $LIBRARY_HOST; смонтируйте (sudo mount -a) и повторите." >&2
|
||||||
|
echo " Если папка и должна быть пустой: PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh ${1:-}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
REF="${1:-}"
|
REF="${1:-}"
|
||||||
git fetch --tags --prune --quiet origin
|
git fetch --tags --prune --quiet origin
|
||||||
|
|
||||||
@@ -53,6 +62,12 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Копия базы перед обновлением: миграции назад не откатываются (deploy/restore.sh — вернуть).
|
||||||
|
if [ -n "$(docker compose ps -q --status running postgres 2>/dev/null)" ]; then
|
||||||
|
echo "→ резервная копия базы"
|
||||||
|
docker compose run --rm --no-deps backup once >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
echo "→ переключаемся на $REF"
|
echo "→ переключаемся на $REF"
|
||||||
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
|
||||||
git checkout --quiet "$REF"
|
git checkout --quiet "$REF"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
|
|||||||
| [testing.md](testing.md) | Стратегия тестирования |
|
| [testing.md](testing.md) | Стратегия тестирования |
|
||||||
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
|
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
|
||||||
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
|
||||||
|
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, бэкапы, диагностика |
|
||||||
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы |
|
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы |
|
||||||
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
|
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
|
||||||
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
|
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Руководство администратора
|
||||||
|
|
||||||
|
Повседневная работа с сервером после установки ([install-guide.md](install-guide.md)): пользователи, музыка,
|
||||||
|
очередь на добавление, «Моя волна», обновления, бэкапы и диагностика. Все команды — из каталога
|
||||||
|
`/opt/pipu-music/deploy`. Справочник по сервисам и переменным — [deployment.md](deployment.md).
|
||||||
|
|
||||||
|
## 1. Первый вход
|
||||||
|
|
||||||
|
1. Логин `admin`. Пароль — `BOOTSTRAP_ADMIN_PASSWORD` из `.env`, а если он не задан — сгенерированный, его
|
||||||
|
печатает лог один раз: `docker compose logs api | grep -i bootstrap`.
|
||||||
|
2. Сервер сразу попросит сменить логин и пароль администратора (пароль ≥ 12 символов). До этого остальные
|
||||||
|
разделы недоступны.
|
||||||
|
3. Подключите хранилище с музыкой (раздел 3) — после первого скана появятся библиотека, поиск и «Моя волна».
|
||||||
|
|
||||||
|
## 2. Пользователи и роли
|
||||||
|
|
||||||
|
«Администрирование → Пользователи».
|
||||||
|
|
||||||
|
| Роль | Что может |
|
||||||
|
|---|---|
|
||||||
|
| Пользователь | Слушать, искать, собирать коллекцию, «Моя волна» |
|
||||||
|
| Модератор | Плюс: заводить пользователей и сбрасывать им пароли, смотреть статус библиотеки, предлагать треки |
|
||||||
|
| Администратор | Всё: хранилища, сканы, очередь на добавление, настройки сервера, роли, аудит |
|
||||||
|
|
||||||
|
Полная матрица прав — [auth-and-roles.md](auth-and-roles.md#матрица-прав).
|
||||||
|
|
||||||
|
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
|
||||||
|
раз, передайте его человеку. При первом входе пароль придётся сменить.
|
||||||
|
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
|
||||||
|
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
|
||||||
|
- Последнего администратора нельзя понизить, заблокировать или удалить.
|
||||||
|
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
|
||||||
|
паролей, изменения хранилищ и настроек, решения по очереди.
|
||||||
|
|
||||||
|
## 3. Хранилища и библиотека
|
||||||
|
|
||||||
|
«Администрирование → Хранилища».
|
||||||
|
|
||||||
|
- **Папка на сервере** — каталог хоста из `LIBRARY_HOST` в `.env` (например, смонтированный диск `/mnt/music`);
|
||||||
|
в форме указывается путь внутри него. **NFS** — сервер и экспорт; монтирует `storage-agent`.
|
||||||
|
- «Проверить» — доступ к хранилищу, число найденных аудиофайлов.
|
||||||
|
- **Шаблоны путей** («Шаблоны»): как из пути файла получить исполнителя, альбом, год и номер трека, если в тегах
|
||||||
|
пусто. «Предпросмотр» показывает разбор реальных путей до сохранения. Что важнее — теги или путь, задаётся
|
||||||
|
в карточке хранилища (по умолчанию теги).
|
||||||
|
- **Скан:** обычный — только новые и изменённые файлы (по расписанию — интервал в карточке хранилища — и после
|
||||||
|
изменения настроек), «Полный» — перечитать всё. Удалённые с диска файлы помечаются отсутствующими, а не стираются из истории.
|
||||||
|
- «Администрирование → Библиотека» — счётчики, прогресс скана и аудиоанализа, файлы с ошибками чтения
|
||||||
|
(битые файлы, неизвестные форматы) и «Переиндексировать поиск» — если поиск выдаёт устаревшее (обычно не нужно:
|
||||||
|
индекс обновляется сам и пересобирается при старте api).
|
||||||
|
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
|
||||||
|
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
|
||||||
|
Не удалось проанализировать часть треков (например, пока диск не был смонтирован) — «Повторить» под счётчиком
|
||||||
|
ошибок возвращает их в очередь; рядом показана самая частая ошибка. Пропавшие файлы анализатор не считает
|
||||||
|
попыткой и ждёт, пока диск вернётся.
|
||||||
|
|
||||||
|
## 4. Очередь на добавление
|
||||||
|
|
||||||
|
Модераторы и администраторы предлагают треки («Предложить треки»), администратор решает, что попадёт
|
||||||
|
в библиотеку. Подробно — [uploads.md](uploads.md).
|
||||||
|
|
||||||
|
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
|
||||||
|
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
|
||||||
|
получит те же исполнителя, альбом и номер трека. Хранилище по умолчанию — в «Настройках сервера».
|
||||||
|
- **Рассмотрение** («Очередь на добавление»): прослушать, поправить метаданные (в том числе сразу для всего
|
||||||
|
альбома), одобрить или отклонить с причиной — автор её увидит. Итоговый путь пересчитывается при правке.
|
||||||
|
Отметка «возможный дубль» — со ссылкой на похожий трек библиотеки; точные копии отсекаются ещё до загрузки.
|
||||||
|
- **Импорт:** одобренный файл записывается в хранилище только под окончательным именем, с проверкой контрольной
|
||||||
|
суммы; кроме самих аудиофайлов (и папок для них) на диск ничего не пишется.
|
||||||
|
- **Квота** («Настройки сервера»): общий лимит очереди (10 ГБ по умолчанию), лимит на одного модератора
|
||||||
|
(выключен) и максимальный размер файла (2 ГБ). Отклонённые файлы и дубли место не занимают; снижение лимита ниже
|
||||||
|
занятого не удаляет загрузки, просто новые не принимаются.
|
||||||
|
|
||||||
|
## 5. «Моя волна»
|
||||||
|
|
||||||
|
- «Администрирование → Библиотека → Моя волна за 7 дней»: сколько треков выдано, доля дослушанных до конца,
|
||||||
|
ранних скипов и лайков на 100 треков. Ориентир — ранних скипов меньше 30%.
|
||||||
|
- Таблица по **версиям модели**: `heuristic-v1` — эвристика, `heuristic-v1+cf` — она же с коллаборативной
|
||||||
|
фильтрацией (ALS), `lgbm-…` — обученный ранжировщик. По ней видно, стало ли лучше после смены модели.
|
||||||
|
- Обучение — каждую ночь в 3:00 UTC: ALS по прослушиваниям, бандит по источникам
|
||||||
|
кандидатов, ранжировщик по показам волны. Новая модель включается, только если по проверке на последней неделе
|
||||||
|
не хуже текущей. Ранжировщику нужно ~20 тыс. показов с известным исходом — до этого работает эвристика.
|
||||||
|
- Новые пользователи с почти пустой историей проходят **онбординг вкуса**: выбирают исполнителей и жанры
|
||||||
|
библиотеки. Пропуск тоже запоминается.
|
||||||
|
|
||||||
|
## 6. Обновление
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./update.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт делает копию базы, переключается на последний релиз, собирает образы, перезапускает сервисы и ждёт
|
||||||
|
готовности; миграции применяются при старте api. Конкретная версия — `./update.sh v1.0.0`; откат на старую версию
|
||||||
|
требует `PIPU_ALLOW_DOWNGRADE=1` (миграции не откатываются — сначала проверьте совместимость). Перед обновлением
|
||||||
|
прочитайте заметки к релизу на странице релизов Gitea.
|
||||||
|
|
||||||
|
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
|
||||||
|
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
|
||||||
|
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
|
||||||
|
|
||||||
|
## 7. Бэкапы и восстановление
|
||||||
|
|
||||||
|
- Каждую ночь (`BACKUP_HOUR`, по умолчанию 3:30 UTC) контейнер `backup` делает копию базы в
|
||||||
|
`/srv/pipu/backups/daily` (14 последних) и раз в неделю — в `weekly` (8 последних).
|
||||||
|
- Раз в неделю копия **проверяется восстановлением** во временную базу. Результат:
|
||||||
|
`docker compose logs backup | tail`; при ошибке в логе — «ПРОВЕРКА НЕ ПРОЙДЕНА».
|
||||||
|
- Копия сейчас: `docker compose run --rm backup once`.
|
||||||
|
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/<файл>` — восстановить. Прежняя база
|
||||||
|
остаётся рядом как `pipu_before_restore_<время>`; убедившись, что всё в порядке, удалите её:
|
||||||
|
`docker compose exec postgres dropdb -U pipu pipu_before_restore_<время>`.
|
||||||
|
- В копию входит всё, что нельзя пересоздать: пользователи, история, лайки, состояние плеера, очередь на добавление,
|
||||||
|
результаты аудиоанализа и модели волны. Поиск, обложки и транскоды восстанавливаются сами.
|
||||||
|
- **Сама музыка не бэкапится** приложением — это ваш диск или NFS. Каталог бэкапов тоже лежит на сервере:
|
||||||
|
копируйте `/srv/pipu/backups` на другую машину, например `rsync` по cron.
|
||||||
|
|
||||||
|
## 8. Наблюдение
|
||||||
|
|
||||||
|
| Что | Как |
|
||||||
|
|---|---|
|
||||||
|
| Сервер жив и готов | `curl -s https://music.example.com/readyz` → все зависимости `ok` |
|
||||||
|
| Состояние сервисов | `docker compose ps` |
|
||||||
|
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`, `backup`) |
|
||||||
|
| Место на диске | `df -h /srv/pipu`; больше всего занимают `cache/transcode` (чистится сам) и `backups` |
|
||||||
|
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
|
||||||
|
| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
|
||||||
|
|
||||||
|
## 9. Частые ситуации
|
||||||
|
|
||||||
|
| Ситуация | Что делать |
|
||||||
|
|---|---|
|
||||||
|
| Ни один трек не играет («Не удалось воспроизвести трек»), в логе api `no such file or directory` | Диск с музыкой не смонтирован: `findmnt <LIBRARY_HOST>`, `sudo mount -a`. Контейнеры увидят диск сами; скан на пустой папке прерывается с ошибкой «is the disk mounted?» и ничего не помечает пропавшим |
|
||||||
|
| Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» |
|
||||||
|
| Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан |
|
||||||
|
| Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» |
|
||||||
|
| Волна однообразная | Идёт ли аудиоанализ («Библиотека → Аудиоанализ»); чем больше прослушиваний, тем точнее волна |
|
||||||
|
| Загрузка застряла в «Обработке» | `docker compose logs api | grep upload`; повторный импорт — «Одобрить» ещё раз |
|
||||||
|
| Пользователь не может войти | Не заблокирован ли; «Сбросить пароль» |
|
||||||
|
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
|
||||||
|
|
||||||
|
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
|
||||||
+1
-1
@@ -103,7 +103,7 @@ container:
|
|||||||
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
|
- /opt/ci-cache/** # постоянные кэши Gradle и Android SDK для job android в release.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=6g` без свопа, CMake / ninja / Gradle
|
Сборка APK ограничена сама (`release.yml`: контейнер `--cpus=3 --memory=7g` без свопа, Gradle `-Xmx2g`, Metro `--max-old-space-size=1536`, таймаут 60 мин, CMake / ninja / Gradle
|
||||||
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
|
по 2 потока, `nice` / `ionice`). Если раннер — VM в гипервизоре (Proxmox), дополнительно ограничьте её там:
|
||||||
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
|
фиксированная RAM без ballooning, сумма RAM всех VM меньше RAM хоста, `cpulimit` ≤ половины ядер хоста,
|
||||||
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
|
лимит дискового I/O — иначе тяжёлая сборка может подвесить весь хост.
|
||||||
|
|||||||
+15
-1
@@ -97,7 +97,21 @@
|
|||||||
|
|
||||||
## Бэкапы
|
## Бэкапы
|
||||||
|
|
||||||
- `pg_dump` ежедневно (cron-контейнер `backup`), хранить 14 дней + еженедельные 8 недель → `/srv/pipu/backups`.
|
- Контейнер `backup` (`deploy/backup.sh loop`): каждый день в `BACKUP_HOUR`:30 UTC (по умолчанию 3:30) —
|
||||||
|
`pg_dump -Fc` → `/srv/pipu/backups/daily/`, хранится `BACKUP_KEEP_DAILY` = 14 последних; первая копия за неделю
|
||||||
|
копируется в `weekly/` (`BACKUP_KEEP_WEEKLY` = 8). Рядом с копией — `*.counts`: число строк в каждой таблице.
|
||||||
|
- Раз в неделю копия **проверяется восстановлением**: разворачивается во временную базу `pipu_verify`, сверяются
|
||||||
|
таблицы и число строк, база удаляется; время последней успешной проверки — `backups/.last-verify`, ошибки —
|
||||||
|
в `docker compose logs backup` («ПРОВЕРКА НЕ ПРОЙДЕНА»). Тот же цикл копия → проверка → восстановление
|
||||||
|
выполняется в CI (`ci / db`) на базе после сквозного сценария.
|
||||||
|
- `update.sh` перед обновлением делает внеочередную копию.
|
||||||
|
- Вручную: `docker compose run --rm backup once` — копия сейчас; `docker compose run --rm backup verify /backups/daily/<файл>`.
|
||||||
|
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/pipu-….dump` — останавливает api, рекомендации и
|
||||||
|
анализ, проверяет копию, переименовывает текущую базу в `pipu_before_restore_<время>` (страховка; удалить —
|
||||||
|
`docker compose exec postgres dropdb -U pipu <имя>`), восстанавливает и запускает сервисы; поиск переиндексируется
|
||||||
|
при старте api. Redis (сессии волны, очереди) не восстанавливается — пользователи просто начнут новую волну.
|
||||||
|
- Копии лежат на том же сервере: для защиты от потери диска синхронизируйте `/srv/pipu/backups` на другую машину
|
||||||
|
(например, `rclone sync` или `rsync` по cron).
|
||||||
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
|
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
|
||||||
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
|
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
|
||||||
- Сама музыка — на NFS, вне зоны ответственности приложения.
|
- Сама музыка — на NFS, вне зоны ответственности приложения.
|
||||||
|
|||||||
+2
-1
@@ -48,8 +48,9 @@
|
|||||||
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
|
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
|
||||||
| `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении |
|
| `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении |
|
||||||
| `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки |
|
| `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки |
|
||||||
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#737F9C` | `#0A0F1E` / `#4B5675` / `#677290` | Текст: основной / вторичный / подписи |
|
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#7F8BA8` | `#0A0F1E` / `#4B5675` / `#5C6785` | Текст: основной / вторичный / подписи |
|
||||||
| `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки |
|
| `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки |
|
||||||
|
| `accent-fill` / `accent-fill2` | `#1A66E6` / `#1F6FF2` | `#1A63DB` / `#165ED6` | Заливка под белым текстом (кнопки, выбранные сегменты) и её hover — контраст ≥ 4.5:1 |
|
||||||
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
|
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
|
||||||
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
|
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
|
||||||
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
|
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
|
||||||
|
|||||||
+13
-2
@@ -314,12 +314,21 @@ cd /opt/pipu-music/deploy && ./update.sh
|
|||||||
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
|
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
|
||||||
автоматически при старте API.
|
автоматически при старте API.
|
||||||
|
|
||||||
Бэкап базы (всё остальное восстанавливается само — см. [deployment.md](deployment.md#бэкапы)):
|
Бэкапы базы делаются сами: каждую ночь в `/srv/pipu/backups`, раз в неделю копия проверяется восстановлением,
|
||||||
|
перед каждым `update.sh` — внеочередная копия. Всё остальное восстанавливается само — см.
|
||||||
|
[deployment.md](deployment.md#бэкапы). Копия сейчас и восстановление:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump
|
cd /opt/pipu-music/deploy && docker compose run --rm backup once
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/pipu-music/deploy && ./restore.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Без аргумента `restore.sh` показывает список копий; с именем файла (`./restore.sh daily/pipu-20261020-033000.dump`)
|
||||||
|
восстанавливает базу, сохранив прежнюю рядом.
|
||||||
|
|
||||||
Остановить сервис, сохранив данные:
|
Остановить сервис, сохранив данные:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -339,6 +348,8 @@ cd /opt/pipu-music/deploy && docker compose down
|
|||||||
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
|
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
|
||||||
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
|
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
|
||||||
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
|
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
|
||||||
|
| Сервисы не стартуют: `… is not a shared or slave mount` | `sudo mount --make-rshared /` (и добавьте в автозагрузку); на systemd-системах корень и так shared |
|
||||||
|
| `update.sh`: «LIBRARY_HOST … пуст» | Диск с музыкой не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; если папка и должна быть пустой — `PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh` |
|
||||||
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` |
|
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` |
|
||||||
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) |
|
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) |
|
||||||
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
|
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
|
||||||
|
|||||||
@@ -35,8 +35,7 @@
|
|||||||
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
|
`fsync`, сверка SHA-256, атомарный `rename`. Перезапись существующих файлов и удаление запрещены на уровне кода агента.
|
||||||
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
|
- Права на NFS-сервере: экспорт должен разрешать запись для UID/GID агента (`PIPU_FILE_UID` / `PIPU_FILE_GID`,
|
||||||
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
|
задаются в `.env` под владельца музыки на файловом сервере); новые файлы — `0644`, каталоги — `0755`.
|
||||||
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет запись: создаёт и удаляет
|
- `POST /admin/sources/{id}/test` для такого источника дополнительно проверяет право записи — без создания файлов.
|
||||||
`.pipu-write-test` в корне.
|
|
||||||
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
|
- У источника задаётся `write_template` — шаблон пути для новых файлов, с автоподбором по существующей структуре
|
||||||
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
|
и проверкой обратимости через регэкспы источника (подробно — [uploads.md](uploads.md#7-раскладка-в-хранилище-как-принято-на-сервере)).
|
||||||
|
|
||||||
@@ -44,8 +43,11 @@
|
|||||||
- Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`;
|
- Агент: `POST /internal/files` `{source_id, local_path?, rel_path, staging_path, sha256}` → `{rel_path, size, renamed}`;
|
||||||
`POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог
|
`POST /internal/files/test` — проверка записи. NFS с `accepts_uploads` монтируется rw в `/library-rw/<id>` (каталог
|
||||||
внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через
|
внутри контейнера агента), а в `/library/<id>` — bind этой точки с `remount,ro`. Local-источники пишутся через
|
||||||
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). Итоговое имя создаётся жёсткой ссылкой
|
`/library-host-rw` (тот же `LIBRARY_HOST`, на запись — только у агента). На музыкальный диск не пишется ничего,
|
||||||
на временный файл (`link` не перезаписывает, в отличие от `rename`); занято — ` (2)`, ` (3)`…
|
кроме самих треков: файл создаётся сразу под итоговым именем (`O_EXCL` — существующий не перезаписывается;
|
||||||
|
занято — ` (2)`, ` (3)`…), `fsync`, сверка SHA-256; при ошибке агент удаляет свой недописанный файл и созданные
|
||||||
|
для него пустые каталоги. Проверка записи — `access(W_OK)` и флаг read-only файловой системы, без пробного файла.
|
||||||
|
Остальные сервисы видят музыку только на чтение (`read_only` в compose).
|
||||||
Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять).
|
Владелец новых файлов и каталогов — `PIPU_FILE_UID` / `PIPU_FILE_GID` (`-1` — не менять).
|
||||||
- Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через
|
- Шаблон: `meta.ParseTemplate` / `Render` (очистка имён, пределы длины), `meta.SuggestTemplate` (регэксп → шаблон через
|
||||||
`regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`,
|
`regexp/syntax`: именованные группы → поля, `(?:(?P<disc>\d)-)?` → `{disc:1-}`, необязательная группа → `[ ]`,
|
||||||
@@ -159,7 +161,9 @@ Meilisearch, индексы:
|
|||||||
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
|
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
|
||||||
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
|
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
|
||||||
отменяет предыдущий запрос (`AbortController`).
|
отменяет предыдущий запрос (`AbortController`).
|
||||||
- Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше
|
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
|
||||||
в выдаче `suggest` (re-rank первых 20 на стороне `api`).
|
(`internal/search/personal.go`). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10,
|
||||||
|
реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на
|
||||||
|
2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка.
|
||||||
- Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача
|
- Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача
|
||||||
`reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).
|
`reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).
|
||||||
@@ -82,6 +82,8 @@
|
|||||||
|
|
||||||
## Самообновление
|
## Самообновление
|
||||||
|
|
||||||
|
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
|
||||||
|
|
||||||
Приложение распространяется только через Gitea Releases (не Google Play).
|
Приложение распространяется только через Gitea Releases (не Google Play).
|
||||||
|
|
||||||
**Поток:**
|
**Поток:**
|
||||||
|
|||||||
@@ -152,6 +152,16 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
|
|||||||
Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%.
|
Обучение — ночью; новая модель активируется, только если офлайн-NDCG@10 не хуже активной более чем на 2%.
|
||||||
Пока размеченных показов меньше ~20k, используется v1 (переключение автоматическое по порогу).
|
Пока размеченных показов меньше ~20k, используется v1 (переключение автоматическое по порогу).
|
||||||
|
|
||||||
|
Реализация (`pipu_rec/train/ranker.py`): при каждом показе волна пишет вектор признаков кандидата
|
||||||
|
в `rec_impression_features` (29 признаков, `FEATURE_NAMES` в `wave/engine.py`; порядок версионируется
|
||||||
|
`FEATURES_VERSION`). Ночью признаки соединяются с исходами `wave_impressions`, группа — сессия волны;
|
||||||
|
валидация — сессии последних 7 дней. Модель включается, если её NDCG@10 не ниже порядка по оценке v1
|
||||||
|
и не хуже активной более чем на 2%. Модель хранится строкой в `model_versions.artifact` (kind `ranker`),
|
||||||
|
сервис загружает активную при старте и после ночного обучения; в выдаче её порядок заменяет оценку v1,
|
||||||
|
MMR-разнообразие и квоты остаются. Пока модели нет, `model_ver` показа — версия v1.
|
||||||
|
Онлайн-сравнение — админка «Библиотека → Моя волна»: доля доигрываний, ранних скипов и лайков
|
||||||
|
за 7 дней отдельно по каждому `model_ver` показа.
|
||||||
|
|
||||||
### 6.4 Занятия и настроения
|
### 6.4 Занятия и настроения
|
||||||
|
|
||||||
Каждый контекст — профиль целевых значений аудиопризнаков (`services/recommender/config/contexts.yaml`,
|
Каждый контекст — профиль целевых значений аудиопризнаков (`services/recommender/config/contexts.yaml`,
|
||||||
@@ -191,6 +201,11 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
|
|||||||
что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают
|
что одному пользователю лучше заходит `fresh`, а другому — `rediscover`. Параметры затухают
|
||||||
(×0.99 в день), чтобы подстраиваться под смену вкуса.
|
(×0.99 в день), чтобы подстраиваться под смену вкуса.
|
||||||
|
|
||||||
|
Реализация (`pipu_rec/train/bandit.py`): параметры пересчитываются ночью по `wave_impressions` за 90 дней
|
||||||
|
(награда — `complete` / `like`) в `user_profiles_rec.bandit`; при сборке пачки источник получает бонус
|
||||||
|
`0.2·(θ − 0.5)`, где θ ~ Beta(α, β) (источник без наблюдений — нейтральные 0.5). `exploration` следует за
|
||||||
|
успехом `fresh` относительно остальных источников (0.05–0.4).
|
||||||
|
|
||||||
### 6.7 Реакция в реальном времени
|
### 6.7 Реакция в реальном времени
|
||||||
|
|
||||||
- Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
|
- Клиент держит в очереди 2 трека вперёд и запрашивает новую пачку, когда осталось ≤ 2.
|
||||||
@@ -204,6 +219,10 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
|
|||||||
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
|
- **Новый пользователь**: опциональный онбординг (выбрать 3+ жанра или исполнителя) → начальные центроиды.
|
||||||
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
|
Без онбординга — первые пачки из `popular` и максимально разнообразных кластеров библиотеки
|
||||||
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
|
(по одному треку из разных кластеров), exploration 40% на первые 30 треков.
|
||||||
|
- Реализация онбординга: `GET /onboarding` (40 исполнителей с наибольшим числом треков, 24 жанра; `required` —
|
||||||
|
не пройден и своей истории < 10 треков), `PUT /me/onboarding` → `user_profiles_rec.onboarding`; пустой выбор —
|
||||||
|
пропуск. Рекомендатель, пока своих оценок < 30, добавляет псевдооценки: треки выбранных исполнителей +1,
|
||||||
|
жанров +0.5 (до 300 треков). Веб показывает онбординг на главной вместо волны.
|
||||||
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
|
- **Новый трек**: сразу доступен через `audio_sim` / `fresh` (как только проанализирован), без единого прослушивания.
|
||||||
|
|
||||||
## 8. Обучение на всех пользователях
|
## 8. Обучение на всех пользователях
|
||||||
|
|||||||
@@ -49,10 +49,15 @@
|
|||||||
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
|
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
|
||||||
- Персонализация suggest.
|
- Персонализация suggest.
|
||||||
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
|
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
|
||||||
|
- **Состояние:** всё реализовано (v0.8, v1.0). Ранжировщик включается сам после ~20 тыс. размеченных показов;
|
||||||
|
критерий проверяется по метрикам версий в админке («Библиотека → Моя волна») и NDCG в `model_versions`.
|
||||||
|
|
||||||
## M7 — Стабилизация (v1.0)
|
## M7 — Стабилизация (v1.0)
|
||||||
- Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора,
|
- Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора,
|
||||||
доступность, полировка анимаций, багфиксы.
|
доступность, полировка анимаций, багфиксы.
|
||||||
|
- **Состояние:** v1.0 — бэкапы с еженедельной проверкой восстановления (и цикл копия → проверка → восстановление
|
||||||
|
в CI), [руководство администратора](admin-guide.md), аудит доступности WCAG 2.1 AA, k6-smoke
|
||||||
|
(`tests/load/smoke.js`, запускается вручную на сервере перед релизом).
|
||||||
|
|
||||||
## Дальше (идеи)
|
## Дальше (идеи)
|
||||||
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
|
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
|
||||||
|
|||||||
+25
-1
@@ -49,4 +49,28 @@
|
|||||||
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
|
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
|
||||||
- Тест на каждый исправленный баг.
|
- Тест на каждый исправленный баг.
|
||||||
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
|
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
|
||||||
- Нагрузочный smoke (k6, вручную перед релизом): 100 одновременных слушателей, 20 rps на suggest — p95 в рамках целей.
|
- Нагрузочный smoke (k6, вручную перед релизом) — см. ниже.
|
||||||
|
|
||||||
|
## Нагрузочный smoke
|
||||||
|
|
||||||
|
`tests/load/smoke.js` (k6 ≥ 0.50), 3 минуты: 100 одновременных слушателей волны (старт волны, начало потока
|
||||||
|
каждого трека — первые 64 КБ, события прослушивания, следующая пачка с отзывами) и 20 запросов подсказок поиска
|
||||||
|
в секунду. Пороги — цели из [api.md](api.md) и [recommendations.md](recommendations.md#10-требования-к-производительности):
|
||||||
|
|
||||||
|
| Запрос | p95 |
|
||||||
|
|---|---|
|
||||||
|
| `GET /search/suggest` | < 50 мс |
|
||||||
|
| `POST /wave/sessions/{id}/next` | < 150 мс |
|
||||||
|
| `POST /wave/sessions` | < 300 мс |
|
||||||
|
| начало потока (`Range: bytes=0-65535`) | < 300 мс |
|
||||||
|
| `POST /events` | < 100 мс |
|
||||||
|
|
||||||
|
Ошибок < 1%. k6 завершается с ненулевым кодом, если порог не выполнен.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Пользователь — отдельный обычный (`role=user`): тест наполняет его историю и лайки. Нагрузку можно менять:
|
||||||
|
`-e LISTENERS=20 -e SUGGEST_RPS=5 -e DURATION=1m`. Запускать с машины в той же сети, что и сервер, —
|
||||||
|
иначе в цифрах будет задержка канала.
|
||||||
+3
-3
@@ -161,9 +161,9 @@
|
|||||||
2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`.
|
2. Запросить у `storage-agent` запись: `POST /internal/files` `{source_id, rel_path, staging_path}`.
|
||||||
Агент:
|
Агент:
|
||||||
- создаёт каталоги;
|
- создаёт каталоги;
|
||||||
- копирует во временный файл `.<name>.pipu-tmp` в целевой папке, `fsync`;
|
- создаёт файл сразу под итоговым именем (`O_EXCL`, существующий не перезаписывается), копирует, `fsync`;
|
||||||
- сверяет SHA-256 копии со staging;
|
- сверяет SHA-256 копии со staging; при ошибке удаляет свой файл и созданные пустые каталоги —
|
||||||
- атомарно переименовывает в итоговое имя.
|
на музыкальный диск не попадает ничего, кроме треков.
|
||||||
3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ.
|
3. Запустить точечный скан файла (`scan_path`) → трек появляется в каталоге и поиске, ставится в аудиоанализ.
|
||||||
В `media_files` сохраняется `upload_id` — история происхождения.
|
В `media_files` сохраняется `upload_id` — история происхождения.
|
||||||
4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`.
|
4. Удалить файл из staging, освободить квоту, статус `imported`, запись в `audit_log`.
|
||||||
|
|||||||
+12
@@ -101,3 +101,15 @@ apps/web/src/
|
|||||||
|
|
||||||
## Доступность
|
## Доступность
|
||||||
- Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA.
|
- Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA.
|
||||||
|
|
||||||
|
## Доступность
|
||||||
|
|
||||||
|
Цель — WCAG 2.1 AA. Проверено axe-core на всех экранах в тёмной и светлой темах (с играющим треком и открытой
|
||||||
|
очередью) — нарушений нет. Правила, которые держим:
|
||||||
|
- текст на заливке акцента — только `--accent-fill` (белый текст ≥ 4.5:1), вторичный текст `--text3` — ≥ 4.5:1
|
||||||
|
на фоне и поверхностях;
|
||||||
|
- у каждого экрана есть `h1`; у кнопок-иконок — `aria-label`, у переключателей — `aria-pressed`;
|
||||||
|
- списки треков — `role="list"` / `listitem` (строка фокусируется, Enter запускает трек, лайк и ссылки — отдельные
|
||||||
|
элементы); прогресс-бары подписаны через `aria-labelledby`;
|
||||||
|
- выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку;
|
||||||
|
- `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена.
|
||||||
@@ -880,6 +880,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/admin/library/analysis/retry": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Вернуть в очередь треки, которые не удалось проанализировать (admin) */
|
||||||
|
post: operations["retryAnalysis"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/admin/search/reindex": {
|
"/api/v1/admin/search/reindex": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1066,6 +1083,43 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/onboarding": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
||||||
|
* @description `required` — показать онбординг: пользователь его не проходил и не пропускал, а своей истории почти нет.
|
||||||
|
*/
|
||||||
|
get: operations["getOnboarding"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/v1/me/onboarding": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/** Сохранить выбор онбординга (или пропуск — пустые списки) */
|
||||||
|
put: operations["saveOnboarding"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/wave/options": {
|
"/api/v1/wave/options": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1181,6 +1235,26 @@ export interface paths {
|
|||||||
export type webhooks = Record<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
schemas: {
|
schemas: {
|
||||||
|
Onboarding: {
|
||||||
|
required: boolean;
|
||||||
|
artists: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
track_count: number;
|
||||||
|
/** @description Обложка самого большого альбома исполнителя */
|
||||||
|
cover_url: string | null;
|
||||||
|
cover_colors: string[];
|
||||||
|
}[];
|
||||||
|
genres: {
|
||||||
|
name: string;
|
||||||
|
track_count: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
OnboardingChoice: {
|
||||||
|
artist_ids: string[];
|
||||||
|
genres: string[];
|
||||||
|
};
|
||||||
UserRef: {
|
UserRef: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1955,6 +2029,8 @@ export interface components {
|
|||||||
done: number;
|
done: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
analyzer_ver: string | null;
|
analyzer_ver: string | null;
|
||||||
|
/** @description Самая частая ошибка среди неудавшихся — подсказка, что чинить */
|
||||||
|
top_error: string | null;
|
||||||
};
|
};
|
||||||
/** @description Метрики волны за 7 дней */
|
/** @description Метрики волны за 7 дней */
|
||||||
WaveMetrics: {
|
WaveMetrics: {
|
||||||
@@ -1964,6 +2040,15 @@ export interface components {
|
|||||||
/** @description Лайков на 100 треков */
|
/** @description Лайков на 100 треков */
|
||||||
like_rate: number | null;
|
like_rate: number | null;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
|
/** @description Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн */
|
||||||
|
by_model: components["schemas"]["WaveModelMetrics"][];
|
||||||
|
};
|
||||||
|
WaveModelMetrics: {
|
||||||
|
model_ver: string;
|
||||||
|
served: number;
|
||||||
|
listen_through_rate: number | null;
|
||||||
|
early_skip_rate: number | null;
|
||||||
|
like_rate: number | null;
|
||||||
};
|
};
|
||||||
AndroidRelease: {
|
AndroidRelease: {
|
||||||
/** @example 0.6.1 */
|
/** @example 0.6.1 */
|
||||||
@@ -3289,6 +3374,29 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
retryAnalysis: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Сколько задач снова в очереди */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
requeued: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
reindexSearch: {
|
reindexSearch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3599,6 +3707,50 @@ export interface operations {
|
|||||||
default: components["responses"]["Problem"];
|
default: components["responses"]["Problem"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getOnboarding: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Варианты и состояние */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Onboarding"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
saveOnboarding: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["OnboardingChoice"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Сохранено */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
default: components["responses"]["Problem"];
|
||||||
|
};
|
||||||
|
};
|
||||||
getWaveOptions: {
|
getWaveOptions: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -22,9 +22,11 @@
|
|||||||
--line2: rgba(130,160,255,.18);
|
--line2: rgba(130,160,255,.18);
|
||||||
--text: #EDF1FF;
|
--text: #EDF1FF;
|
||||||
--text2: #A0ABC6;
|
--text2: #A0ABC6;
|
||||||
--text3: #737F9C;
|
--text3: #7F8BA8;
|
||||||
--accent: #2F80FF;
|
--accent: #2F80FF;
|
||||||
--accent2: #5E9BFF;
|
--accent2: #5E9BFF;
|
||||||
|
--accent-fill: #1A66E6;
|
||||||
|
--accent-fill2: #1F6FF2;
|
||||||
--accent-soft: rgba(47,128,255,.15);
|
--accent-soft: rgba(47,128,255,.15);
|
||||||
--violet: #9B7CFF;
|
--violet: #9B7CFF;
|
||||||
--violet-soft: rgba(124,92,255,.16);
|
--violet-soft: rgba(124,92,255,.16);
|
||||||
@@ -56,9 +58,11 @@
|
|||||||
--line2: rgba(20,35,80,.17);
|
--line2: rgba(20,35,80,.17);
|
||||||
--text: #0A0F1E;
|
--text: #0A0F1E;
|
||||||
--text2: #4B5675;
|
--text2: #4B5675;
|
||||||
--text3: #677290;
|
--text3: #5C6785;
|
||||||
--accent: #1F6FF2;
|
--accent: #1F6FF2;
|
||||||
--accent2: #1F6FF2;
|
--accent2: #1F6FF2;
|
||||||
|
--accent-fill: #1A63DB;
|
||||||
|
--accent-fill2: #165ED6;
|
||||||
--accent-soft: rgba(31,111,242,.10);
|
--accent-soft: rgba(31,111,242,.10);
|
||||||
--violet: #6A47F0;
|
--violet: #6A47F0;
|
||||||
--violet-soft: rgba(106,71,240,.10);
|
--violet-soft: rgba(106,71,240,.10);
|
||||||
@@ -90,9 +94,11 @@
|
|||||||
--line2: rgba(20,35,80,.17);
|
--line2: rgba(20,35,80,.17);
|
||||||
--text: #0A0F1E;
|
--text: #0A0F1E;
|
||||||
--text2: #4B5675;
|
--text2: #4B5675;
|
||||||
--text3: #677290;
|
--text3: #5C6785;
|
||||||
--accent: #1F6FF2;
|
--accent: #1F6FF2;
|
||||||
--accent2: #1F6FF2;
|
--accent2: #1F6FF2;
|
||||||
|
--accent-fill: #1A63DB;
|
||||||
|
--accent-fill2: #165ED6;
|
||||||
--accent-soft: rgba(31,111,242,.10);
|
--accent-soft: rgba(31,111,242,.10);
|
||||||
--violet: #6A47F0;
|
--violet: #6A47F0;
|
||||||
--violet-soft: rgba(106,71,240,.10);
|
--violet-soft: rgba(106,71,240,.10);
|
||||||
@@ -123,9 +129,11 @@
|
|||||||
--line2: rgba(130,160,255,.18);
|
--line2: rgba(130,160,255,.18);
|
||||||
--text: #EDF1FF;
|
--text: #EDF1FF;
|
||||||
--text2: #A0ABC6;
|
--text2: #A0ABC6;
|
||||||
--text3: #737F9C;
|
--text3: #7F8BA8;
|
||||||
--accent: #2F80FF;
|
--accent: #2F80FF;
|
||||||
--accent2: #5E9BFF;
|
--accent2: #5E9BFF;
|
||||||
|
--accent-fill: #1A66E6;
|
||||||
|
--accent-fill2: #1F6FF2;
|
||||||
--accent-soft: rgba(47,128,255,.15);
|
--accent-soft: rgba(47,128,255,.15);
|
||||||
--violet: #9B7CFF;
|
--violet: #9B7CFF;
|
||||||
--violet-soft: rgba(124,92,255,.16);
|
--violet-soft: rgba(124,92,255,.16);
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ export const tokens = {
|
|||||||
"line2": "rgba(130,160,255,.18)",
|
"line2": "rgba(130,160,255,.18)",
|
||||||
"text": "#EDF1FF",
|
"text": "#EDF1FF",
|
||||||
"text2": "#A0ABC6",
|
"text2": "#A0ABC6",
|
||||||
"text3": "#737F9C",
|
"text3": "#7F8BA8",
|
||||||
"accent": "#2F80FF",
|
"accent": "#2F80FF",
|
||||||
"accent2": "#5E9BFF",
|
"accent2": "#5E9BFF",
|
||||||
|
"accent-fill": "#1A66E6",
|
||||||
|
"accent-fill2": "#1F6FF2",
|
||||||
"accent-soft": "rgba(47,128,255,.15)",
|
"accent-soft": "rgba(47,128,255,.15)",
|
||||||
"violet": "#9B7CFF",
|
"violet": "#9B7CFF",
|
||||||
"violet-soft": "rgba(124,92,255,.16)",
|
"violet-soft": "rgba(124,92,255,.16)",
|
||||||
@@ -42,9 +44,11 @@ export const tokens = {
|
|||||||
"line2": "rgba(20,35,80,.17)",
|
"line2": "rgba(20,35,80,.17)",
|
||||||
"text": "#0A0F1E",
|
"text": "#0A0F1E",
|
||||||
"text2": "#4B5675",
|
"text2": "#4B5675",
|
||||||
"text3": "#677290",
|
"text3": "#5C6785",
|
||||||
"accent": "#1F6FF2",
|
"accent": "#1F6FF2",
|
||||||
"accent2": "#1F6FF2",
|
"accent2": "#1F6FF2",
|
||||||
|
"accent-fill": "#1A63DB",
|
||||||
|
"accent-fill2": "#165ED6",
|
||||||
"accent-soft": "rgba(31,111,242,.10)",
|
"accent-soft": "rgba(31,111,242,.10)",
|
||||||
"violet": "#6A47F0",
|
"violet": "#6A47F0",
|
||||||
"violet-soft": "rgba(106,71,240,.10)",
|
"violet-soft": "rgba(106,71,240,.10)",
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate",
|
"$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate. accent-fill — заливка под белым текстом (контраст ≥ 4.5:1, WCAG AA)",
|
||||||
"color": {
|
"color": {
|
||||||
"dark": {
|
"dark": {
|
||||||
"bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236",
|
"bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236",
|
||||||
"elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)",
|
"elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)",
|
||||||
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#737F9C",
|
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#7F8BA8",
|
||||||
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-soft": "rgba(47,128,255,.15)",
|
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-fill": "#1A66E6", "accent-fill2": "#1F6FF2", "accent-soft": "rgba(47,128,255,.15)",
|
||||||
"violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)",
|
"violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)",
|
||||||
"ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)",
|
"ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)",
|
||||||
"danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)",
|
"danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)",
|
||||||
@@ -14,8 +14,8 @@
|
|||||||
"light": {
|
"light": {
|
||||||
"bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3",
|
"bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3",
|
||||||
"elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)",
|
"elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)",
|
||||||
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#677290",
|
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#5C6785",
|
||||||
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-soft": "rgba(31,111,242,.10)",
|
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-fill": "#1A63DB", "accent-fill2": "#165ED6", "accent-soft": "rgba(31,111,242,.10)",
|
||||||
"violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)",
|
"violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)",
|
||||||
"ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)",
|
"ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)",
|
||||||
"danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)",
|
"danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)",
|
||||||
|
|||||||
@@ -158,7 +158,10 @@
|
|||||||
"wave_sessions": "Sessions",
|
"wave_sessions": "Sessions",
|
||||||
"wave_listen": "Played ≥ 50%",
|
"wave_listen": "Played ≥ 50%",
|
||||||
"wave_early_skip": "Early skips",
|
"wave_early_skip": "Early skips",
|
||||||
"wave_likes": "Likes per 100"
|
"wave_likes": "Likes per 100",
|
||||||
|
"wave_model": "Model version",
|
||||||
|
"wave_by_model": "Wave model versions compared",
|
||||||
|
"analysis_retry": "Retry"
|
||||||
},
|
},
|
||||||
"write_template": {
|
"write_template": {
|
||||||
"title": "Uploads and path template",
|
"title": "Uploads and path template",
|
||||||
|
|||||||
@@ -20,5 +20,19 @@
|
|||||||
"min_length": "At least {{min}} characters",
|
"min_length": "At least {{min}} characters",
|
||||||
"submit": "Save and continue",
|
"submit": "Save and continue",
|
||||||
"logout": "Sign out"
|
"logout": "Sign out"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"link": "Android app",
|
||||||
|
"title": "pipu for Android",
|
||||||
|
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
|
||||||
|
"download": "Download APK",
|
||||||
|
"version": "version {{version}}",
|
||||||
|
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
|
||||||
|
"step_server": "Launch pipu and enter the server address:",
|
||||||
|
"copy_server": "Copy the server address",
|
||||||
|
"step_updates": "Sign in with your account. The app offers new versions by itself.",
|
||||||
|
"checksum": "SHA-256 checksum",
|
||||||
|
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
|
||||||
|
"to_login": "Sign in to the web app"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"profile": {
|
"profile": {
|
||||||
"menu": "Profile menu",
|
"menu": "Profile menu",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Sign out"
|
"logout": "Sign out",
|
||||||
|
"android_app": "Android app"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "Administrator",
|
"admin": "Administrator",
|
||||||
@@ -81,7 +82,8 @@
|
|||||||
"source_wave": "My Wave",
|
"source_wave": "My Wave",
|
||||||
"source_queue": "Queue",
|
"source_queue": "Queue",
|
||||||
"queue": "Queue",
|
"queue": "Queue",
|
||||||
"source_wave_seed": "Artist wave · {{label}}"
|
"source_wave_seed": "Artist wave · {{label}}",
|
||||||
|
"queue_wave": "The wave never ends: next tracks are picked as you listen"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Settings",
|
"title": "Settings",
|
||||||
@@ -278,5 +280,17 @@
|
|||||||
"cache": "Recent tracks cache",
|
"cache": "Recent tracks cache",
|
||||||
"cache_clear": "Clear ({{size}} MB)",
|
"cache_clear": "Clear ({{size}} MB)",
|
||||||
"cache_pending": "Will be cleared on next launch"
|
"cache_pending": "Will be cleared on next launch"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "What do you like to listen to?",
|
||||||
|
"subtitle": "Pick a few artists and genres to start My Wave. After that it learns from what you play.",
|
||||||
|
"artists": "Artists",
|
||||||
|
"genres": "Genres",
|
||||||
|
"skip": "Skip",
|
||||||
|
"done": "Done — to the wave",
|
||||||
|
"pick_more_one": "Pick {{count}} more artist",
|
||||||
|
"pick_more_few": "Pick {{count}} more artists",
|
||||||
|
"pick_more_many": "Pick {{count}} more artists",
|
||||||
|
"pick_more_other": "Pick {{count}} more artists"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -158,7 +158,10 @@
|
|||||||
"wave_sessions": "Сессий",
|
"wave_sessions": "Сессий",
|
||||||
"wave_listen": "Дослушано ≥ 50%",
|
"wave_listen": "Дослушано ≥ 50%",
|
||||||
"wave_early_skip": "Ранние скипы",
|
"wave_early_skip": "Ранние скипы",
|
||||||
"wave_likes": "Лайков на 100"
|
"wave_likes": "Лайков на 100",
|
||||||
|
"wave_model": "Версия модели",
|
||||||
|
"wave_by_model": "Сравнение версий модели волны",
|
||||||
|
"analysis_retry": "Повторить"
|
||||||
},
|
},
|
||||||
"write_template": {
|
"write_template": {
|
||||||
"title": "Приём загрузок и шаблон пути",
|
"title": "Приём загрузок и шаблон пути",
|
||||||
|
|||||||
@@ -20,5 +20,19 @@
|
|||||||
"min_length": "Не короче {{min}} символов",
|
"min_length": "Не короче {{min}} символов",
|
||||||
"submit": "Сохранить и продолжить",
|
"submit": "Сохранить и продолжить",
|
||||||
"logout": "Выйти"
|
"logout": "Выйти"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"link": "Приложение для Android",
|
||||||
|
"title": "pipu для Android",
|
||||||
|
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
|
||||||
|
"download": "Скачать APK",
|
||||||
|
"version": "версия {{version}}",
|
||||||
|
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
|
||||||
|
"step_server": "Запустите pipu и введите адрес сервера:",
|
||||||
|
"copy_server": "Скопировать адрес сервера",
|
||||||
|
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
|
||||||
|
"checksum": "Контрольная сумма SHA-256",
|
||||||
|
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
|
||||||
|
"to_login": "Войти в веб-версию"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"profile": {
|
"profile": {
|
||||||
"menu": "Меню профиля",
|
"menu": "Меню профиля",
|
||||||
"settings": "Настройки",
|
"settings": "Настройки",
|
||||||
"logout": "Выйти"
|
"logout": "Выйти",
|
||||||
|
"android_app": "Приложение для Android"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"admin": "Администратор",
|
"admin": "Администратор",
|
||||||
@@ -81,7 +82,8 @@
|
|||||||
"source_wave": "Моя волна",
|
"source_wave": "Моя волна",
|
||||||
"source_queue": "Очередь",
|
"source_queue": "Очередь",
|
||||||
"queue": "Очередь",
|
"queue": "Очередь",
|
||||||
"source_wave_seed": "Волна по исполнителю · {{label}}"
|
"source_wave_seed": "Волна по исполнителю · {{label}}",
|
||||||
|
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "Настройки",
|
"title": "Настройки",
|
||||||
@@ -278,5 +280,17 @@
|
|||||||
"cache": "Кэш недавних треков",
|
"cache": "Кэш недавних треков",
|
||||||
"cache_clear": "Очистить ({{size}} МБ)",
|
"cache_clear": "Очистить ({{size}} МБ)",
|
||||||
"cache_pending": "Очистится при следующем запуске"
|
"cache_pending": "Очистится при следующем запуске"
|
||||||
|
},
|
||||||
|
"onboarding": {
|
||||||
|
"title": "Что вы любите слушать?",
|
||||||
|
"subtitle": "Отметьте несколько исполнителей и жанров — с них начнётся «Моя волна». Дальше она будет учиться на ваших прослушиваниях.",
|
||||||
|
"artists": "Исполнители",
|
||||||
|
"genres": "Жанры",
|
||||||
|
"skip": "Пропустить",
|
||||||
|
"done": "Готово — к волне",
|
||||||
|
"pick_more_one": "Выберите ещё {{count}} исполнителя",
|
||||||
|
"pick_more_few": "Выберите ещё {{count}} исполнителя",
|
||||||
|
"pick_more_many": "Выберите ещё {{count}} исполнителей",
|
||||||
|
"pick_more_other": "Выберите ещё {{count}} исполнителя"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -271,6 +271,27 @@ func TestUserJourney(t *testing.T) {
|
|||||||
c.ok("GET", "/api/v1/me/likes", nil, &likes)
|
c.ok("GET", "/api/v1/me/likes", nil, &likes)
|
||||||
require.Equal(t, 1, likes.Total)
|
require.Equal(t, 1, likes.Total)
|
||||||
|
|
||||||
|
// Персональные подсказки: лайкнутый трек — первым среди треков альбома.
|
||||||
|
var sug struct {
|
||||||
|
Tracks []track `json:"tracks"`
|
||||||
|
}
|
||||||
|
c.ok("GET", "/api/v1/search/suggest?q="+url.QueryEscape("кино"), nil, &sug)
|
||||||
|
require.NotEmpty(t, sug.Tracks)
|
||||||
|
require.Equal(t, tr.ID, sug.Tracks[0].ID)
|
||||||
|
|
||||||
|
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
|
||||||
|
var onb struct {
|
||||||
|
Required bool `json:"required"`
|
||||||
|
Artists []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
} `json:"artists"`
|
||||||
|
}
|
||||||
|
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||||||
|
require.NotEmpty(t, onb.Artists)
|
||||||
|
c.ok("PUT", "/api/v1/me/onboarding", map[string]any{"artist_ids": []string{onb.Artists[0].ID}, "genres": []string{}}, nil)
|
||||||
|
c.ok("GET", "/api/v1/onboarding", nil, &onb)
|
||||||
|
require.False(t, onb.Required)
|
||||||
|
|
||||||
// 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки.
|
// 8. «Моя волна»: рекомендатель недоступен — запасная волна из библиотеки.
|
||||||
var wave struct {
|
var wave struct {
|
||||||
SessionID string `json:"session_id"`
|
SessionID string `json:"session_id"`
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
|
|||||||
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
|
||||||
AuthService: authSvc,
|
AuthService: authSvc,
|
||||||
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
|
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
|
||||||
Search: search.NewHandler(meili, libStore, indexer, dto, log),
|
Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
|
||||||
Playback: pb,
|
Playback: pb,
|
||||||
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
|
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
|
||||||
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
|
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
|
||||||
|
|||||||
+3
-3
@@ -6,6 +6,7 @@ tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen
|
|||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0
|
||||||
github.com/getkin/kin-openapi v0.127.0
|
github.com/getkin/kin-openapi v0.127.0
|
||||||
github.com/go-chi/chi/v5 v5.2.2
|
github.com/go-chi/chi/v5 v5.2.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-jwt/jwt/v5 v5.2.2
|
||||||
@@ -16,11 +17,12 @@ require (
|
|||||||
github.com/redis/go-redis/v9 v9.10.0
|
github.com/redis/go-redis/v9 v9.10.0
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
|
golang.org/x/sys v0.33.0
|
||||||
|
golang.org/x/text v0.25.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
@@ -43,8 +45,6 @@ require (
|
|||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
golang.org/x/mod v0.17.0 // indirect
|
golang.org/x/mod v0.17.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/sync v0.14.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
|
||||||
golang.org/x/text v0.25.0 // indirect
|
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -288,6 +288,9 @@ type AnalysisStatus struct {
|
|||||||
Failed int `json:"failed"`
|
Failed int `json:"failed"`
|
||||||
Queued int `json:"queued"`
|
Queued int `json:"queued"`
|
||||||
Running int `json:"running"`
|
Running int `json:"running"`
|
||||||
|
|
||||||
|
// TopError Самая частая ошибка среди неудавшихся — подсказка, что чинить
|
||||||
|
TopError *string `json:"top_error"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AndroidRelease defines model for AndroidRelease.
|
// AndroidRelease defines model for AndroidRelease.
|
||||||
@@ -488,6 +491,30 @@ type Me struct {
|
|||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Onboarding defines model for Onboarding.
|
||||||
|
type Onboarding struct {
|
||||||
|
Artists []struct {
|
||||||
|
CoverColors []string `json:"cover_colors"`
|
||||||
|
|
||||||
|
// CoverUrl Обложка самого большого альбома исполнителя
|
||||||
|
CoverUrl *string `json:"cover_url"`
|
||||||
|
Id openapi_types.UUID `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
TrackCount int `json:"track_count"`
|
||||||
|
} `json:"artists"`
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
TrackCount int `json:"track_count"`
|
||||||
|
} `json:"genres"`
|
||||||
|
Required bool `json:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnboardingChoice defines model for OnboardingChoice.
|
||||||
|
type OnboardingChoice struct {
|
||||||
|
ArtistIds []openapi_types.UUID `json:"artist_ids"`
|
||||||
|
Genres []string `json:"genres"`
|
||||||
|
}
|
||||||
|
|
||||||
// PathPattern defines model for PathPattern.
|
// PathPattern defines model for PathPattern.
|
||||||
type PathPattern struct {
|
type PathPattern struct {
|
||||||
IsEnabled bool `json:"is_enabled"`
|
IsEnabled bool `json:"is_enabled"`
|
||||||
@@ -1128,7 +1155,9 @@ type WaveFeedbackOutcome string
|
|||||||
|
|
||||||
// WaveMetrics Метрики волны за 7 дней
|
// WaveMetrics Метрики волны за 7 дней
|
||||||
type WaveMetrics struct {
|
type WaveMetrics struct {
|
||||||
EarlySkipRate *float32 `json:"early_skip_rate"`
|
// ByModel Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
|
||||||
|
ByModel []WaveModelMetrics `json:"by_model"`
|
||||||
|
EarlySkipRate *float32 `json:"early_skip_rate"`
|
||||||
|
|
||||||
// LikeRate Лайков на 100 треков
|
// LikeRate Лайков на 100 треков
|
||||||
LikeRate *float32 `json:"like_rate"`
|
LikeRate *float32 `json:"like_rate"`
|
||||||
@@ -1137,6 +1166,15 @@ type WaveMetrics struct {
|
|||||||
Sessions int `json:"sessions"`
|
Sessions int `json:"sessions"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WaveModelMetrics defines model for WaveModelMetrics.
|
||||||
|
type WaveModelMetrics struct {
|
||||||
|
EarlySkipRate *float32 `json:"early_skip_rate"`
|
||||||
|
LikeRate *float32 `json:"like_rate"`
|
||||||
|
ListenThroughRate *float32 `json:"listen_through_rate"`
|
||||||
|
ModelVer string `json:"model_ver"`
|
||||||
|
Served int `json:"served"`
|
||||||
|
}
|
||||||
|
|
||||||
// WaveNext defines model for WaveNext.
|
// WaveNext defines model for WaveNext.
|
||||||
type WaveNext struct {
|
type WaveNext struct {
|
||||||
Feedback *[]WaveFeedback `json:"feedback,omitempty"`
|
Feedback *[]WaveFeedback `json:"feedback,omitempty"`
|
||||||
@@ -1394,6 +1432,9 @@ type UpdateMeJSONRequestBody = UpdateMeRequest
|
|||||||
// SetBootstrapCredentialsJSONRequestBody defines body for SetBootstrapCredentials for application/json ContentType.
|
// SetBootstrapCredentialsJSONRequestBody defines body for SetBootstrapCredentials for application/json ContentType.
|
||||||
type SetBootstrapCredentialsJSONRequestBody = BootstrapCredentialsRequest
|
type SetBootstrapCredentialsJSONRequestBody = BootstrapCredentialsRequest
|
||||||
|
|
||||||
|
// SaveOnboardingJSONRequestBody defines body for SaveOnboarding for application/json ContentType.
|
||||||
|
type SaveOnboardingJSONRequestBody = OnboardingChoice
|
||||||
|
|
||||||
// ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType.
|
// ChangePasswordJSONRequestBody defines body for ChangePassword for application/json ContentType.
|
||||||
type ChangePasswordJSONRequestBody = ChangePasswordRequest
|
type ChangePasswordJSONRequestBody = ChangePasswordRequest
|
||||||
|
|
||||||
@@ -1426,6 +1467,9 @@ type ServerInterface interface {
|
|||||||
// Журнал действий (admin), новые сверху
|
// Журнал действий (admin), новые сверху
|
||||||
// (GET /api/v1/admin/audit)
|
// (GET /api/v1/admin/audit)
|
||||||
ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams)
|
ListAudit(w http.ResponseWriter, r *http.Request, params ListAuditParams)
|
||||||
|
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||||
|
// (POST /api/v1/admin/library/analysis/retry)
|
||||||
|
RetryAnalysis(w http.ResponseWriter, r *http.Request)
|
||||||
// Файлы, которые не удалось прочитать (admin)
|
// Файлы, которые не удалось прочитать (admin)
|
||||||
// (GET /api/v1/admin/library/errors)
|
// (GET /api/v1/admin/library/errors)
|
||||||
ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams)
|
ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams)
|
||||||
@@ -1564,6 +1608,9 @@ type ServerInterface interface {
|
|||||||
// Лайки, новые сверху
|
// Лайки, новые сверху
|
||||||
// (GET /api/v1/me/likes)
|
// (GET /api/v1/me/likes)
|
||||||
GetLikes(w http.ResponseWriter, r *http.Request, params GetLikesParams)
|
GetLikes(w http.ResponseWriter, r *http.Request, params GetLikesParams)
|
||||||
|
// Сохранить выбор онбординга (или пропуск — пустые списки)
|
||||||
|
// (PUT /api/v1/me/onboarding)
|
||||||
|
SaveOnboarding(w http.ResponseWriter, r *http.Request)
|
||||||
// Сменить пароль
|
// Сменить пароль
|
||||||
// (POST /api/v1/me/password)
|
// (POST /api/v1/me/password)
|
||||||
ChangePassword(w http.ResponseWriter, r *http.Request)
|
ChangePassword(w http.ResponseWriter, r *http.Request)
|
||||||
@@ -1585,6 +1632,9 @@ type ServerInterface interface {
|
|||||||
// Завершить сессию на устройстве
|
// Завершить сессию на устройстве
|
||||||
// (DELETE /api/v1/me/sessions/{id})
|
// (DELETE /api/v1/me/sessions/{id})
|
||||||
RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
|
RevokeMySession(w http.ResponseWriter, r *http.Request, id openapi_types.UUID)
|
||||||
|
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
||||||
|
// (GET /api/v1/onboarding)
|
||||||
|
GetOnboarding(w http.ResponseWriter, r *http.Request)
|
||||||
// Страница поиска
|
// Страница поиска
|
||||||
// (GET /api/v1/search)
|
// (GET /api/v1/search)
|
||||||
Search(w http.ResponseWriter, r *http.Request, params SearchParams)
|
Search(w http.ResponseWriter, r *http.Request, params SearchParams)
|
||||||
@@ -1651,6 +1701,12 @@ func (_ Unimplemented) ListAudit(w http.ResponseWriter, r *http.Request, params
|
|||||||
w.WriteHeader(http.StatusNotImplemented)
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||||
|
// (POST /api/v1/admin/library/analysis/retry)
|
||||||
|
func (_ Unimplemented) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
// Файлы, которые не удалось прочитать (admin)
|
// Файлы, которые не удалось прочитать (admin)
|
||||||
// (GET /api/v1/admin/library/errors)
|
// (GET /api/v1/admin/library/errors)
|
||||||
func (_ Unimplemented) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
|
func (_ Unimplemented) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
|
||||||
@@ -1927,6 +1983,12 @@ func (_ Unimplemented) GetLikes(w http.ResponseWriter, r *http.Request, params G
|
|||||||
w.WriteHeader(http.StatusNotImplemented)
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Сохранить выбор онбординга (или пропуск — пустые списки)
|
||||||
|
// (PUT /api/v1/me/onboarding)
|
||||||
|
func (_ Unimplemented) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
// Сменить пароль
|
// Сменить пароль
|
||||||
// (POST /api/v1/me/password)
|
// (POST /api/v1/me/password)
|
||||||
func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
func (_ Unimplemented) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1969,6 +2031,12 @@ func (_ Unimplemented) RevokeMySession(w http.ResponseWriter, r *http.Request, i
|
|||||||
w.WriteHeader(http.StatusNotImplemented)
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
||||||
|
// (GET /api/v1/onboarding)
|
||||||
|
func (_ Unimplemented) GetOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotImplemented)
|
||||||
|
}
|
||||||
|
|
||||||
// Страница поиска
|
// Страница поиска
|
||||||
// (GET /api/v1/search)
|
// (GET /api/v1/search)
|
||||||
func (_ Unimplemented) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
|
func (_ Unimplemented) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
|
||||||
@@ -2127,6 +2195,26 @@ func (siw *ServerInterfaceWrapper) ListAudit(w http.ResponseWriter, r *http.Requ
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryAnalysis operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.RetryAnalysis(w, r)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// ListLibraryErrors operation middleware
|
// ListLibraryErrors operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) ListLibraryErrors(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) ListLibraryErrors(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -3001,12 +3089,6 @@ func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
|
||||||
|
|
||||||
r = r.WithContext(ctx)
|
|
||||||
|
|
||||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
siw.Handler.DownloadAndroidApk(w, r, version)
|
siw.Handler.DownloadAndroidApk(w, r, version)
|
||||||
}))
|
}))
|
||||||
@@ -3021,12 +3103,6 @@ func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *
|
|||||||
// GetAndroidLatest operation middleware
|
// GetAndroidLatest operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) GetAndroidLatest(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) GetAndroidLatest(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
|
||||||
|
|
||||||
r = r.WithContext(ctx)
|
|
||||||
|
|
||||||
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
siw.Handler.GetAndroidLatest(w, r)
|
siw.Handler.GetAndroidLatest(w, r)
|
||||||
}))
|
}))
|
||||||
@@ -3519,6 +3595,26 @@ func (siw *ServerInterfaceWrapper) GetLikes(w http.ResponseWriter, r *http.Reque
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveOnboarding operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.SaveOnboarding(w, r)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// ChangePassword operation middleware
|
// ChangePassword operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -3692,6 +3788,26 @@ func (siw *ServerInterfaceWrapper) RevokeMySession(w http.ResponseWriter, r *htt
|
|||||||
handler.ServeHTTP(w, r)
|
handler.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOnboarding operation middleware
|
||||||
|
func (siw *ServerInterfaceWrapper) GetOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
|
||||||
|
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
|
||||||
|
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
siw.Handler.GetOnboarding(w, r)
|
||||||
|
}))
|
||||||
|
|
||||||
|
for _, middleware := range siw.HandlerMiddlewares {
|
||||||
|
handler = middleware(handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// Search operation middleware
|
// Search operation middleware
|
||||||
func (siw *ServerInterfaceWrapper) Search(w http.ResponseWriter, r *http.Request) {
|
func (siw *ServerInterfaceWrapper) Search(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
@@ -4359,6 +4475,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get(options.BaseURL+"/api/v1/admin/audit", wrapper.ListAudit)
|
r.Get(options.BaseURL+"/api/v1/admin/audit", wrapper.ListAudit)
|
||||||
})
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Post(options.BaseURL+"/api/v1/admin/library/analysis/retry", wrapper.RetryAnalysis)
|
||||||
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get(options.BaseURL+"/api/v1/admin/library/errors", wrapper.ListLibraryErrors)
|
r.Get(options.BaseURL+"/api/v1/admin/library/errors", wrapper.ListLibraryErrors)
|
||||||
})
|
})
|
||||||
@@ -4497,6 +4616,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get(options.BaseURL+"/api/v1/me/likes", wrapper.GetLikes)
|
r.Get(options.BaseURL+"/api/v1/me/likes", wrapper.GetLikes)
|
||||||
})
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Put(options.BaseURL+"/api/v1/me/onboarding", wrapper.SaveOnboarding)
|
||||||
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Post(options.BaseURL+"/api/v1/me/password", wrapper.ChangePassword)
|
r.Post(options.BaseURL+"/api/v1/me/password", wrapper.ChangePassword)
|
||||||
})
|
})
|
||||||
@@ -4518,6 +4640,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Delete(options.BaseURL+"/api/v1/me/sessions/{id}", wrapper.RevokeMySession)
|
r.Delete(options.BaseURL+"/api/v1/me/sessions/{id}", wrapper.RevokeMySession)
|
||||||
})
|
})
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Get(options.BaseURL+"/api/v1/onboarding", wrapper.GetOnboarding)
|
||||||
|
})
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Get(options.BaseURL+"/api/v1/search", wrapper.Search)
|
r.Get(options.BaseURL+"/api/v1/search", wrapper.Search)
|
||||||
})
|
})
|
||||||
@@ -4607,6 +4732,36 @@ func (response ListAuditdefaultApplicationProblemPlusJSONResponse) VisitListAudi
|
|||||||
return json.NewEncoder(w).Encode(response.Body)
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RetryAnalysisRequestObject struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetryAnalysisResponseObject interface {
|
||||||
|
VisitRetryAnalysisResponse(w http.ResponseWriter) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetryAnalysis200JSONResponse struct {
|
||||||
|
Requeued int `json:"requeued"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response RetryAnalysis200JSONResponse) VisitRetryAnalysisResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type RetryAnalysisdefaultApplicationProblemPlusJSONResponse struct {
|
||||||
|
Body Problem
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response RetryAnalysisdefaultApplicationProblemPlusJSONResponse) VisitRetryAnalysisResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/problem+json")
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
|
}
|
||||||
|
|
||||||
type ListLibraryErrorsRequestObject struct {
|
type ListLibraryErrorsRequestObject struct {
|
||||||
Params ListLibraryErrorsParams
|
Params ListLibraryErrorsParams
|
||||||
}
|
}
|
||||||
@@ -5956,6 +6111,34 @@ func (response GetLikesdefaultApplicationProblemPlusJSONResponse) VisitGetLikesR
|
|||||||
return json.NewEncoder(w).Encode(response.Body)
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SaveOnboardingRequestObject struct {
|
||||||
|
Body *SaveOnboardingJSONRequestBody
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveOnboardingResponseObject interface {
|
||||||
|
VisitSaveOnboardingResponse(w http.ResponseWriter) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveOnboarding204Response struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response SaveOnboarding204Response) VisitSaveOnboardingResponse(w http.ResponseWriter) error {
|
||||||
|
w.WriteHeader(204)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type SaveOnboardingdefaultApplicationProblemPlusJSONResponse struct {
|
||||||
|
Body Problem
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response SaveOnboardingdefaultApplicationProblemPlusJSONResponse) VisitSaveOnboardingResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/problem+json")
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
|
}
|
||||||
|
|
||||||
type ChangePasswordRequestObject struct {
|
type ChangePasswordRequestObject struct {
|
||||||
Body *ChangePasswordJSONRequestBody
|
Body *ChangePasswordJSONRequestBody
|
||||||
}
|
}
|
||||||
@@ -6155,6 +6338,34 @@ func (response RevokeMySessiondefaultApplicationProblemPlusJSONResponse) VisitRe
|
|||||||
return json.NewEncoder(w).Encode(response.Body)
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetOnboardingRequestObject struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetOnboardingResponseObject interface {
|
||||||
|
VisitGetOnboardingResponse(w http.ResponseWriter) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetOnboarding200JSONResponse Onboarding
|
||||||
|
|
||||||
|
func (response GetOnboarding200JSONResponse) VisitGetOnboardingResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetOnboardingdefaultApplicationProblemPlusJSONResponse struct {
|
||||||
|
Body Problem
|
||||||
|
StatusCode int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (response GetOnboardingdefaultApplicationProblemPlusJSONResponse) VisitGetOnboardingResponse(w http.ResponseWriter) error {
|
||||||
|
w.Header().Set("Content-Type", "application/problem+json")
|
||||||
|
w.WriteHeader(response.StatusCode)
|
||||||
|
|
||||||
|
return json.NewEncoder(w).Encode(response.Body)
|
||||||
|
}
|
||||||
|
|
||||||
type SearchRequestObject struct {
|
type SearchRequestObject struct {
|
||||||
Params SearchParams
|
Params SearchParams
|
||||||
}
|
}
|
||||||
@@ -6674,6 +6885,9 @@ type StrictServerInterface interface {
|
|||||||
// Журнал действий (admin), новые сверху
|
// Журнал действий (admin), новые сверху
|
||||||
// (GET /api/v1/admin/audit)
|
// (GET /api/v1/admin/audit)
|
||||||
ListAudit(ctx context.Context, request ListAuditRequestObject) (ListAuditResponseObject, error)
|
ListAudit(ctx context.Context, request ListAuditRequestObject) (ListAuditResponseObject, error)
|
||||||
|
// Вернуть в очередь треки, которые не удалось проанализировать (admin)
|
||||||
|
// (POST /api/v1/admin/library/analysis/retry)
|
||||||
|
RetryAnalysis(ctx context.Context, request RetryAnalysisRequestObject) (RetryAnalysisResponseObject, error)
|
||||||
// Файлы, которые не удалось прочитать (admin)
|
// Файлы, которые не удалось прочитать (admin)
|
||||||
// (GET /api/v1/admin/library/errors)
|
// (GET /api/v1/admin/library/errors)
|
||||||
ListLibraryErrors(ctx context.Context, request ListLibraryErrorsRequestObject) (ListLibraryErrorsResponseObject, error)
|
ListLibraryErrors(ctx context.Context, request ListLibraryErrorsRequestObject) (ListLibraryErrorsResponseObject, error)
|
||||||
@@ -6812,6 +7026,9 @@ type StrictServerInterface interface {
|
|||||||
// Лайки, новые сверху
|
// Лайки, новые сверху
|
||||||
// (GET /api/v1/me/likes)
|
// (GET /api/v1/me/likes)
|
||||||
GetLikes(ctx context.Context, request GetLikesRequestObject) (GetLikesResponseObject, error)
|
GetLikes(ctx context.Context, request GetLikesRequestObject) (GetLikesResponseObject, error)
|
||||||
|
// Сохранить выбор онбординга (или пропуск — пустые списки)
|
||||||
|
// (PUT /api/v1/me/onboarding)
|
||||||
|
SaveOnboarding(ctx context.Context, request SaveOnboardingRequestObject) (SaveOnboardingResponseObject, error)
|
||||||
// Сменить пароль
|
// Сменить пароль
|
||||||
// (POST /api/v1/me/password)
|
// (POST /api/v1/me/password)
|
||||||
ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error)
|
ChangePassword(ctx context.Context, request ChangePasswordRequestObject) (ChangePasswordResponseObject, error)
|
||||||
@@ -6833,6 +7050,9 @@ type StrictServerInterface interface {
|
|||||||
// Завершить сессию на устройстве
|
// Завершить сессию на устройстве
|
||||||
// (DELETE /api/v1/me/sessions/{id})
|
// (DELETE /api/v1/me/sessions/{id})
|
||||||
RevokeMySession(ctx context.Context, request RevokeMySessionRequestObject) (RevokeMySessionResponseObject, error)
|
RevokeMySession(ctx context.Context, request RevokeMySessionRequestObject) (RevokeMySessionResponseObject, error)
|
||||||
|
// Онбординг вкуса — исполнители и жанры библиотеки для выбора
|
||||||
|
// (GET /api/v1/onboarding)
|
||||||
|
GetOnboarding(ctx context.Context, request GetOnboardingRequestObject) (GetOnboardingResponseObject, error)
|
||||||
// Страница поиска
|
// Страница поиска
|
||||||
// (GET /api/v1/search)
|
// (GET /api/v1/search)
|
||||||
Search(ctx context.Context, request SearchRequestObject) (SearchResponseObject, error)
|
Search(ctx context.Context, request SearchRequestObject) (SearchResponseObject, error)
|
||||||
@@ -6944,6 +7164,30 @@ func (sh *strictHandler) ListAudit(w http.ResponseWriter, r *http.Request, param
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryAnalysis operation middleware
|
||||||
|
func (sh *strictHandler) RetryAnalysis(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request RetryAnalysisRequestObject
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||||
|
return sh.ssi.RetryAnalysis(ctx, request.(RetryAnalysisRequestObject))
|
||||||
|
}
|
||||||
|
for _, middleware := range sh.middlewares {
|
||||||
|
handler = middleware(handler, "RetryAnalysis")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := handler(r.Context(), w, r, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
} else if validResponse, ok := response.(RetryAnalysisResponseObject); ok {
|
||||||
|
if err := validResponse.VisitRetryAnalysisResponse(w); err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
}
|
||||||
|
} else if response != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ListLibraryErrors operation middleware
|
// ListLibraryErrors operation middleware
|
||||||
func (sh *strictHandler) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
|
func (sh *strictHandler) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
|
||||||
var request ListLibraryErrorsRequestObject
|
var request ListLibraryErrorsRequestObject
|
||||||
@@ -8220,6 +8464,37 @@ func (sh *strictHandler) GetLikes(w http.ResponseWriter, r *http.Request, params
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SaveOnboarding operation middleware
|
||||||
|
func (sh *strictHandler) SaveOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request SaveOnboardingRequestObject
|
||||||
|
|
||||||
|
var body SaveOnboardingJSONRequestBody
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||||
|
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Body = &body
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||||
|
return sh.ssi.SaveOnboarding(ctx, request.(SaveOnboardingRequestObject))
|
||||||
|
}
|
||||||
|
for _, middleware := range sh.middlewares {
|
||||||
|
handler = middleware(handler, "SaveOnboarding")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := handler(r.Context(), w, r, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
} else if validResponse, ok := response.(SaveOnboardingResponseObject); ok {
|
||||||
|
if err := validResponse.VisitSaveOnboardingResponse(w); err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
}
|
||||||
|
} else if response != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ChangePassword operation middleware
|
// ChangePassword operation middleware
|
||||||
func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
func (sh *strictHandler) ChangePassword(w http.ResponseWriter, r *http.Request) {
|
||||||
var request ChangePasswordRequestObject
|
var request ChangePasswordRequestObject
|
||||||
@@ -8415,6 +8690,30 @@ func (sh *strictHandler) RevokeMySession(w http.ResponseWriter, r *http.Request,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOnboarding operation middleware
|
||||||
|
func (sh *strictHandler) GetOnboarding(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var request GetOnboardingRequestObject
|
||||||
|
|
||||||
|
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
|
||||||
|
return sh.ssi.GetOnboarding(ctx, request.(GetOnboardingRequestObject))
|
||||||
|
}
|
||||||
|
for _, middleware := range sh.middlewares {
|
||||||
|
handler = middleware(handler, "GetOnboarding")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := handler(r.Context(), w, r, request)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
} else if validResponse, ok := response.(GetOnboardingResponseObject); ok {
|
||||||
|
if err := validResponse.VisitGetOnboardingResponse(w); err != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, err)
|
||||||
|
}
|
||||||
|
} else if response != nil {
|
||||||
|
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Search operation middleware
|
// Search operation middleware
|
||||||
func (sh *strictHandler) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
|
func (sh *strictHandler) Search(w http.ResponseWriter, r *http.Request, params SearchParams) {
|
||||||
var request SearchRequestObject
|
var request SearchRequestObject
|
||||||
@@ -8911,241 +9210,250 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Base64 encoded, gzipped, json marshaled Swagger object
|
// Base64 encoded, gzipped, json marshaled Swagger object
|
||||||
var swaggerSpec = []string{
|
var swaggerSpec = []string{
|
||||||
|
|
||||||
"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",
|
||||||
"YSFPodutPb77+P8FAAD//9wKjL6lLQEA",
|
"PS/jTmCDRAGB8NuecDw/B57YRxfVmVkTVJ08lRgJ9XN4bOL7ga/RbcO/CkwVDNsBBXgeCS9Dw9umio34",
|
||||||
|
"F663pZvt5PXGGXsD7tlrZNaD9eVmX39kr5EP+WOTbM4lvaZE6LSqmDpu4Qg9ailIQ1ErGjftF15D5/Lf",
|
||||||
|
"urz9Ao8lFiXlArHlxBK9h2AxtH0g+KSaE9prBN6hx3K4XOmLpBrkvHQJCeg/TiN9fL7y8mSLv4LBUDn7",
|
||||||
|
"7s/seo2eYgDqkB5HW98dlt+1wuyUd9x2vHWF+uo/v4okEbHu7eyq65W0xtJV3k6Yna+zge5ffHfNXAZP",
|
||||||
|
"YZVUxs6pROjkDmJXwKxGfvEYsMVuiuhRbLPFlm7SngzuFOi4KoJmYx6NWZdtwyQaVP2M3J+0UPwZZ6FX",
|
||||||
|
"xzLfVMsnkK7NO0SqfAh196JLECBtS4mrTMHjeFHbSq1sAmGoZ4tVYnfC1V/lKRLv8UcmWTMHr0BzNAc6",
|
||||||
|
"4VPe0xxd/fkBg3+CaEAcxD2VB1CzOuR4oU/s9nouNW7iE5OtOWuv59CCl6vv88DgZhKt5q5Ngf0s/O1X",
|
||||||
|
"5l6/sMl9DcUGcQhRkxGmnfgQGXiYnX7eHv8v0LZPsDGI2OkT+gwrnkRAKJG5IuVm25oqSb5pLaOoc3pQ",
|
||||||
|
"WyK2T/xr/XCVTZEJREx4QEHa9zu1+dosCEo+1oM4OohjPqw/kHupyp+7RPkW+g9IfxDNYaU/CZ+99Cee",
|
||||||
|
"kyH9JY6HyT9Mcqulv4KwkKfQ69Ue3nn4/wIAAP//8doNsq86AQA=",
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSwagger returns the content of the embedded swagger specification file
|
// GetSwagger returns the content of the embedded swagger specification file
|
||||||
|
|||||||
@@ -565,5 +565,6 @@ func TestPublicRoutes(t *testing.T) {
|
|||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
|
||||||
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
|
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
|
||||||
|
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
|
||||||
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
|
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
|
||||||
}
|
}
|
||||||
@@ -55,6 +55,8 @@ var publicRoutes = map[string]bool{
|
|||||||
"GET /api/v1/server-info": true,
|
"GET /api/v1/server-info": true,
|
||||||
"POST /api/v1/auth/login": true,
|
"POST /api/v1/auth/login": true,
|
||||||
"POST /api/v1/auth/refresh": true,
|
"POST /api/v1/auth/refresh": true,
|
||||||
|
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
|
||||||
|
"GET /api/v1/app/android/latest": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
|
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
|
||||||
@@ -90,7 +92,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
|
|||||||
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
|
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
|
||||||
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
|
||||||
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
|
||||||
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/"))
|
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
|
||||||
|
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
|
||||||
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ func TestLibraryAndUploads(t *testing.T) {
|
|||||||
assert.True(t, src.AcceptsUploads)
|
assert.True(t, src.AcceptsUploads)
|
||||||
require.NotNil(t, src.WriteTemplate)
|
require.NotNil(t, src.WriteTemplate)
|
||||||
|
|
||||||
scanner := library.NewScanner(lib, fakeProber{}, library.NewProgressBoard(), log)
|
board := library.NewProgressBoard()
|
||||||
|
scanner := library.NewScanner(lib, fakeProber{}, board, log)
|
||||||
var fileIDs []uuid.UUID
|
var fileIDs []uuid.UUID
|
||||||
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
|
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
|
||||||
id, err := scanner.ScanFile(ctx, src, root, rel)
|
id, err := scanner.ScanFile(ctx, src, root, rel)
|
||||||
@@ -114,6 +115,15 @@ func TestLibraryAndUploads(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, fileIDs[0], again, "rescan keeps the file row")
|
assert.Equal(t, fileIDs[0], again, "rescan keeps the file row")
|
||||||
|
|
||||||
|
// Пустой каталог при известных файлах (диск не смонтирован) — скан прерывается и ничего не помечает пропавшим.
|
||||||
|
require.NoError(t, scanner.Start(src, t.TempDir(), false))
|
||||||
|
require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 50*time.Millisecond)
|
||||||
|
p, _ := board.Get(src.ID)
|
||||||
|
assert.Contains(t, p.Error, "is the disk mounted")
|
||||||
|
var missing int
|
||||||
|
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM media_files WHERE source_id = $1 AND missing_since IS NOT NULL`, src.ID).Scan(&missing))
|
||||||
|
assert.Zero(t, missing)
|
||||||
|
|
||||||
// --- Отпечатки ---
|
// --- Отпечатки ---
|
||||||
todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10)
|
todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ func (fp *Fingerprinter) chromaprint() bool {
|
|||||||
func (fp *Fingerprinter) Run(ctx context.Context) {
|
func (fp *Fingerprinter) Run(ctx context.Context) {
|
||||||
withFP := fp.chromaprint()
|
withFP := fp.chromaprint()
|
||||||
roots := map[uuid.UUID]string{}
|
roots := map[uuid.UUID]string{}
|
||||||
|
unavailable := map[uuid.UUID]bool{} // каталог источника пуст или не открывается — диск не смонтирован
|
||||||
var after uuid.UUID
|
var after uuid.UUID
|
||||||
for ctx.Err() == nil {
|
for ctx.Err() == nil {
|
||||||
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
|
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
|
||||||
@@ -213,6 +214,9 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
after = f.ID
|
after = f.ID
|
||||||
|
if unavailable[f.SourceID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
root, ok := roots[f.SourceID]
|
root, ok := roots[f.SourceID]
|
||||||
if !ok {
|
if !ok {
|
||||||
if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
|
if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
|
||||||
@@ -220,12 +224,31 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
roots[f.SourceID] = root
|
roots[f.SourceID] = root
|
||||||
|
if empty, err := dirEmpty(root); empty || err != nil {
|
||||||
|
fp.log.Warn("fingerprints: source folder is empty or unreadable, skipped — is the disk mounted?",
|
||||||
|
"source_id", f.SourceID, "root", root, "err", err)
|
||||||
|
unavailable[f.SourceID] = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
}
|
}
|
||||||
fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP)
|
fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dirEmpty(dir string) (bool, error) {
|
||||||
|
d, err := os.Open(dir)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = d.Close() }()
|
||||||
|
names, err := d.Readdirnames(1)
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return len(names) == 0, err
|
||||||
|
}
|
||||||
|
|
||||||
func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) {
|
func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) {
|
||||||
sum := f.SHA256
|
sum := f.SHA256
|
||||||
if sum == nil {
|
if sum == nil {
|
||||||
|
|||||||
@@ -292,6 +292,15 @@ func (h *Handler) ScanSource(ctx context.Context, req gen.ScanSourceRequestObjec
|
|||||||
return gen.ScanSource202JSONResponse(progressDTO(p)), nil
|
return gen.ScanSource202JSONResponse(progressDTO(p)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryAnalysis реализует gen.StrictServerInterface.
|
||||||
|
func (h *Handler) RetryAnalysis(ctx context.Context, _ gen.RetryAnalysisRequestObject) (gen.RetryAnalysisResponseObject, error) {
|
||||||
|
n, err := h.svc.RetryAnalysis(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return gen.RetryAnalysis200JSONResponse{Requeued: n}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetLibraryStatus реализует gen.StrictServerInterface.
|
// GetLibraryStatus реализует gen.StrictServerInterface.
|
||||||
func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) {
|
func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) {
|
||||||
st, err := h.svc.Status(ctx)
|
st, err := h.svc.Status(ctx)
|
||||||
@@ -304,13 +313,19 @@ func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRe
|
|||||||
MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{},
|
MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{},
|
||||||
Analysis: gen.AnalysisStatus{
|
Analysis: gen.AnalysisStatus{
|
||||||
Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done,
|
Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done,
|
||||||
Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer,
|
Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, TopError: st.Analysis.TopError,
|
||||||
},
|
},
|
||||||
Wave: gen.WaveMetrics{
|
Wave: gen.WaveMetrics{
|
||||||
Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough,
|
Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough,
|
||||||
EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate,
|
EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate, ByModel: []gen.WaveModelMetrics{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
for _, m := range st.Wave.ByModel {
|
||||||
|
out.Wave.ByModel = append(out.Wave.ByModel, gen.WaveModelMetrics{
|
||||||
|
ModelVer: m.ModelVer, Served: m.Served, ListenThroughRate: m.ListenThrough,
|
||||||
|
EarlySkipRate: m.EarlySkip, LikeRate: m.LikeRate,
|
||||||
|
})
|
||||||
|
}
|
||||||
for _, s := range st.Sources {
|
for _, s := range st.Sources {
|
||||||
dto := h.sourceDTO(s)
|
dto := h.sourceDTO(s)
|
||||||
out.Sources = append(out.Sources, gen.SourceStatusItem{
|
out.Sources = append(out.Sources, gen.SourceStatusItem{
|
||||||
|
|||||||
@@ -119,6 +119,15 @@ type walked struct {
|
|||||||
hash []byte
|
hash []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasPresent(files map[string]FileRow) bool {
|
||||||
|
for _, f := range files {
|
||||||
|
if !f.Missing {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// job — файл, который нужно прочитать.
|
// job — файл, который нужно прочитать.
|
||||||
type job struct {
|
type job struct {
|
||||||
walked
|
walked
|
||||||
@@ -135,6 +144,11 @@ type probed struct {
|
|||||||
// ErrScanRunning — скан источника уже идёт.
|
// ErrScanRunning — скан источника уже идёт.
|
||||||
var ErrScanRunning = errors.New("library: scan already running")
|
var ErrScanRunning = errors.New("library: scan already running")
|
||||||
|
|
||||||
|
// ErrSourceEmpty — в каталоге источника нет ни одного файла, а в библиотеке они есть: скорее всего, диск
|
||||||
|
// не смонтирован. Скан прерывается, чтобы не пометить всю библиотеку отсутствующей (и через MissingRetention —
|
||||||
|
// не удалить треки вместе с историей).
|
||||||
|
var ErrSourceEmpty = errors.New("library: source folder is empty but the library has its files — is the disk mounted?")
|
||||||
|
|
||||||
// Start запускает скан в фоне. root — каталог источника в файловой системе api.
|
// Start запускает скан в фоне. root — каталог источника в файловой системе api.
|
||||||
func (sc *Scanner) Start(src Source, root string, full bool) error {
|
func (sc *Scanner) Start(src Source, root string, full bool) error {
|
||||||
if !sc.board.start(src.ID, full, sc.now()) {
|
if !sc.board.start(src.ID, full, sc.now()) {
|
||||||
@@ -192,6 +206,9 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if len(found) == 0 && hasPresent(existing) {
|
||||||
|
return ErrSourceEmpty
|
||||||
|
}
|
||||||
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
|
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|||||||
@@ -587,6 +587,15 @@ func (s *Service) previewOne(ctx context.Context, root, rel string, patterns []m
|
|||||||
}
|
}
|
||||||
|
|
||||||
// StartScan запускает скан источника в фоне.
|
// StartScan запускает скан источника в фоне.
|
||||||
|
// RetryAnalysis возвращает в очередь треки, которые анализатор не смог обработать (например, пока диск
|
||||||
|
// не был смонтирован).
|
||||||
|
func (s *Service) RetryAnalysis(ctx context.Context) (int, error) {
|
||||||
|
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.store.RetryFailedAnalysis(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
|
func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
|
||||||
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
|
||||||
return Progress{}, err
|
return Progress{}, err
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string
|
|||||||
// AnalysisCounts — состояние очереди аудиоанализа.
|
// AnalysisCounts — состояние очереди аудиоанализа.
|
||||||
type AnalysisCounts struct {
|
type AnalysisCounts struct {
|
||||||
Queued, Running, Done, Failed int
|
Queued, Running, Done, Failed int
|
||||||
AnalyzerVer *string
|
AnalyzerVer, TopError *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора.
|
// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора.
|
||||||
@@ -614,20 +614,59 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
|
|||||||
err := st.pool.QueryRow(ctx, `
|
err := st.pool.QueryRow(ctx, `
|
||||||
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
|
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
|
||||||
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
|
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
|
||||||
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1)
|
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
|
||||||
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer)
|
(SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL
|
||||||
|
GROUP BY error ORDER BY count(*) DESC LIMIT 1)
|
||||||
|
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
|
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
|
||||||
}
|
}
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetryFailedAnalysis возвращает неудавшиеся задачи анализа в очередь с нуля попыток.
|
||||||
|
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
|
||||||
|
tag, err := st.pool.Exec(ctx, `
|
||||||
|
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
|
||||||
|
WHERE status = 'failed'`)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("library: retry analysis: %w", err)
|
||||||
|
}
|
||||||
|
return int(tag.RowsAffected()), nil
|
||||||
|
}
|
||||||
|
|
||||||
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
|
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
|
||||||
type WaveMetrics struct {
|
type WaveMetrics struct {
|
||||||
Served, Sessions int
|
Served, Sessions int
|
||||||
|
WaveRates
|
||||||
|
ByModel []WaveModelMetrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaveRates — доли по показам с известным исходом (nil — исходов ещё нет).
|
||||||
|
type WaveRates struct {
|
||||||
ListenThrough, EarlySkip, LikeRate *float32
|
ListenThrough, EarlySkip, LikeRate *float32
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WaveModelMetrics — метрики показов одной версии модели.
|
||||||
|
type WaveModelMetrics struct {
|
||||||
|
ModelVer string
|
||||||
|
Served int
|
||||||
|
WaveRates
|
||||||
|
}
|
||||||
|
|
||||||
|
func rates(judged, through, early, liked int) WaveRates {
|
||||||
|
if judged == 0 {
|
||||||
|
return WaveRates{}
|
||||||
|
}
|
||||||
|
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
|
||||||
|
return WaveRates{ListenThrough: <, EarlySkip: &es, LikeRate: &lr}
|
||||||
|
}
|
||||||
|
|
||||||
|
const waveOutcomes = `count(outcome),
|
||||||
|
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
|
||||||
|
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
|
||||||
|
count(*) FILTER (WHERE outcome = 'like')`
|
||||||
|
|
||||||
// WaveMetrics считает метрики по показам с известным исходом.
|
// WaveMetrics считает метрики по показам с известным исходом.
|
||||||
func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
|
func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
|
||||||
var (
|
var (
|
||||||
@@ -635,20 +674,32 @@ func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
|
|||||||
judged, through, early, liked int
|
judged, through, early, liked int
|
||||||
)
|
)
|
||||||
err := st.pool.QueryRow(ctx, `
|
err := st.pool.QueryRow(ctx, `
|
||||||
SELECT count(*), count(DISTINCT wave_session_id), count(outcome),
|
SELECT count(*), count(DISTINCT wave_session_id), `+waveOutcomes+`
|
||||||
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
|
|
||||||
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
|
|
||||||
count(*) FILTER (WHERE outcome = 'like')
|
|
||||||
FROM wave_impressions WHERE served_at > now() - interval '7 days'`).
|
FROM wave_impressions WHERE served_at > now() - interval '7 days'`).
|
||||||
Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked)
|
Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err)
|
return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err)
|
||||||
}
|
}
|
||||||
if judged > 0 {
|
m.WaveRates = rates(judged, through, early, liked)
|
||||||
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
|
|
||||||
m.ListenThrough, m.EarlySkip, m.LikeRate = <, &es, &lr
|
rows, err := st.pool.Query(ctx, `
|
||||||
|
SELECT coalesce(model_ver, ''), count(*), `+waveOutcomes+`
|
||||||
|
FROM wave_impressions WHERE served_at > now() - interval '7 days'
|
||||||
|
GROUP BY 1 ORDER BY 2 DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
|
||||||
}
|
}
|
||||||
return m, nil
|
defer rows.Close()
|
||||||
|
m.ByModel = []WaveModelMetrics{}
|
||||||
|
for rows.Next() {
|
||||||
|
var mm WaveModelMetrics
|
||||||
|
if err := rows.Scan(&mm.ModelVer, &mm.Served, &judged, &through, &early, &liked); err != nil {
|
||||||
|
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
|
||||||
|
}
|
||||||
|
mm.WaveRates = rates(judged, through, early, liked)
|
||||||
|
m.ByModel = append(m.ByModel, mm)
|
||||||
|
}
|
||||||
|
return m, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// FingerprintTodo — файл, которому нужны отпечатки.
|
// FingerprintTodo — файл, которому нужны отпечатки.
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func (m *Meili) wait(ctx context.Context, t taskRef) error {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Error *struct {
|
Error *struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
|
Code string `json:"code"`
|
||||||
} `json:"error"`
|
} `json:"error"`
|
||||||
}
|
}
|
||||||
if err := m.do(ctx, http.MethodGet, fmt.Sprintf("/tasks/%d", t.TaskUID), nil, &task); err != nil {
|
if err := m.do(ctx, http.MethodGet, fmt.Sprintf("/tasks/%d", t.TaskUID), nil, &task); err != nil {
|
||||||
@@ -88,6 +89,10 @@ func (m *Meili) wait(ctx context.Context, t taskRef) error {
|
|||||||
case "failed", "canceled":
|
case "failed", "canceled":
|
||||||
msg := task.Status
|
msg := task.Status
|
||||||
if task.Error != nil {
|
if task.Error != nil {
|
||||||
|
// Удаление отсутствующего индекса принимается (202) и падает уже в задаче.
|
||||||
|
if task.Error.Code == "index_not_found" {
|
||||||
|
return fmt.Errorf("%w: task %d: %s", ErrMeiliNotFound, t.TaskUID, task.Error.Message)
|
||||||
|
}
|
||||||
msg = task.Error.Message
|
msg = task.Error.Message
|
||||||
}
|
}
|
||||||
return fmt.Errorf("meilisearch: task %d: %s", t.TaskUID, msg)
|
return fmt.Errorf("meilisearch: task %d: %s", t.TaskUID, msg)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package search
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// На чистом Meilisearch удаление несуществующего индекса падает в задаче, а не ответом 404.
|
||||||
|
func TestCallMapsFailedIndexTaskToNotFound(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.Method == http.MethodDelete {
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = w.Write([]byte(`{"taskUid":0}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"status":"failed","error":{"code":"index_not_found","message":"Index ` + "`x`" + ` not found."}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := NewMeili(srv.URL, "").call(context.Background(), http.MethodDelete, "/indexes/x", nil)
|
||||||
|
if !errors.Is(err, ErrMeiliNotFound) {
|
||||||
|
t.Fatalf("err = %v, want ErrMeiliNotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -28,12 +28,15 @@ type Handler struct {
|
|||||||
meili *Meili
|
meili *Meili
|
||||||
catalog *library.Store
|
catalog *library.Store
|
||||||
indexer *Indexer
|
indexer *Indexer
|
||||||
|
aff *Affinity
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler создаёт обработчик поиска.
|
// NewHandler создаёт обработчик поиска.
|
||||||
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, dto library.DTO, log *slog.Logger) *Handler {
|
func NewHandler(
|
||||||
return &Handler{meili: meili, catalog: catalog, indexer: indexer, dto: dto, log: log}
|
meili *Meili, catalog *library.Store, indexer *Indexer, aff *Affinity, dto library.DTO, log *slog.Logger,
|
||||||
|
) *Handler {
|
||||||
|
return &Handler{meili: meili, catalog: catalog, indexer: indexer, aff: aff, dto: dto, log: log}
|
||||||
}
|
}
|
||||||
|
|
||||||
type limits struct{ artists, albums, tracks, offset int }
|
type limits struct{ artists, albums, tracks, offset int }
|
||||||
@@ -150,10 +153,16 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
|
|||||||
|
|
||||||
// SearchSuggest реализует gen.StrictServerInterface.
|
// SearchSuggest реализует gen.StrictServerInterface.
|
||||||
func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) {
|
func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) {
|
||||||
f, corrected, err := h.run(ctx, req.Params.Q, limits{artists: 3, albums: 3, tracks: 5})
|
want := limits{artists: 3, albums: 3, tracks: 5}
|
||||||
|
l := want
|
||||||
|
if h.aff != nil {
|
||||||
|
l = limits{artists: want.artists * suggestPool, albums: want.albums * suggestPool, tracks: want.tracks * suggestPool}
|
||||||
|
}
|
||||||
|
f, corrected, err := h.run(ctx, req.Params.Q, l)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
f = h.personal(ctx, f, want)
|
||||||
out, err := h.hydrate(ctx, f, corrected)
|
out, err := h.hydrate(ctx, f, corrected)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -161,6 +170,23 @@ func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestReques
|
|||||||
return gen.SearchSuggest200JSONResponse(out), nil
|
return gen.SearchSuggest200JSONResponse(out), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// personal оставляет в подсказках по want.* лучших с учётом вкуса пользователя.
|
||||||
|
// Без пользователя или при сбое — просто первые по релевантности.
|
||||||
|
func (h *Handler) personal(ctx context.Context, f found, want limits) found {
|
||||||
|
var aff map[uuid.UUID]float64
|
||||||
|
if p, ok := auth.PrincipalFrom(ctx); ok && h.aff != nil {
|
||||||
|
all := ids(append(append(append([]string{}, f.artists...), f.albums...), f.tracks...))
|
||||||
|
var err error
|
||||||
|
if aff, err = h.aff.Of(ctx, p.UserID, all); err != nil {
|
||||||
|
h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.artists = personalize(f.artists, aff, want.artists)
|
||||||
|
f.albums = personalize(f.albums, aff, want.albums)
|
||||||
|
f.tracks = personalize(f.tracks, aff, want.tracks)
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
// Search реализует gen.StrictServerInterface.
|
// Search реализует gen.StrictServerInterface.
|
||||||
func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) {
|
func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) {
|
||||||
typ := gen.All
|
typ := gen.All
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package search
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,3 +22,14 @@ func TestSwitchLayout(t *testing.T) {
|
|||||||
assert.Equal(t, "кино", SwitchLayout("rbyj"))
|
assert.Equal(t, "кино", SwitchLayout("rbyj"))
|
||||||
assert.Equal(t, "", SwitchLayout("123"))
|
assert.Equal(t, "", SwitchLayout("123"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPersonalizeLiftsFamiliarAndSinksDisliked(t *testing.T) {
|
||||||
|
liked, disliked := uuid.New(), uuid.New()
|
||||||
|
other := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
|
||||||
|
found := []string{other[0], disliked.String(), other[1], other[2], liked.String()}
|
||||||
|
aff := map[uuid.UUID]float64{liked: 30, disliked: -10}
|
||||||
|
|
||||||
|
got := personalize(found, aff, 3)
|
||||||
|
assert.Equal(t, []string{liked.String(), other[0], other[1]}, got)
|
||||||
|
assert.Equal(t, found[:2], personalize(found, nil, 2), "без вкуса — порядок релевантности")
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package storageagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canWrite — запасная проверка для не-Unix (только разработка): бит записи у каталога.
|
||||||
|
func canWrite(dir string) error {
|
||||||
|
info, err := os.Stat(dir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if info.Mode().Perm()&0o200 == 0 {
|
||||||
|
return errors.New("directory is not writable")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package storageagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canWrite — можно ли создавать файлы в каталоге (без пробной записи); read-only монтирование — ошибка.
|
||||||
|
func canWrite(dir string) error {
|
||||||
|
var st unix.Statfs_t
|
||||||
|
if err := unix.Statfs(dir, &st); err == nil && st.Flags&unix.ST_RDONLY != 0 {
|
||||||
|
return errors.New("filesystem is mounted read-only")
|
||||||
|
}
|
||||||
|
return unix.Access(dir, unix.W_OK|unix.X_OK)
|
||||||
|
}
|
||||||
@@ -142,27 +142,19 @@ func (a *agent) writeFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, res)
|
writeJSON(w, http.StatusOK, res)
|
||||||
}
|
}
|
||||||
|
|
||||||
// write копирует staging во временный файл рядом с целью, fsync, сверяет SHA-256 и создаёт итоговое имя
|
// write копирует staging сразу под итоговым именем (O_EXCL — существующий файл не перезаписывается, при
|
||||||
// жёсткой ссылкой (не перезаписывает существующий файл, в отличие от rename), затем убирает временный.
|
// конфликте — « (2)»), fsync, сверяет SHA-256 копии. На музыкальный диск не попадает ничего, кроме самого трека:
|
||||||
|
// ни временных файлов, ни служебных пометок. При любой ошибке недописанный файл и созданные для него пустые
|
||||||
|
// каталоги удаляются — агент убирает только то, что создал сам.
|
||||||
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
|
func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, error) {
|
||||||
target := filepath.Join(root, filepath.FromSlash(rel))
|
dir := filepath.Dir(filepath.Join(root, filepath.FromSlash(rel)))
|
||||||
dir := filepath.Dir(target)
|
created, err := a.mkdirs(root, dir)
|
||||||
if err := a.mkdirs(root, dir); err != nil {
|
|
||||||
return WriteResult{}, err
|
|
||||||
}
|
|
||||||
tmp := filepath.Join(dir, "."+filepath.Base(target)+".pipu-tmp")
|
|
||||||
size, err := a.copyTemp(staging, tmp)
|
|
||||||
if err != nil {
|
|
||||||
_ = os.Remove(tmp)
|
|
||||||
return WriteResult{}, err
|
|
||||||
}
|
|
||||||
defer func() { _ = os.Remove(tmp) }()
|
|
||||||
got, err := fileSHA256(tmp)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return WriteResult{}, err
|
return WriteResult{}, err
|
||||||
}
|
}
|
||||||
if !bytes.Equal(got, want) {
|
fail := func(err error) (WriteResult, error) {
|
||||||
return WriteResult{}, fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
|
removeEmpty(created)
|
||||||
|
return WriteResult{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ext := path.Ext(rel)
|
ext := path.Ext(rel)
|
||||||
@@ -172,24 +164,39 @@ func (a *agent) write(root, rel, staging string, want []byte) (WriteResult, erro
|
|||||||
if n > 1 {
|
if n > 1 {
|
||||||
name = fmt.Sprintf("%s (%d)%s", base, n, ext)
|
name = fmt.Sprintf("%s (%d)%s", base, n, ext)
|
||||||
}
|
}
|
||||||
err := os.Link(tmp, filepath.Join(root, filepath.FromSlash(name)))
|
target := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
dst, err := os.OpenFile(target, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
||||||
|
if os.IsExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fail(fmt.Errorf("create: %w", err))
|
||||||
|
}
|
||||||
|
size, err := copyInto(dst, staging)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
syncDir(dir)
|
var got []byte
|
||||||
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
|
if got, err = fileSHA256(target); err == nil && !bytes.Equal(got, want) {
|
||||||
|
err = fmt.Errorf("checksum mismatch: copy %x, expected %x", got, want)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !os.IsExist(err) {
|
if err != nil {
|
||||||
return WriteResult{}, fmt.Errorf("link: %w", err)
|
_ = os.Remove(target) // наш собственный недописанный / испорченный файл
|
||||||
|
return fail(err)
|
||||||
}
|
}
|
||||||
|
a.chown(target)
|
||||||
|
syncDir(dir)
|
||||||
|
return WriteResult{RelPath: name, Size: size, Renamed: n > 1}, nil
|
||||||
}
|
}
|
||||||
return WriteResult{}, errors.New("too many files with the same name")
|
return fail(errors.New("too many files with the same name"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID).
|
// mkdirs создаёт недостающие каталоги от root до dir (0755, владелец — FileUID/FileGID) и возвращает созданные.
|
||||||
func (a *agent) mkdirs(root, dir string) error {
|
func (a *agent) mkdirs(root, dir string) ([]string, error) {
|
||||||
rel, err := filepath.Rel(root, dir)
|
rel, err := filepath.Rel(root, dir)
|
||||||
if err != nil || strings.HasPrefix(rel, "..") {
|
if err != nil || strings.HasPrefix(rel, "..") {
|
||||||
return errBadPath
|
return nil, errBadPath
|
||||||
}
|
}
|
||||||
|
var created []string
|
||||||
cur := root
|
cur := root
|
||||||
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
|
for _, seg := range strings.Split(rel, string(filepath.Separator)) {
|
||||||
if seg == "." || seg == "" {
|
if seg == "." || seg == "" {
|
||||||
@@ -200,24 +207,30 @@ func (a *agent) mkdirs(root, dir string) error {
|
|||||||
if os.IsExist(err) {
|
if os.IsExist(err) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return fmt.Errorf("mkdir: %w", err)
|
removeEmpty(created)
|
||||||
|
return nil, fmt.Errorf("mkdir: %w", err)
|
||||||
}
|
}
|
||||||
|
created = append(created, cur)
|
||||||
a.chown(cur)
|
a.chown(cur)
|
||||||
}
|
}
|
||||||
return nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *agent) copyTemp(staging, tmp string) (int64, error) {
|
// removeEmpty удаляет созданные каталоги от глубоких к верхним; os.Remove не трогает непустые.
|
||||||
|
func removeEmpty(dirs []string) {
|
||||||
|
for i := len(dirs) - 1; i >= 0; i-- {
|
||||||
|
_ = os.Remove(dirs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyInto копирует staging в открытый файл и делает fsync; файл закрывается.
|
||||||
|
func copyInto(dst *os.File, staging string) (int64, error) {
|
||||||
src, err := os.Open(staging)
|
src, err := os.Open(staging)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
_ = dst.Close()
|
||||||
return 0, fmt.Errorf("staging: %w", err)
|
return 0, fmt.Errorf("staging: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = src.Close() }()
|
defer func() { _ = src.Close() }()
|
||||||
_ = os.Remove(tmp) // остаток прерванной попытки
|
|
||||||
dst, err := os.OpenFile(tmp, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("create temp: %w", err)
|
|
||||||
}
|
|
||||||
n, err := io.Copy(dst, src)
|
n, err := io.Copy(dst, src)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = dst.Sync()
|
err = dst.Sync()
|
||||||
@@ -228,7 +241,6 @@ func (a *agent) copyTemp(staging, tmp string) (int64, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, fmt.Errorf("copy: %w", err)
|
return 0, fmt.Errorf("copy: %w", err)
|
||||||
}
|
}
|
||||||
a.chown(tmp)
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +252,8 @@ func (a *agent) chown(p string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testWrite создаёт и удаляет .pipu-write-test в корне источника на запись.
|
// testWrite проверяет право записи в корень источника, ничего не создавая на диске (access(W_OK);
|
||||||
|
// на NFS ответ даёт сам сервер с учётом root_squash и прав экспорта).
|
||||||
func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
|
func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
|
||||||
var req WriteTestRequest
|
var req WriteTestRequest
|
||||||
if !a.decode(w, r, &req) {
|
if !a.decode(w, r, &req) {
|
||||||
@@ -251,16 +264,7 @@ func (a *agent) testWrite(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()})
|
writeJSON(w, http.StatusOK, WriteTestResult{Message: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
p := filepath.Join(root, ".pipu-write-test")
|
if err := canWrite(root); err != nil {
|
||||||
f, err := os.OpenFile(p, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
|
|
||||||
if err == nil {
|
|
||||||
_, err = f.WriteString("pipu")
|
|
||||||
if cerr := f.Close(); err == nil {
|
|
||||||
err = cerr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ = os.Remove(p)
|
|
||||||
if err != nil {
|
|
||||||
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
|
writeJSON(w, http.StatusOK, WriteTestResult{Message: "cannot write: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -74,6 +75,7 @@ func TestWriteFile(t *testing.T) {
|
|||||||
assert.True(t, res.Renamed)
|
assert.True(t, res.Renamed)
|
||||||
entries, _ := os.ReadDir(dir)
|
entries, _ := os.ReadDir(dir)
|
||||||
assert.Len(t, entries, 2)
|
assert.Len(t, entries, 2)
|
||||||
|
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
|
||||||
|
|
||||||
// Local-источник пишется в LIBRARY_HOST.
|
// Local-источник пишется в LIBRARY_HOST.
|
||||||
local := "music"
|
local := "music"
|
||||||
@@ -103,8 +105,21 @@ func TestWriteFileRejects(t *testing.T) {
|
|||||||
other := sha256.Sum256([]byte("other"))
|
other := sha256.Sum256([]byte("other"))
|
||||||
rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])})
|
rec := e.post("/internal/files", WriteRequest{SourceID: srcID, RelPath: "A/x.flac", StagingPath: "u1/f1", SHA256: hex.EncodeToString(other[:])})
|
||||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||||
entries, _ := os.ReadDir(filepath.Join(e.paths.LibraryRW, srcID, "A"))
|
// Созданная под файл папка тоже убрана: на диске не остаётся следов неудачной записи.
|
||||||
assert.Empty(t, entries)
|
assert.NoDirExists(t, filepath.Join(e.paths.LibraryRW, srcID, "A"))
|
||||||
|
assertOnlyMusic(t, filepath.Join(e.paths.LibraryRW, srcID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertOnlyMusic — в дереве хранилища только аудиофайлы и каталоги: ни временных, ни служебных файлов.
|
||||||
|
func assertOnlyMusic(t *testing.T, root string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
assert.True(t, audioExt[strings.ToLower(filepath.Ext(p))] && !strings.HasPrefix(d.Name(), "."), "unexpected file %s", p)
|
||||||
|
return nil
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWritableMount(t *testing.T) {
|
func TestWritableMount(t *testing.T) {
|
||||||
@@ -120,7 +135,8 @@ func TestWritableMount(t *testing.T) {
|
|||||||
var res WriteTestResult
|
var res WriteTestResult
|
||||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &res))
|
||||||
assert.True(t, res.OK, res.Message)
|
assert.True(t, res.OK, res.Message)
|
||||||
assert.NoFileExists(t, filepath.Join(rw, ".pipu-write-test"))
|
entries, _ := os.ReadDir(rw)
|
||||||
|
assert.Empty(t, entries, "write check creates nothing")
|
||||||
|
|
||||||
// Без accepts_uploads rw-точка снимается.
|
// Без accepts_uploads rw-точка снимается.
|
||||||
e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}})
|
e.post("/internal/mounts", MountRequest{SourceID: srcID, NFSSpec: NFSSpec{Server: "nas", Export: "/music"}})
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -15,8 +15,8 @@ COPY services/recommender/ ./
|
|||||||
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
|
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
# ffmpeg декодирует аудио для анализатора.
|
# ffmpeg декодирует аудио для анализатора; libgomp1 нужен LightGBM (ранжировщик волны).
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu
|
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libgomp1 && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu
|
||||||
COPY --from=build /app /app
|
COPY --from=build /app /app
|
||||||
ARG VERSION=0.0.0-dev
|
ARG VERSION=0.0.0-dev
|
||||||
ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1
|
ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ log = logging.getLogger(__name__)
|
|||||||
MAX_ATTEMPTS = 3
|
MAX_ATTEMPTS = 3
|
||||||
MAX_DURATION_S = 15 * 60
|
MAX_DURATION_S = 15 * 60
|
||||||
IDLE_SLEEP_S = 10.0
|
IDLE_SLEEP_S = 10.0
|
||||||
|
MISSING_SLEEP_S = 60.0 # файлы пропали целиком — ждём, пока смонтируют диск
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -168,6 +169,18 @@ class Worker:
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
def postpone(self, conn: psycopg.Connection[Any], track_ids: list[str]) -> None:
|
||||||
|
"""Файла нет на диске (не смонтирован?) — обратно в очередь, попытка не засчитывается."""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE analysis_jobs SET status = 'queued', attempts = greatest(attempts - 1, 0),
|
||||||
|
error = 'file not found — is the disk mounted?', updated_at = now()
|
||||||
|
WHERE track_id = ANY(%s::uuid[])
|
||||||
|
""",
|
||||||
|
(track_ids,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
def requeue_stale(self, conn: psycopg.Connection[Any]) -> None:
|
def requeue_stale(self, conn: psycopg.Connection[Any]) -> None:
|
||||||
"""Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь."""
|
"""Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь."""
|
||||||
conn.execute(
|
conn.execute(
|
||||||
@@ -189,6 +202,16 @@ class Worker:
|
|||||||
self.requeue_stale(conn)
|
self.requeue_stale(conn)
|
||||||
while True:
|
while True:
|
||||||
jobs = self.claim(conn, self.workers * 2)
|
jobs = self.claim(conn, self.workers * 2)
|
||||||
|
missing = [j for j in jobs if not Path(j.path).exists()]
|
||||||
|
if missing:
|
||||||
|
self.postpone(conn, [j.track_id for j in missing])
|
||||||
|
jobs = [j for j in jobs if j not in missing]
|
||||||
|
if not jobs:
|
||||||
|
log.warning(
|
||||||
|
"analysis: %d files not found, waiting — is the disk mounted?", len(missing)
|
||||||
|
)
|
||||||
|
time.sleep(MISSING_SLEEP_S)
|
||||||
|
continue
|
||||||
if not jobs:
|
if not jobs:
|
||||||
time.sleep(IDLE_SLEEP_S)
|
time.sleep(IDLE_SLEEP_S)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def _serve(settings: Settings) -> None:
|
|||||||
wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path))
|
wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path))
|
||||||
if settings.train_hour >= 0:
|
if settings.train_hour >= 0:
|
||||||
# После активации новой модели каталог перечитывается сразу — горячая подмена векторов.
|
# После активации новой модели каталог перечитывается сразу — горячая подмена векторов.
|
||||||
NightlyTrainer(settings.database_url, settings.train_hour, on_done=wave.index.refresh).start()
|
NightlyTrainer(settings.database_url, settings.train_hour, on_done=wave.reload).start()
|
||||||
app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave)
|
app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave)
|
||||||
uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level)
|
uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Бандит по источникам кандидатов (docs/recommendations.md#6.6): Beta(α, β) на пару (пользователь, источник).
|
||||||
|
|
||||||
|
Награда — трек из источника доигран или лайкнут; скип и дизлайк — неудача. Наблюдения затухают ×0.99 в день,
|
||||||
|
чтобы бандит подстраивался под смену вкуса. Доля «нового» (exploration) следует за успехом источника `fresh`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import psycopg
|
||||||
|
|
||||||
|
DAILY_DECAY = 0.99
|
||||||
|
REWARD = {"complete": 1.0, "like": 1.0, "skip": 0.0, "skip_early": 0.0, "dislike": 0.0}
|
||||||
|
EXPLORATION_MIN, EXPLORATION_MAX = 0.05, 0.4
|
||||||
|
|
||||||
|
Params = dict[str, list[float]] # источник → [α, β]
|
||||||
|
|
||||||
|
|
||||||
|
def compute(rows: list[tuple[str, str, str, float]], now: float) -> dict[str, Params]:
|
||||||
|
"""rows: (user, source, outcome, served_at) → user → source → [α, β] с приором Beta(1, 1)."""
|
||||||
|
out: dict[str, Params] = defaultdict(dict)
|
||||||
|
for user, source, outcome, ts in rows:
|
||||||
|
r = REWARD.get(outcome)
|
||||||
|
if r is None:
|
||||||
|
continue
|
||||||
|
w = DAILY_DECAY ** max((now - ts) / 86400.0, 0.0)
|
||||||
|
ab = out[user].setdefault(source, [1.0, 1.0])
|
||||||
|
ab[0] += w * r
|
||||||
|
ab[1] += w * (1.0 - r)
|
||||||
|
return {u: {s: [round(a, 4), round(b, 4)] for s, (a, b) in p.items()} for u, p in out.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def exploration(params: Params, default: float = 0.15) -> float:
|
||||||
|
"""Доля «нового»: успешнее `fresh` относительно остальных источников — больше исследования."""
|
||||||
|
fresh = params.get("fresh")
|
||||||
|
others = [a / (a + b) for s, (a, b) in params.items() if s != "fresh"]
|
||||||
|
if fresh is None or not others:
|
||||||
|
return default
|
||||||
|
rate = fresh[0] / (fresh[0] + fresh[1])
|
||||||
|
base = sum(others) / len(others)
|
||||||
|
value = default * (rate / base if base > 0 else 1.0)
|
||||||
|
return round(min(max(value, EXPLORATION_MIN), EXPLORATION_MAX), 3)
|
||||||
|
|
||||||
|
|
||||||
|
def update_bandits(conn: psycopg.Connection[Any], now: float) -> int:
|
||||||
|
"""Пересчитывает параметры по показам волны за 90 дней. Возвращает число пользователей."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT ws.user_id::text, wi.candidate_source, wi.outcome, extract(epoch FROM wi.served_at)::float8
|
||||||
|
FROM wave_impressions wi JOIN wave_sessions ws ON ws.id = wi.wave_session_id
|
||||||
|
WHERE wi.outcome IS NOT NULL AND wi.served_at > now() - interval '90 days'
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
params = compute([(u, s, o, float(ts)) for u, s, o, ts in rows], now)
|
||||||
|
with conn.transaction(), conn.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"""
|
||||||
|
INSERT INTO user_profiles_rec (user_id, bandit, exploration, updated_at)
|
||||||
|
VALUES (%s, %s, %s, now())
|
||||||
|
ON CONFLICT (user_id) DO UPDATE SET bandit = EXCLUDED.bandit, exploration = EXCLUDED.exploration,
|
||||||
|
updated_at = now()
|
||||||
|
""",
|
||||||
|
[(u, json.dumps(p), exploration(p)) for u, p in params.items()],
|
||||||
|
)
|
||||||
|
return len(params)
|
||||||
@@ -15,7 +15,9 @@ import numpy as np
|
|||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
from pipu_rec.train.als import ALSModel, normalize, train_als
|
from pipu_rec.train.als import ALSModel, normalize, train_als
|
||||||
|
from pipu_rec.train.bandit import update_bandits
|
||||||
from pipu_rec.train.metrics import evaluate
|
from pipu_rec.train.metrics import evaluate
|
||||||
|
from pipu_rec.train.ranker import train_and_save
|
||||||
from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores
|
from pipu_rec.train.signals import DISLIKE, LIKE, Interaction, event_weight, implicit_scores
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
@@ -157,9 +159,21 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
|
|||||||
if not conn.execute("SELECT pg_try_advisory_lock(%s)", (ADVISORY_LOCK,)).fetchone()[0]: # type: ignore[index]
|
if not conn.execute("SELECT pg_try_advisory_lock(%s)", (ADVISORY_LOCK,)).fetchone()[0]: # type: ignore[index]
|
||||||
return {"skipped": "another training is running"}
|
return {"skipped": "another training is running"}
|
||||||
try:
|
try:
|
||||||
|
# Бандит считается всегда — ему хватает и пары сессий волны, в отличие от ALS.
|
||||||
|
bandit_users = update_bandits(conn, now)
|
||||||
|
# Ранжировщик v2 учится на показах волны и от числа событий ALS не зависит.
|
||||||
|
try:
|
||||||
|
ranker = train_and_save(conn, now)
|
||||||
|
except Exception as e: # обучение ранжировщика не должно мешать ALS
|
||||||
|
log.exception("ranker training failed")
|
||||||
|
ranker = {"ranker": {"error": str(e)}}
|
||||||
interactions = load_interactions(conn)
|
interactions = load_interactions(conn)
|
||||||
if len(interactions) < MIN_INTERACTIONS:
|
if len(interactions) < MIN_INTERACTIONS:
|
||||||
return {"skipped": f"only {len(interactions)} interactions"}
|
return {
|
||||||
|
"skipped": f"only {len(interactions)} interactions",
|
||||||
|
"bandit_users": bandit_users,
|
||||||
|
**ranker,
|
||||||
|
}
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
offline = offline_metrics(interactions, now)
|
offline = offline_metrics(interactions, now)
|
||||||
model = train_als(implicit_scores(interactions, now))
|
model = train_als(implicit_scores(interactions, now))
|
||||||
@@ -171,6 +185,7 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
|
|||||||
"users": len(model.users),
|
"users": len(model.users),
|
||||||
"items": len(model.items),
|
"items": len(model.items),
|
||||||
"interactions": len(interactions),
|
"interactions": len(interactions),
|
||||||
|
"bandit_users": bandit_users,
|
||||||
"train_s": round(time.monotonic() - started, 1),
|
"train_s": round(time.monotonic() - started, 1),
|
||||||
}
|
}
|
||||||
if not should_activate(offline, active_metrics(conn, "als")):
|
if not should_activate(offline, active_metrics(conn, "als")):
|
||||||
@@ -180,10 +195,10 @@ def train_all(dsn: str, now: float | None = None) -> dict[str, Any]:
|
|||||||
(version, json.dumps(metrics)),
|
(version, json.dumps(metrics)),
|
||||||
)
|
)
|
||||||
log.warning("als %s not activated: offline metrics regressed %s", version, offline)
|
log.warning("als %s not activated: offline metrics regressed %s", version, offline)
|
||||||
return {"version": version, "activated": False, **metrics}
|
return {"version": version, "activated": False, **metrics, **ranker}
|
||||||
save_als(conn, model, version, metrics)
|
save_als(conn, model, version, metrics)
|
||||||
log.info("als %s activated: %s", version, metrics)
|
log.info("als %s activated: %s", version, metrics)
|
||||||
return {"version": version, "activated": True, **metrics}
|
return {"version": version, "activated": True, **metrics, **ranker}
|
||||||
finally:
|
finally:
|
||||||
conn.execute("SELECT pg_advisory_unlock(%s)", (ADVISORY_LOCK,))
|
conn.execute("SELECT pg_advisory_unlock(%s)", (ADVISORY_LOCK,))
|
||||||
|
|
||||||
@@ -214,7 +229,8 @@ class NightlyTrainer:
|
|||||||
try:
|
try:
|
||||||
result = train_all(self.dsn)
|
result = train_all(self.dsn)
|
||||||
log.info("training finished: %s", result)
|
log.info("training finished: %s", result)
|
||||||
if result.get("activated") and self.on_done:
|
ranker_on = bool((result.get("ranker") or {}).get("activated"))
|
||||||
|
if (result.get("activated") or ranker_on) and self.on_done:
|
||||||
self.on_done()
|
self.on_done()
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("training failed")
|
log.exception("training failed")
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""Ранжировщик волны v2 — LightGBM lambdarank на показах всех пользователей (docs/recommendations.md#6.3).
|
||||||
|
|
||||||
|
Признаки кандидата пишутся в момент показа (rec_impression_features), метка — исход показа:
|
||||||
|
3 — лайк, 2 — доиграл, 0 — скип; ранний скип и дизлайк — 0 с весом ×2. Группа — сессия волны.
|
||||||
|
Новая модель включается, только если её NDCG@10 на отложенной неделе не хуже порядка по оценке v1 и не хуже
|
||||||
|
активной модели более чем на 2%. Пока размеченных показов меньше MIN_ROWS, работает v1.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import datetime as dt
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import math
|
||||||
|
import threading
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import lightgbm as lgb
|
||||||
|
import numpy as np
|
||||||
|
import psycopg
|
||||||
|
from numpy.typing import NDArray
|
||||||
|
|
||||||
|
from pipu_rec.wave.engine import FEATURE_NAMES, FEATURES_VERSION
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MIN_ROWS = 20_000
|
||||||
|
HOLDOUT_DAYS = 7
|
||||||
|
MAX_REGRESSION = 0.02
|
||||||
|
LABEL = {"like": 3, "complete": 2, "skip": 0, "skip_early": 0, "dislike": 0}
|
||||||
|
WEIGHT = {"skip_early": 2.0, "dislike": 2.0}
|
||||||
|
V1_COLUMN = FEATURE_NAMES.index("v1_score")
|
||||||
|
|
||||||
|
|
||||||
|
Row = tuple[str, list[float], str, float] # сессия, признаки, исход, время показа
|
||||||
|
|
||||||
|
|
||||||
|
def load_rows(conn: psycopg.Connection[Any]) -> list[Row]:
|
||||||
|
rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT f.wave_session_id::text, f.features, wi.outcome, extract(epoch FROM f.served_at)::float8
|
||||||
|
FROM rec_impression_features f
|
||||||
|
JOIN wave_impressions wi ON wi.wave_session_id = f.wave_session_id AND wi.track_id = f.track_id
|
||||||
|
WHERE wi.outcome IS NOT NULL AND f.served_at > now() - interval '90 days'
|
||||||
|
ORDER BY f.wave_session_id, f.served_at
|
||||||
|
"""
|
||||||
|
).fetchall()
|
||||||
|
return [(s, list(x), o, float(ts)) for s, x, o, ts in rows if o in LABEL and len(x) == len(FEATURE_NAMES)]
|
||||||
|
|
||||||
|
|
||||||
|
def _groups(rows: list[Row]) -> list[list[Row]]:
|
||||||
|
by: dict[str, list[Row]] = defaultdict(list)
|
||||||
|
for r in rows:
|
||||||
|
by[r[0]].append(r)
|
||||||
|
return [g for g in by.values() if len(g) > 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _ndcg(scores: NDArray[np.float64], labels: NDArray[np.float64], k: int = 10) -> float | None:
|
||||||
|
if labels.max() <= 0:
|
||||||
|
return None # в группе нечего ранжировать — в среднем не участвует
|
||||||
|
order = np.argsort(-scores)[:k]
|
||||||
|
dcg = sum((2 ** labels[i] - 1) / math.log2(r + 2) for r, i in enumerate(order))
|
||||||
|
ideal = sorted(labels, reverse=True)[:k]
|
||||||
|
idcg = sum((2**v - 1) / math.log2(r + 2) for r, v in enumerate(ideal))
|
||||||
|
return float(dcg / idcg) if idcg > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def mean_ndcg(groups: list[list[Row]], score: Any) -> float:
|
||||||
|
vals = []
|
||||||
|
for g in groups:
|
||||||
|
x = np.array([r[1] for r in g], dtype=np.float64)
|
||||||
|
y = np.array([LABEL[r[2]] for r in g], dtype=np.float64)
|
||||||
|
v = _ndcg(np.asarray(score(x), dtype=np.float64), y)
|
||||||
|
if v is not None:
|
||||||
|
vals.append(v)
|
||||||
|
return round(float(np.mean(vals)), 4) if vals else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def train(rows: list[Row], now: float, min_rows: int = MIN_ROWS) -> tuple[lgb.Booster | None, dict[str, Any]]:
|
||||||
|
"""Обучает модель; None — данных мало (метрики объясняют почему)."""
|
||||||
|
metrics: dict[str, Any] = {"rows": len(rows), "features_version": FEATURES_VERSION}
|
||||||
|
if len(rows) < min_rows:
|
||||||
|
metrics["skipped"] = f"need {min_rows} labelled impressions"
|
||||||
|
return None, metrics
|
||||||
|
cutoff = now - HOLDOUT_DAYS * 86400
|
||||||
|
groups = _groups(rows)
|
||||||
|
train_g = [g for g in groups if max(r[3] for r in g) < cutoff]
|
||||||
|
valid_g = [g for g in groups if max(r[3] for r in g) >= cutoff]
|
||||||
|
if not train_g or not valid_g:
|
||||||
|
metrics["skipped"] = "no time split"
|
||||||
|
return None, metrics
|
||||||
|
|
||||||
|
def matrix(
|
||||||
|
gs: list[list[Row]],
|
||||||
|
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.float64], list[int]]:
|
||||||
|
x = np.array([r[1] for g in gs for r in g], dtype=np.float64)
|
||||||
|
y = np.array([LABEL[r[2]] for g in gs for r in g], dtype=np.int64)
|
||||||
|
w = np.array([WEIGHT.get(r[2], 1.0) for g in gs for r in g], dtype=np.float64)
|
||||||
|
return x, y, w, [len(g) for g in gs]
|
||||||
|
|
||||||
|
x, y, w, group = matrix(train_g)
|
||||||
|
params = {
|
||||||
|
"objective": "lambdarank",
|
||||||
|
"learning_rate": 0.05,
|
||||||
|
"num_leaves": 31,
|
||||||
|
"min_data_in_leaf": 20,
|
||||||
|
"verbose": -1,
|
||||||
|
}
|
||||||
|
booster = lgb.train(params, lgb.Dataset(x, y, weight=w, group=group), num_boost_round=200)
|
||||||
|
metrics.update(
|
||||||
|
{
|
||||||
|
"train_groups": len(train_g),
|
||||||
|
"valid_groups": len(valid_g),
|
||||||
|
"ndcg@10": mean_ndcg(valid_g, booster.predict),
|
||||||
|
"ndcg@10_v1": mean_ndcg(valid_g, lambda m: m[:, V1_COLUMN]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return booster, metrics
|
||||||
|
|
||||||
|
|
||||||
|
def should_activate(metrics: dict[str, Any], current: dict[str, Any] | None) -> bool:
|
||||||
|
new = float(metrics.get("ndcg@10", 0.0))
|
||||||
|
if new < float(metrics.get("ndcg@10_v1", 0.0)):
|
||||||
|
return False
|
||||||
|
if current and current.get("ndcg@10"):
|
||||||
|
return new >= float(current["ndcg@10"]) * (1 - MAX_REGRESSION)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def train_and_save(conn: psycopg.Connection[Any], now: float) -> dict[str, Any]:
|
||||||
|
"""Шаг ночного пайплайна: обучение, проверка, запись в model_versions."""
|
||||||
|
booster, metrics = train(load_rows(conn), now)
|
||||||
|
if booster is None:
|
||||||
|
return {"ranker": metrics}
|
||||||
|
version = "lgbm-" + dt.datetime.fromtimestamp(now, dt.UTC).strftime("%Y%m%d%H%M")
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT metrics FROM model_versions WHERE kind = 'ranker' AND is_active "
|
||||||
|
"ORDER BY created_at DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
active = should_activate(metrics, row[0] if row else None)
|
||||||
|
with conn.transaction():
|
||||||
|
if active:
|
||||||
|
conn.execute("UPDATE model_versions SET is_active = false WHERE kind = 'ranker' AND is_active")
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO model_versions (id, kind, metrics, is_active, artifact) "
|
||||||
|
"VALUES (%s, 'ranker', %s, %s, %s) "
|
||||||
|
"ON CONFLICT (id) DO NOTHING",
|
||||||
|
(version, json.dumps(metrics), active, booster.model_to_string()),
|
||||||
|
)
|
||||||
|
log.info("ranker %s %s: %s", version, "activated" if active else "kept inactive", metrics)
|
||||||
|
return {"ranker": {"version": version, "activated": active, **metrics}}
|
||||||
|
|
||||||
|
|
||||||
|
class LoadedRanker:
|
||||||
|
"""Активная модель в памяти сервиса (Ranker для engine.build_batch)."""
|
||||||
|
|
||||||
|
def __init__(self, version: str, model: str) -> None:
|
||||||
|
self.version = version
|
||||||
|
self.booster = lgb.Booster(model_str=model)
|
||||||
|
|
||||||
|
def predict(self, x: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||||
|
return np.asarray(self.booster.predict(x), dtype=np.float64)
|
||||||
|
|
||||||
|
|
||||||
|
class RankerHolder:
|
||||||
|
"""Загружает активный ранжировщик из model_versions; reload — после ночного обучения."""
|
||||||
|
|
||||||
|
def __init__(self, dsn: str) -> None:
|
||||||
|
self.dsn = dsn
|
||||||
|
self._ranker: LoadedRanker | None = None
|
||||||
|
self._loaded = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def get(self) -> LoadedRanker | None:
|
||||||
|
if not self._loaded:
|
||||||
|
self.reload()
|
||||||
|
return self._ranker
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._loaded = True
|
||||||
|
try:
|
||||||
|
with psycopg.connect(self.dsn) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT id, artifact, metrics FROM model_versions "
|
||||||
|
"WHERE kind = 'ranker' AND is_active "
|
||||||
|
"AND artifact IS NOT NULL ORDER BY created_at DESC LIMIT 1"
|
||||||
|
).fetchone()
|
||||||
|
if row is None or (row[2] or {}).get("features_version") != FEATURES_VERSION:
|
||||||
|
self._ranker = None
|
||||||
|
return
|
||||||
|
self._ranker = LoadedRanker(row[0], row[1])
|
||||||
|
log.info("ranker %s loaded", row[0])
|
||||||
|
except Exception:
|
||||||
|
log.exception("ranker load failed — wave uses v1")
|
||||||
|
self._ranker = None
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from numpy.typing import NDArray
|
from numpy.typing import NDArray
|
||||||
@@ -93,6 +96,9 @@ class Profile:
|
|||||||
rediscover: set[str] # любимые, не игравшие > 60 дней
|
rediscover: set[str] # любимые, не игравшие > 60 дней
|
||||||
exploration: float = 0.15
|
exploration: float = 0.15
|
||||||
taste_cf: list[float] | None = None # фактор пользователя ALS
|
taste_cf: list[float] | None = None # фактор пользователя ALS
|
||||||
|
bandit: dict[str, list[float]] = field(
|
||||||
|
default_factory=dict
|
||||||
|
) # источник → [α, β] (docs/recommendations.md#6.6)
|
||||||
recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf)
|
recent_likes: list[str] = field(default_factory=list) # последние лайки, новые первыми (для item_cf)
|
||||||
|
|
||||||
|
|
||||||
@@ -111,8 +117,60 @@ class Pick:
|
|||||||
id: str
|
id: str
|
||||||
source: str
|
source: str
|
||||||
score: float
|
score: float
|
||||||
|
features: list[float] | None = None # вектор признаков в момент показа (для обучения ранжировщика)
|
||||||
|
|
||||||
|
|
||||||
|
# Источники кандидатов (one-hot в признаках ранжировщика; порядок фиксирован).
|
||||||
|
SOURCES = ["seed", "fresh", "audio_sim", "cf", "item_cf", "artist", "rediscover", "popular", "explore"]
|
||||||
|
|
||||||
|
# Признаки ранжировщика v2 (docs/recommendations.md#6.3). Порядок — часть формата модели:
|
||||||
|
# менять только вместе с FEATURES_VERSION (старые модели тогда не загружаются).
|
||||||
|
FEATURE_NAMES = [
|
||||||
|
"sim_session",
|
||||||
|
"sim_taste",
|
||||||
|
"cf",
|
||||||
|
"artist_affinity",
|
||||||
|
"popularity",
|
||||||
|
"v1_score",
|
||||||
|
"context_fit",
|
||||||
|
"is_new",
|
||||||
|
"liked_recent",
|
||||||
|
"bandit_mean",
|
||||||
|
*[f"src_{s}" for s in SOURCES],
|
||||||
|
"energy",
|
||||||
|
"bpm",
|
||||||
|
"danceability",
|
||||||
|
"valence",
|
||||||
|
"hour_sin",
|
||||||
|
"hour_cos",
|
||||||
|
"position",
|
||||||
|
"skips_in_row",
|
||||||
|
"has_activity",
|
||||||
|
"has_mood",
|
||||||
|
]
|
||||||
|
FEATURES_VERSION = 1
|
||||||
|
|
||||||
|
|
||||||
|
class Ranker(Protocol):
|
||||||
|
"""Обученный ранжировщик: оценки кандидатов по матрице признаков (строки — кандидаты)."""
|
||||||
|
|
||||||
|
version: str
|
||||||
|
|
||||||
|
def predict(self, x: NDArray[np.float64]) -> NDArray[np.float64]: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Context:
|
||||||
|
"""Контекст показа для признаков ранжировщика."""
|
||||||
|
|
||||||
|
hour: float = field(default_factory=lambda: time.localtime().tm_hour + time.localtime().tm_min / 60)
|
||||||
|
has_activity: bool = False
|
||||||
|
has_mood: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# Вес бонуса бандита к оценке (θ − 0.5 ∈ [−0.5, 0.5]).
|
||||||
|
BANDIT_WEIGHT = 0.2
|
||||||
|
|
||||||
# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e).
|
# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e).
|
||||||
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
|
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
|
||||||
ETA = 0.3
|
ETA = 0.3
|
||||||
@@ -176,10 +234,66 @@ def cf_scores(index: CatalogIndex, profile: Profile) -> NDArray[np.float64] | No
|
|||||||
return np.where(index.has_cf, index.cf @ u, 0.0).astype(np.float64)
|
return np.where(index.has_cf, index.cf @ u, 0.0).astype(np.float64)
|
||||||
|
|
||||||
|
|
||||||
def model_version(index: CatalogIndex, profile: Profile) -> str:
|
def model_version(index: CatalogIndex, profile: Profile, ranker: Ranker | None = None) -> str:
|
||||||
|
if ranker is not None:
|
||||||
|
return ranker.version
|
||||||
return MODEL_VER + (CF_SUFFIX if cf_scores(index, profile) is not None else "")
|
return MODEL_VER + (CF_SUFFIX if cf_scores(index, profile) is not None else "")
|
||||||
|
|
||||||
|
|
||||||
|
def candidate_features(
|
||||||
|
index: CatalogIndex,
|
||||||
|
profile: Profile,
|
||||||
|
session: Session,
|
||||||
|
cand: NDArray[np.intp],
|
||||||
|
sources: dict[int, str],
|
||||||
|
sim_session: NDArray[np.float64],
|
||||||
|
sim_taste: NDArray[np.float64],
|
||||||
|
cf_score: NDArray[np.float64] | None,
|
||||||
|
artist_aff: NDArray[np.float64],
|
||||||
|
v1: NDArray[np.float64],
|
||||||
|
context_fit: NDArray[np.float64],
|
||||||
|
ctx: Context,
|
||||||
|
) -> NDArray[np.float64]:
|
||||||
|
"""Матрица признаков (кандидаты × FEATURE_NAMES)."""
|
||||||
|
m = len(cand)
|
||||||
|
x = np.zeros((m, len(FEATURE_NAMES)), dtype=np.float64)
|
||||||
|
|
||||||
|
def feat(name: str) -> NDArray[np.float64]:
|
||||||
|
v = index.features.get(name)
|
||||||
|
if v is None:
|
||||||
|
return np.full(m, -1.0)
|
||||||
|
return np.nan_to_num(v[cand], nan=-1.0)
|
||||||
|
|
||||||
|
col = {n: k for k, n in enumerate(FEATURE_NAMES)}
|
||||||
|
x[:, col["sim_session"]] = sim_session[cand]
|
||||||
|
x[:, col["sim_taste"]] = sim_taste[cand]
|
||||||
|
x[:, col["cf"]] = cf_score[cand] if cf_score is not None else 0.0
|
||||||
|
x[:, col["artist_affinity"]] = artist_aff[cand]
|
||||||
|
x[:, col["popularity"]] = index.popularity[cand]
|
||||||
|
x[:, col["v1_score"]] = v1
|
||||||
|
x[:, col["context_fit"]] = context_fit
|
||||||
|
x[:, col["is_new"]] = [index.ids[i] not in profile.played for i in cand]
|
||||||
|
x[:, col["liked_recent"]] = [index.ids[i] in profile.liked_recent for i in cand]
|
||||||
|
for j, i in enumerate(cand):
|
||||||
|
src = sources[int(i)]
|
||||||
|
ab = profile.bandit.get(src)
|
||||||
|
x[j, col["bandit_mean"]] = ab[0] / (ab[0] + ab[1]) if ab else 0.5
|
||||||
|
if src in SOURCES:
|
||||||
|
x[j, col[f"src_{src}"]] = 1.0
|
||||||
|
x[:, col["energy"]] = feat("energy")
|
||||||
|
bpm = feat("bpm")
|
||||||
|
x[:, col["bpm"]] = np.where(bpm > 0, bpm / 200.0, -1.0)
|
||||||
|
x[:, col["danceability"]] = feat("danceability")
|
||||||
|
x[:, col["valence"]] = feat("valence")
|
||||||
|
x[:, col["hour_sin"]] = math.sin(2 * math.pi * ctx.hour / 24)
|
||||||
|
x[:, col["hour_cos"]] = math.cos(2 * math.pi * ctx.hour / 24)
|
||||||
|
x[:, col["position"]] = min(len(session.served) / 50.0, 1.0)
|
||||||
|
x[:, col["skips_in_row"]] = min(session.skips_in_row / 3.0, 1.0)
|
||||||
|
x[:, col["has_activity"]] = float(ctx.has_activity)
|
||||||
|
x[:, col["has_mood"]] = float(ctx.has_mood)
|
||||||
|
return x
|
||||||
|
|
||||||
|
|
||||||
def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]:
|
def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]:
|
||||||
idx = np.nonzero(mask)[0]
|
idx = np.nonzero(mask)[0]
|
||||||
if len(idx) <= k:
|
if len(idx) <= k:
|
||||||
@@ -197,6 +311,8 @@ def build_batch(
|
|||||||
exclude: set[str] | None = None,
|
exclude: set[str] | None = None,
|
||||||
seed_ids: list[str] | None = None,
|
seed_ids: list[str] | None = None,
|
||||||
rng: np.random.Generator | None = None,
|
rng: np.random.Generator | None = None,
|
||||||
|
ranker: Ranker | None = None,
|
||||||
|
ctx: Context | None = None,
|
||||||
) -> list[Pick]:
|
) -> list[Pick]:
|
||||||
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
|
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
|
||||||
rng = rng or np.random.default_rng()
|
rng = rng or np.random.default_rng()
|
||||||
@@ -273,7 +389,7 @@ def build_batch(
|
|||||||
add(rng.permutation(np.nonzero(allowed)[0])[:40].tolist(), "explore")
|
add(rng.permutation(np.nonzero(allowed)[0])[:40].tolist(), "explore")
|
||||||
|
|
||||||
cand = np.array(sorted(sources))
|
cand = np.array(sorted(sources))
|
||||||
# 3. Оценка v1.
|
# 3. Оценка v1 (она же — признак и запасной вариант для v2).
|
||||||
score = (
|
score = (
|
||||||
0.35 * sim_session[cand]
|
0.35 * sim_session[cand]
|
||||||
+ 0.25 * sim_taste[cand]
|
+ 0.25 * sim_taste[cand]
|
||||||
@@ -284,8 +400,17 @@ def build_batch(
|
|||||||
)
|
)
|
||||||
liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand])
|
liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand])
|
||||||
score = score - 0.2 * liked_recent
|
score = score - 0.2 * liked_recent
|
||||||
|
# Бандит: семплирование Томпсона по источникам — удачные для пользователя источники получают бонус.
|
||||||
|
# Источник без наблюдений — нейтральные 0.5 (без шума, иначе холодный старт ранжировался бы случайно).
|
||||||
|
theta: dict[str, float] = {}
|
||||||
|
for src in set(sources.values()):
|
||||||
|
ab = profile.bandit.get(src)
|
||||||
|
theta[src] = float(rng.beta(ab[0], ab[1])) if ab else 0.5
|
||||||
|
score = score + BANDIT_WEIGHT * (np.array([theta[sources[int(i)]] for i in cand]) - 0.5)
|
||||||
|
v1 = score.copy()
|
||||||
|
|
||||||
# 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
|
# 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
|
||||||
|
f = np.ones(len(cand))
|
||||||
if rules:
|
if rules:
|
||||||
f = fit({k: v[cand] for k, v in index.features.items()}, rules)
|
f = fit({k: v[cand] for k, v in index.features.items()}, rules)
|
||||||
# Нужен запас для разнообразия, но не больше 50 и не больше четверти пула.
|
# Нужен запас для разнообразия, но не больше 50 и не больше четверти пула.
|
||||||
@@ -294,11 +419,33 @@ def build_batch(
|
|||||||
keep = f >= threshold
|
keep = f >= threshold
|
||||||
if keep.sum() >= enough or threshold == 0.0:
|
if keep.sum() >= enough or threshold == 0.0:
|
||||||
break
|
break
|
||||||
cand, score, f = cand[keep], score[keep], f[keep]
|
cand, score, f, v1 = cand[keep], score[keep], f[keep], v1[keep]
|
||||||
score = score + 0.5 * f # контекст важнее вкуса, когда выбран явно
|
score = score + 0.5 * f # контекст важнее вкуса, когда выбран явно
|
||||||
if len(cand) == 0:
|
if len(cand) == 0:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# Признаки кандидатов: пишутся с показом (обучение v2) и подаются обученному ранжировщику.
|
||||||
|
ctx = ctx or Context()
|
||||||
|
f64 = np.float64
|
||||||
|
x = candidate_features(
|
||||||
|
index,
|
||||||
|
profile,
|
||||||
|
session,
|
||||||
|
cand,
|
||||||
|
sources,
|
||||||
|
np.asarray(sim_session, f64),
|
||||||
|
np.asarray(sim_taste, f64),
|
||||||
|
cf_score,
|
||||||
|
np.asarray(artist_aff, f64),
|
||||||
|
np.asarray(v1, f64),
|
||||||
|
np.asarray(f, f64),
|
||||||
|
ctx,
|
||||||
|
)
|
||||||
|
if ranker is not None:
|
||||||
|
# v2: порядок задаёт модель; в MMR нужна шкала 0..1 — берём ранг предсказания.
|
||||||
|
pred = ranker.predict(x)
|
||||||
|
score = np.argsort(np.argsort(pred)) / max(len(pred) - 1, 1)
|
||||||
|
|
||||||
# 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового».
|
# 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового».
|
||||||
picks: list[Pick] = []
|
picks: list[Pick] = []
|
||||||
chosen: list[int] = []
|
chosen: list[int] = []
|
||||||
@@ -333,7 +480,14 @@ def build_batch(
|
|||||||
i = int(cand[best_j])
|
i = int(cand[best_j])
|
||||||
chosen.append(i)
|
chosen.append(i)
|
||||||
order.remove(best_j)
|
order.remove(best_j)
|
||||||
picks.append(Pick(id=index.ids[i], source=sources[i], score=round(float(score[best_j]), 4)))
|
picks.append(
|
||||||
|
Pick(
|
||||||
|
id=index.ids[i],
|
||||||
|
source=sources[i],
|
||||||
|
score=round(float(score[best_j]), 4),
|
||||||
|
features=x[best_j].tolist(),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Исследование: «новых» (неслышанных) меньше доли exploration — заменяем ими слабейшие выборы.
|
# Исследование: «новых» (неслышанных) меньше доли exploration — заменяем ими слабейшие выборы.
|
||||||
fresh_left = sorted(
|
fresh_left = sorted(
|
||||||
@@ -348,5 +502,7 @@ def build_batch(
|
|||||||
weakest = min(others, key=lambda k: picks[k].score)
|
weakest = min(others, key=lambda k: picks[k].score)
|
||||||
if any(index.artist[index.pos[p.id]] == index.artist[i] for k, p in enumerate(picks) if k != weakest):
|
if any(index.artist[index.pos[p.id]] == index.artist[i] for k, p in enumerate(picks) if k != weakest):
|
||||||
continue
|
continue
|
||||||
picks[weakest] = Pick(id=index.ids[i], source="fresh", score=round(float(score[j]), 4))
|
picks[weakest] = Pick(
|
||||||
|
id=index.ids[i], source="fresh", score=round(float(score[j]), 4), features=x[j].tolist()
|
||||||
|
)
|
||||||
return picks
|
return picks
|
||||||
@@ -2,18 +2,22 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import psycopg
|
import psycopg
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from pipu_rec.train.ranker import RankerHolder
|
||||||
from pipu_rec.wave.contexts import Contexts, Rule
|
from pipu_rec.wave.contexts import Contexts, Rule
|
||||||
from pipu_rec.wave.engine import Pick, build_batch, model_version, taste_centroids, update_session
|
from pipu_rec.wave.engine import Context, Pick, build_batch, model_version, taste_centroids, update_session
|
||||||
from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks
|
from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks
|
||||||
|
|
||||||
MAX_SERVED_MEMORY = 300
|
MAX_SERVED_MEMORY = 300
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"]
|
Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"]
|
||||||
|
|
||||||
|
|
||||||
@@ -55,6 +59,12 @@ class WaveService:
|
|||||||
self.index = IndexHolder(dsn)
|
self.index = IndexHolder(dsn)
|
||||||
self.sessions = SessionStore(redis_url)
|
self.sessions = SessionStore(redis_url)
|
||||||
self.contexts = contexts
|
self.contexts = contexts
|
||||||
|
self.ranker = RankerHolder(dsn)
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
"""После ночного обучения: перечитать каталог (векторы ALS) и активный ранжировщик."""
|
||||||
|
self.index.refresh()
|
||||||
|
self.ranker.reload()
|
||||||
|
|
||||||
def rules(self, activity: str | None, mood: str | None) -> list[Rule]:
|
def rules(self, activity: str | None, mood: str | None) -> list[Rule]:
|
||||||
"""Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется."""
|
"""Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется."""
|
||||||
@@ -74,6 +84,7 @@ class WaveService:
|
|||||||
update_session(
|
update_session(
|
||||||
index, session, [(f.track_id, f.outcome) for f in req.feedback], taste_centroids(index, profile)
|
index, session, [(f.track_id, f.outcome) for f in req.feedback], taste_centroids(index, profile)
|
||||||
)
|
)
|
||||||
|
ranker = self.ranker.get()
|
||||||
picks: list[Pick] = build_batch(
|
picks: list[Pick] = build_batch(
|
||||||
index,
|
index,
|
||||||
profile,
|
profile,
|
||||||
@@ -83,12 +94,31 @@ class WaveService:
|
|||||||
exclude=set(req.exclude_ids),
|
exclude=set(req.exclude_ids),
|
||||||
seed_ids=seeds,
|
seed_ids=seeds,
|
||||||
rng=np.random.default_rng(),
|
rng=np.random.default_rng(),
|
||||||
|
ranker=ranker,
|
||||||
|
ctx=Context(has_activity=bool(req.activity), has_mood=bool(req.mood)),
|
||||||
)
|
)
|
||||||
|
self.log_features(req.session_id, picks)
|
||||||
session.served = (session.served + [p.id for p in picks])[-MAX_SERVED_MEMORY:]
|
session.served = (session.served + [p.id for p in picks])[-MAX_SERVED_MEMORY:]
|
||||||
artists = [index.artist[index.pos[p.id]] for p in picks]
|
artists = [index.artist[index.pos[p.id]] for p in picks]
|
||||||
session.recent_artists = (session.recent_artists + artists)[-10:]
|
session.recent_artists = (session.recent_artists + artists)[-10:]
|
||||||
self.sessions.put(req.session_id, session)
|
self.sessions.put(req.session_id, session)
|
||||||
return BatchResponse(
|
return BatchResponse(
|
||||||
tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks],
|
tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks],
|
||||||
model_ver=model_version(index, profile),
|
model_ver=model_version(index, profile, ranker),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def log_features(self, session_id: str, picks: list[Pick]) -> None:
|
||||||
|
"""Признаки показанных треков — обучающие данные ранжировщика v2. Сбой записи не ломает волну."""
|
||||||
|
rows = [(session_id, p.id, p.features) for p in picks if p.features is not None]
|
||||||
|
if not rows:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with psycopg.connect(self.dsn, autocommit=True) as conn, conn.cursor() as cur:
|
||||||
|
cur.executemany(
|
||||||
|
"INSERT INTO rec_impression_features (wave_session_id, track_id, features) "
|
||||||
|
"VALUES (%s, %s, %s) "
|
||||||
|
"ON CONFLICT DO NOTHING",
|
||||||
|
rows,
|
||||||
|
)
|
||||||
|
except psycopg.Error as e:
|
||||||
|
log.warning("impression features not saved: %s", e)
|
||||||
@@ -156,10 +156,14 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT exploration, taste_cf::text FROM user_profiles_rec WHERE user_id = %s", (user_id,)
|
"SELECT exploration, taste_cf::text, bandit, onboarding FROM user_profiles_rec WHERE user_id = %s",
|
||||||
|
(user_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
if row and row[3] and len(scores) < ONBOARDING_UNTIL:
|
||||||
|
apply_onboarding(conn, scores, row[3])
|
||||||
exploration = float(row[0]) if row else 0.15
|
exploration = float(row[0]) if row else 0.15
|
||||||
taste_cf = parse_vector(row[1]) if row else None
|
taste_cf = parse_vector(row[1]) if row else None
|
||||||
|
bandit = {str(k): [float(x) for x in v] for k, v in (row[2] or {}).items()} if row else {}
|
||||||
if len(played) < 30:
|
if len(played) < 30:
|
||||||
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
|
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
|
||||||
day = 86400
|
day = 86400
|
||||||
@@ -173,10 +177,41 @@ def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
|
|||||||
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
|
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
|
||||||
exploration=exploration,
|
exploration=exploration,
|
||||||
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
|
taste_cf=taste_cf.tolist() if taste_cf is not None else None,
|
||||||
|
bandit=bandit,
|
||||||
recent_likes=recent_likes,
|
recent_likes=recent_likes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Пока своих оценок меньше, выбор онбординга задаёт начальный вкус (docs/recommendations.md#7).
|
||||||
|
ONBOARDING_UNTIL = 30
|
||||||
|
ONBOARDING_LIMIT = 300
|
||||||
|
|
||||||
|
|
||||||
|
def apply_onboarding(conn: psycopg.Connection[Any], scores: dict[str, float], choice: dict[str, Any]) -> None:
|
||||||
|
"""Псевдооценки: треки выбранных исполнителей +1, выбранных жанров +0.5 (до ONBOARDING_LIMIT треков).
|
||||||
|
Собственные оценки не перезаписываются; в «слышанное» эти треки не попадают."""
|
||||||
|
artists = [str(a) for a in choice.get("artist_ids") or []]
|
||||||
|
genres = [str(g).lower() for g in choice.get("genres") or []]
|
||||||
|
pseudo: dict[str, float] = {}
|
||||||
|
if artists:
|
||||||
|
for (tid,) in conn.execute(
|
||||||
|
"SELECT DISTINCT track_id::text FROM track_artists WHERE artist_id::text = ANY(%s) LIMIT %s",
|
||||||
|
(artists, ONBOARDING_LIMIT),
|
||||||
|
):
|
||||||
|
pseudo[tid] = pseudo.get(tid, 0.0) + 1.0
|
||||||
|
if genres:
|
||||||
|
for (tid,) in conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT tg.track_id::text FROM track_genres tg JOIN genres g ON g.id = tg.genre_id
|
||||||
|
WHERE lower(g.name) = ANY(%s) LIMIT %s
|
||||||
|
""",
|
||||||
|
(genres, ONBOARDING_LIMIT),
|
||||||
|
):
|
||||||
|
pseudo[tid] = pseudo.get(tid, 0.0) + 0.5
|
||||||
|
for tid, v in pseudo.items():
|
||||||
|
scores.setdefault(tid, v)
|
||||||
|
|
||||||
|
|
||||||
def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]:
|
def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]:
|
||||||
"""Треки, от которых запущена волна: трек, альбом или исполнитель."""
|
"""Треки, от которых запущена волна: трек, альбом или исполнитель."""
|
||||||
queries = {
|
queries = {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ dependencies = [
|
|||||||
"redis>=6.2,<7",
|
"redis>=6.2,<7",
|
||||||
"numpy>=2.2,<3",
|
"numpy>=2.2,<3",
|
||||||
"pyyaml>=6.0,<7",
|
"pyyaml>=6.0,<7",
|
||||||
|
"lightgbm>=4.5,<5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
Локально: TEST_DATABASE_URL=postgresql://… uv run pytest tests/test_db_integration.py
|
Локально: TEST_DATABASE_URL=postgresql://… uv run pytest tests/test_db_integration.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
@@ -139,3 +140,44 @@ def test_training_and_wave_queries(world: dict[str, Any]) -> None:
|
|||||||
assert active is not None and active[0] == 1
|
assert active is not None and active[0] == 1
|
||||||
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0))
|
picks = build_batch(index, profile, Session(), [], n=5, rng=np.random.default_rng(0))
|
||||||
assert len(picks) == 5
|
assert len(picks) == 5
|
||||||
|
|
||||||
|
|
||||||
|
def test_onboarding_seeds_taste(world: dict[str, Any]) -> None:
|
||||||
|
user = uid()
|
||||||
|
with psycopg.connect(DSN, autocommit=True) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash) VALUES (%s, %s, 'x')", (user, f"new-{user[:6]}")
|
||||||
|
)
|
||||||
|
artist = conn.execute(
|
||||||
|
"SELECT artist_id::text FROM track_artists WHERE track_id = %s", (world["tracks"][0],)
|
||||||
|
).fetchone()
|
||||||
|
assert artist is not None
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO user_profiles_rec (user_id, onboarding) VALUES (%s, %s)",
|
||||||
|
(user, json.dumps({"artist_ids": [artist[0]], "genres": []})),
|
||||||
|
)
|
||||||
|
profile = load_profile(conn, user)
|
||||||
|
assert len(profile.scores) >= 10, "chosen artist's tracks become the initial taste"
|
||||||
|
assert not profile.played
|
||||||
|
|
||||||
|
|
||||||
|
def test_impression_features_roundtrip(world: dict[str, Any]) -> None:
|
||||||
|
from pipu_rec.train.ranker import load_rows, train_and_save
|
||||||
|
from pipu_rec.wave.engine import FEATURE_NAMES
|
||||||
|
|
||||||
|
session, user, track = uid(), world["users"][0], world["tracks"][0]
|
||||||
|
with psycopg.connect(DSN, autocommit=True) as conn:
|
||||||
|
conn.execute("INSERT INTO wave_sessions (id, user_id) VALUES (%s, %s)", (session, user))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO wave_impressions (wave_session_id, position, track_id, candidate_source, outcome) "
|
||||||
|
"VALUES (%s, 0, %s, 'audio_sim', 'complete')",
|
||||||
|
(session, track),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO rec_impression_features (wave_session_id, track_id, features) VALUES (%s, %s, %s)",
|
||||||
|
(session, track, [0.5] * len(FEATURE_NAMES)),
|
||||||
|
)
|
||||||
|
rows = load_rows(conn)
|
||||||
|
assert any(r[0] == session and r[2] == "complete" for r in rows)
|
||||||
|
result = train_and_save(conn, time.time())
|
||||||
|
assert "skipped" in result["ranker"], "too few impressions — v1 stays"
|
||||||
@@ -116,3 +116,96 @@ def test_cf_vectors_are_padded_to_column_size() -> None:
|
|||||||
v = np.ones((3, 5), dtype=np.float32)
|
v = np.ones((3, 5), dtype=np.float32)
|
||||||
assert pad(v).shape == (3, CF_DIM)
|
assert pad(v).shape == (3, CF_DIM)
|
||||||
assert pad(np.ones((2, 80), dtype=np.float32)).shape == (2, 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"
|
||||||
Generated
+50
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.20.2"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "numpy"
|
name = "numpy"
|
||||||
version = "2.5.4"
|
version = "2.5.4"
|
||||||
@@ -262,6 +289,7 @@ version = "0.1.0"
|
|||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "fastapi" },
|
{ name = "fastapi" },
|
||||||
|
{ name = "lightgbm" },
|
||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
@@ -282,6 +310,7 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "fastapi", specifier = ">=0.115,<1" },
|
{ name = "fastapi", specifier = ">=0.115,<1" },
|
||||||
|
{ name = "lightgbm", specifier = ">=4.5,<5" },
|
||||||
{ name = "numpy", specifier = ">=2.2,<3" },
|
{ name = "numpy", specifier = ">=2.2,<3" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
|
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
|
||||||
{ name = "pydantic-settings", specifier = ">=2.9,<3" },
|
{ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user