Compare commits

..
18 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 73e642c1ec fix(mobile): APK starts on Android 15+ 16 KB page devices and emulators
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 2m56s
release / android (APK в релиз) (push) Successful in 4m53s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 3m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m2s
ci / ts / ts (packages + web) (push) Successful in 1m48s
release / release (версия и заметки) (push) Successful in 8s
On an Android 16 emulator the release APK crashed at launch with
"Cannot read property 'NativeModule' of undefined": libexpo-modules-core.so
was missing for the ABI the device picked. Two build defects:

- librnskia.so was linked with 4 KB segment alignment (Skia 2.0.0-next.4
  lacks ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES), so 16 KB page devices cannot
  load it; a pnpm patch adds the flag
- the APK carried incomplete x86, x86_64 and armeabi-v7a folders from the
  React Native libraries; an Expo config plugin keeps only arm64-v8a, the ABI
  the native modules are built for (the APK also halves in size)

scripts/check-apk.mjs now fails the release on another ABI, a missing core
library or a library not aligned to 16 KB.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:55:07 +03:00
84 changed files with 2172 additions and 369 deletions

No files matched your search

+43
View File
@@ -4,16 +4,19 @@
name: ci / db
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
- 'services/api/**'
- 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml'
pull_request:
paths:
- 'services/api/**'
- 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml'
concurrency:
@@ -51,6 +54,23 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: "1.24", cache-dependency-path: services/api/go.sum }
# Контейнеры сервисов стартуют параллельно с job: без ожидания тесты иногда не находят даже DNS-имя postgres.
- name: Место на раннере и готовность Postgres, Redis, Meilisearch
run: |
# Диск раннера общий с контейнерами сервисов: при нехватке места Postgres падает посреди тестов.
free=$(df -Pm / | awk 'NR==2 {print $4}')
echo "свободно на раннере: ${free} МБ"
[ "$free" -ge 2048 ] || { echo "на раннере меньше 2 ГБ — почистите: docker system prune -af" >&2; exit 1; }
for svc in postgres:5432 redis:6379 meilisearch:7700; do
host=${svc%:*}; port=${svc#*:}
for i in $(seq 1 60); do
if getent hosts "$host" >/dev/null && timeout 2 bash -c "</dev/tcp/$host/$port" 2>/dev/null; then
echo "$svc готов"; break
fi
[ "$i" = 60 ] && { echo "$svc не поднялся за 2 минуты" >&2; exit 1; }
sleep 2
done
done
- name: Go — миграции и запросы store
working-directory: services/api
run: go test -tags integration -count=1 -v ./internal/dbtest/
@@ -64,3 +84,26 @@ jobs:
run: |
uv sync --frozen
uv run pytest -q tests/test_db_integration.py
# База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление.
- name: Бэкап, проверка и восстановление (deploy/backup.sh)
env:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: pipu
PGDATABASE: pipu
BACKUP_DIR: /tmp/backups
VERIFY_EXACT: "1"
run: |
SUDO=$(command -v sudo || true)
$SUDO install -d /usr/share/postgresql-common/pgdg
$SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null
export PATH=/usr/lib/postgresql/17/bin:$PATH
before=$(psql -Atc "SELECT count(*) FROM tracks")
file=$(bash deploy/backup.sh once)
bash deploy/backup.sh verify "$file"
bash deploy/backup.sh restore "$file"
after=$(psql -Atc "SELECT count(*) FROM tracks")
echo "tracks: $before → $after"
test "$before" = "$after"
+1
View File
@@ -2,6 +2,7 @@
name: ci / mobile
on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push:
branches: [main]
paths:
+5
View File
@@ -143,6 +143,11 @@ jobs:
fi
./gradlew --stop
# Только arm64-v8a, все нужные .so на месте и выровнены по 16 КБ — иначе APK падает при запуске
# на части устройств (Android 15+ с 16 КБ страницами, эмуляторы x86).
- name: Проверка APK (ABI, 16 КБ страницы)
run: node scripts/check-apk.mjs apps/mobile/android/app/build/outputs/apk/release/app-release.apk
- name: Диагностика сбоя (диск, память, OOM)
if: failure()
run: |
+52 -2
View File
@@ -1006,6 +1006,24 @@ paths:
$ref: "#/components/schemas/SearchResults"
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/library/analysis/retry:
post:
operationId: retryAnalysis
tags: [library]
summary: Вернуть в очередь треки, которые не удалось проанализировать (admin)
responses:
"200":
description: Сколько задач снова в очереди
content:
application/json:
schema:
type: object
required: [requeued]
properties:
requeued:
type: integer
default:
$ref: "#/components/responses/Problem"
/api/v1/admin/search/reindex:
post:
operationId: reindexSearch
@@ -1379,6 +1397,7 @@ paths:
get:
operationId: getAndroidLatest
tags: [app]
security: [] # публично: страница /android раздаёт приложение без входа
summary: Последняя версия Android-приложения (из релизов Gitea через сервер)
description: |
Сервер сам опрашивает Gitea своим токеном и кэширует ответ на 10 минут, поэтому приложению
@@ -1396,6 +1415,7 @@ paths:
get:
operationId: downloadAndroidApk
tags: [app]
security: []
summary: APK версии (кэшируется на сервере, поддерживает Range для докачки)
parameters:
- name: version
@@ -1994,6 +2014,10 @@ components:
web_player:
type: string
enum: [bar, island]
search_position:
type: string
enum: [center, left, right]
description: Где в топбаре веба поиск (по умолчанию center)
mobile_player:
type: string
enum: [cover, wave]
@@ -3081,7 +3105,7 @@ components:
description: Рекомендатель недоступен — пачка собрана упрощённо (лайки, любимые исполнители)
AnalysisStatus:
type: object
required: [queued, running, done, failed, analyzer_ver]
required: [queued, running, done, failed, analyzer_ver, top_error]
properties:
queued:
type: integer
@@ -3094,9 +3118,13 @@ components:
analyzer_ver:
type: string
nullable: true
top_error:
type: string
nullable: true
description: Самая частая ошибка среди неудавшихся — подсказка, что чинить
WaveMetrics:
type: object
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions]
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
description: Метрики волны за 7 дней
properties:
served:
@@ -3113,6 +3141,28 @@ components:
description: Лайков на 100 треков
sessions:
type: integer
by_model:
type: array
description: Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
items:
$ref: '#/components/schemas/WaveModelMetrics'
WaveModelMetrics:
type: object
required: [model_ver, served, listen_through_rate, early_skip_rate, like_rate]
properties:
model_ver:
type: string
served:
type: integer
listen_through_rate:
type: number
nullable: true
early_skip_rate:
type: number
nullable: true
like_rate:
type: number
nullable: true
AndroidRelease:
type: object
required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
+2
View File
@@ -31,6 +31,8 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
// Сервер часто живёт в домашней сети без TLS (http://192.168.1.10:8080), а release-сборка
// Android по умолчанию запрещает незашифрованный HTTP.
['expo-build-properties', { android: { usesCleartextTraffic: true } }],
// APK только под arm64-v8a — та же ABI, под которую собираются нативные модули (plugins/withArm64Only.js).
'./plugins/withArm64Only',
],
extra: { versionCode },
experiments: { typedRoutes: false },
+18
View File
@@ -0,0 +1,18 @@
// Только arm64-v8a в APK. Наши нативные модули (Expo, Skia, Reanimated) собираются под одну ABI
// (-PreactNativeArchitectures=arm64-v8a в release.yml), а готовые библиотеки React Native приносят
// каталоги всех ABI. Без фильтра x86/x86_64-эмулятор и 32-битные устройства выбирают неполный каталог
// и падают при старте: «couldn't find DSO to load: libexpo-modules-core.so».
const { withAppBuildGradle } = require('expo/config-plugins');
const MARKER = '// pipu: arm64-v8a only';
module.exports = function withArm64Only(config) {
return withAppBuildGradle(config, (cfg) => {
if (cfg.modResults.contents.includes(MARKER)) return cfg;
cfg.modResults.contents = cfg.modResults.contents.replace(
/defaultConfig\s*\{/,
(m) => `${m}\n ${MARKER}\n ndk { abiFilters "arm64-v8a" }`,
);
return cfg;
});
};
+1 -1
View File
@@ -117,7 +117,7 @@ function Chips({
height: 38,
paddingHorizontal: 14,
borderRadius: 999,
backgroundColor: on ? colors.accent : colors.surface2,
backgroundColor: on ? colors['accent-fill'] : colors.surface2,
borderWidth: 1,
borderColor: on ? colors.accent : colors.line,
}}
+2 -2
View File
@@ -107,7 +107,7 @@ export function Button({
}) {
const s = useStyles();
const colors = useColors();
const bg = { primary: colors.accent, secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
return (
<Pressable
@@ -282,7 +282,7 @@ export function Segmented<T extends string>({
onPress={() => onChange(o.value)}
accessibilityRole="tab"
accessibilityState={{ selected: value === o.value }}
style={[s.segment, value === o.value && { backgroundColor: colors.accent }]}
style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
>
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
</Pressable>
+2
View File
@@ -8,6 +8,8 @@ WORKDIR /repo
# Сначала только манифесты workspace: слой с установкой зависимостей пересобирается лишь при их изменении,
# а не при любой правке кода. Новый пакет в apps/ или packages/ — добавить его package.json сюда.
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
# pnpm-патчи зависимостей (lockfile ссылается на них, без них --frozen-lockfile падает)
COPY patches/ patches/
COPY apps/web/package.json apps/web/
COPY apps/mobile/package.json apps/mobile/
COPY packages/api-client/package.json packages/api-client/
+1 -1
View File
@@ -250,7 +250,7 @@ describe('player', () => {
const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!;
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
+5
View File
@@ -21,6 +21,7 @@ import { AlbumPage } from '../pages/AlbumPage';
import { ArtistPage } from '../pages/ArtistPage';
import { CollectionPage } from '../pages/CollectionPage';
import { HomePage } from '../pages/HomePage';
import { AppDownloadPage } from '../pages/AppDownloadPage';
import { LoginPage } from '../pages/LoginPage';
import { SearchPage, type SearchType } from '../pages/SearchPage';
import { SettingsPage } from '../pages/SettingsPage';
@@ -59,6 +60,9 @@ const rootRoute = createRootRouteWithContext<RouterContext>()({
errorComponent: RouteError,
});
// Публичная: ссылку на приложение отправляют тем, у кого ещё нет аккаунта на этом устройстве.
const androidAppRoute = createRoute({ getParentRoute: () => rootRoute, path: '/android', component: AppDownloadPage });
const loginRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/login',
@@ -233,6 +237,7 @@ const adminSettingsRoute = createRoute({
});
const routeTree = rootRoute.addChildren([
androidAppRoute,
loginRoute,
setupAdminRoute,
setupPasswordRoute,
+2 -2
View File
@@ -132,7 +132,7 @@ export function TrackList({
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null;
return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}>
<div className={s.tracks} role="list" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc;
@@ -145,7 +145,7 @@ export function TrackList({
<Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div
role="row"
role="listitem"
tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
+70
View File
@@ -0,0 +1,70 @@
import { useQuery } from '@tanstack/react-query';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../app/session';
import { formatBytes } from '../shared/lib/format';
import { checkIcon, copyIcon, downloadIcon } from '../shared/ui/icons';
import { Icon } from '../shared/ui/ui';
import { AuthLayout } from './AuthLayout';
import s from './auth.module.css';
/**
* Публичная страница Android-приложения (/android): ссылку можно просто отправить человеку.
* APK — последний релиз, который сервер сам берёт из Gitea (docs/mobile.md#самообновление).
*/
export function AppDownloadPage() {
const { t, i18n } = useTranslation(['auth', 'common']);
const session = useSession();
const [copied, setCopied] = useState(false);
const latest = useQuery({
queryKey: ['android-latest'],
queryFn: async () => (await session.client.GET('/api/v1/app/android/latest')).data ?? null,
staleTime: 10 * 60_000,
});
const server = location.origin;
const copy = () =>
void navigator.clipboard?.writeText(server).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
const r = latest.data;
return (
<AuthLayout footer={<a href="/login">{t('app.to_login')}</a>}>
<span className={s.badge}>Android</span>
<h1>{t('app.title')}</h1>
<p className={s.lead}>{t('app.subtitle')}</p>
{latest.isPending ? null : r ? (
<>
<a className={s.download} href={r.download_url} download>
<Icon src={downloadIcon} size={22} />
<span>
{t('app.download')}
<span className={s.downloadMeta}>
{t('app.version', { version: r.version })} · {formatBytes(r.size, i18n.language)}
</span>
</span>
</a>
<ol className={s.steps}>
<li>{t('app.step_install')}</li>
<li>
{t('app.step_server')}
<button type="button" className={s.server} onClick={copy} aria-label={t('app.copy_server')}>
<code>{server}</code>
<Icon src={copied ? checkIcon : copyIcon} size={16} />
</button>
</li>
<li>{t('app.step_updates')}</li>
</ol>
<details className={s.details}>
<summary>{t('app.checksum')}</summary>
<code>{r.sha256}</code>
</details>
</>
) : (
<div className={s.hint}>{t('app.unavailable')}</div>
)}
</AuthLayout>
);
}
+9 -1
View File
@@ -37,7 +37,15 @@ export function LoginPage() {
};
return (
<AuthLayout footer={info.data ? `pipu ${info.data.version}` : undefined}>
<AuthLayout
footer={
info.data ? (
<>
pipu {info.data.version} · <a href="/android">{t('app.link')}</a>
</>
) : undefined
}
>
<h1>{t('login.title')}</h1>
<p className={s.lead}>{t('login.subtitle')}</p>
{info.data?.bootstrap_required && <div className={s.hint}>{t('login.bootstrap_hint')}</div>}
+13
View File
@@ -126,6 +126,19 @@ function AppearanceSection() {
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.search_position')}</span>
<Segmented<'left' | 'center' | 'right'>
label={t('settings.search_position')}
value={user.ui_prefs.search_position ?? 'center'}
onChange={(v) => void setPrefs({ search_position: v })}
options={[
{ value: 'left', label: t('settings.search_left') },
{ value: 'center', label: t('settings.search_center') },
{ value: 'right', label: t('settings.search_right') },
]}
/>
</div>
<div className={s.settingRow}>
<span>{t('settings.ambient')}</span>
<Segmented<'on' | 'off'>
+52 -4
View File
@@ -1,5 +1,6 @@
import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session';
@@ -31,6 +32,11 @@ export function LibraryPage() {
const reindex = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/search/reindex')),
});
const queryClient = useQueryClient();
const retry = useMutation({
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/library/analysis/retry')),
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['library-status'] }),
});
const d = status.data;
const tiles = d
? ([
@@ -76,7 +82,19 @@ export function LibraryPage() {
total: d.analysis.done + d.analysis.queued + d.analysis.running + d.analysis.failed,
})}
/>
{d.analysis.failed > 0 && <span className={l.scanError}>{t('library.analysis_failed', { count: d.analysis.failed })}</span>}
{d.analysis.failed > 0 && (
<div className={l.retryRow}>
<span className={l.scanError}>
{t('library.analysis_failed', { count: d.analysis.failed })}
{d.analysis.top_error && <span className={l.errorDetail}>{d.analysis.top_error}</span>}
</span>
{admin && (
<Button variant="secondary" icon={refreshIcon} busy={retry.isPending} onClick={() => retry.mutate()}>
{t('library.analysis_retry')}
</Button>
)}
</div>
)}
{d.analysis.analyzer_ver && <span className={l.scanSummary}>{t('library.analyzer', { ver: d.analysis.analyzer_ver })}</span>}
</section>
)}
@@ -99,6 +117,33 @@ export function LibraryPage() {
</div>
))}
</div>
{d.wave.by_model.length > 1 && (
<div className={s.tableWrap}>
<table className={s.table}>
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
<thead>
<tr>
<th scope="col">{t('library.wave_model')}</th>
<th scope="col">{t('library.wave_served')}</th>
<th scope="col">{t('library.wave_listen')}</th>
<th scope="col">{t('library.wave_early_skip')}</th>
<th scope="col">{t('library.wave_likes')}</th>
</tr>
</thead>
<tbody>
{d.wave.by_model.map((m) => (
<tr key={m.model_ver}>
<td>{m.model_ver || '—'}</td>
<td>{m.served.toLocaleString()}</td>
<td>{pct(m.listen_through_rate)}</td>
<td>{pct(m.early_skip_rate)}</td>
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{d && d.sources.length > 0 && (
@@ -133,10 +178,13 @@ const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
const value = total > 0 ? Math.round((done / total) * 100) : 0;
const id = useId();
return (
<div className={l.scan}>
<div className={l.scanText}>{label}</div>
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<div className={l.scanText} id={id}>
{label}
</div>
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${value}%` }} />
</div>
</div>
+4 -2
View File
@@ -1,4 +1,5 @@
import type { components } from '@pipu/api-client';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../../shared/lib/format';
@@ -16,14 +17,15 @@ export function StatusBadge({ status }: { status: SourceStatus }) {
/** Прогресс идущего скана или итог последнего. */
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
const { t, i18n } = useTranslation('admin');
const id = useId();
if (scan?.running) {
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
return (
<div className={s.scan}>
<div className={s.scanText}>
<div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
</div>
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} />
</div>
</div>
@@ -118,6 +118,20 @@
color: var(--danger);
}
.retryRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.errorDetail {
display: block;
margin-top: 4px;
color: var(--text3);
word-break: break-word;
}
.bar {
height: 6px;
border-radius: 3px;
+77
View File
@@ -161,6 +161,83 @@
color: var(--text3);
}
.footer a {
color: var(--text2);
}
/* Страница Android-приложения (/android). */
.download {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 18px;
border-radius: var(--radius-lg);
background: var(--accent-fill);
color: #fff;
font-weight: 700;
text-decoration: none;
box-shadow: var(--glow);
transition:
background 0.2s,
transform 0.15s;
}
.download:hover {
background: var(--accent-fill2);
}
.download:active {
transform: scale(0.98);
}
.downloadMeta {
display: block;
margin-top: 2px;
font-size: 12px;
font-weight: 500;
opacity: 0.85;
}
.steps {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 10px;
color: var(--text2);
font-size: 14px;
line-height: 1.5;
}
.server {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 6px;
padding: 2px 10px;
border-radius: 8px;
border: 1px solid var(--line2);
background: var(--surface2);
color: var(--text);
cursor: pointer;
}
.details {
font-size: 12px;
color: var(--text3);
}
.details summary {
cursor: pointer;
}
.details code {
display: block;
margin-top: 6px;
word-break: break-all;
font-family: var(--font-mono);
}
.link {
align-self: center;
}
+2 -1
View File
@@ -7,6 +7,7 @@
gap: 28px;
padding: 24px 0;
text-align: center;
user-select: none; /* главная — только элементы управления волной */
}
.orbLabel {
@@ -86,7 +87,7 @@
.chipOn,
.chipOn:hover {
background: var(--accent);
background: var(--accent-fill);
border-color: var(--accent);
color: #fff;
box-shadow: var(--glow);
+1 -1
View File
@@ -104,7 +104,7 @@
}
.genre[aria-pressed='true'] {
background: var(--accent);
background: var(--accent-fill);
border-color: var(--accent);
color: #fff;
box-shadow: var(--glow);
+4 -2
View File
@@ -1,6 +1,7 @@
import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session';
@@ -47,18 +48,19 @@ export function useUploadQuota() {
export function QuotaMeter() {
const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null;
const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language);
return (
<div className={u.quota}>
<span className={u.quotaText}>
<span className={u.quotaText} id={id}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span>
<div className={u.progress} role="progressbar" aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} />
</div>
</div>
+2
View File
@@ -23,6 +23,8 @@ export { default as albumIcon } from '@material-symbols/svg-400/rounded/album.sv
export { default as arrowDownIcon } from '@material-symbols/svg-400/rounded/arrow_downward.svg';
export { default as arrowUpIcon } from '@material-symbols/svg-400/rounded/arrow_upward.svg';
export { default as chevronRightIcon } from '@material-symbols/svg-400/rounded/chevron_right.svg';
export { default as downloadIcon } from '@material-symbols/svg-400/rounded/download.svg';
export { default as androidIcon } from '@material-symbols/svg-400/rounded/phone_android.svg';
export { default as deleteIcon } from '@material-symbols/svg-400/rounded/delete.svg';
export { default as editIcon } from '@material-symbols/svg-400/rounded/edit.svg';
export { default as folderIcon } from '@material-symbols/svg-400/rounded/folder.svg';
+3 -3
View File
@@ -53,13 +53,13 @@
}
.primary {
background: var(--accent);
background: var(--accent-fill);
color: #fff;
box-shadow: var(--glow);
}
.primary:hover:not(:disabled) {
background: var(--accent2);
background: var(--accent-fill2);
}
.secondary {
@@ -186,7 +186,7 @@
}
.segmented button[aria-pressed='true'] {
background: var(--accent);
background: var(--accent-fill);
color: #fff;
}
+21
View File
@@ -41,6 +41,19 @@ button {
cursor: pointer;
}
/* Элементы управления не выделяются при кликах и двойных кликах — текст остаётся выделяемым на страницах. */
button,
[role='button'],
[role='tab'],
[role='option'],
[role='menuitem'],
[role='listitem'],
nav a,
label {
user-select: none;
-webkit-user-select: none;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
@@ -53,6 +66,14 @@ button {
}
}
/* Выезжающие справа панели (очередь, docs/design.md#4). */
@keyframes pipuSlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
+20 -4
View File
@@ -27,12 +27,15 @@
padding-bottom: 132px;
}
/* Над контентом: выпадающие подсказки поиска и меню профиля перекрывают страницу, а не уходят под неё. */
.top {
grid-area: top;
display: flex;
z-index: 30;
/* Три зоны: логотип, середина, профиль; поиск — в одной из них (настройка «Расположение поиска»). */
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 24px;
justify-content: space-between;
gap: 16px;
padding: 0 20px;
border-bottom: 1px solid var(--line);
background: var(--bar-a);
@@ -51,9 +54,22 @@
}
}
.topLeft {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.topCenter {
display: flex;
justify-content: center;
}
.topRight {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
@@ -155,7 +171,7 @@
height: 20px;
padding: 0 6px;
border-radius: var(--radius-pill);
background: var(--accent);
background: var(--accent-fill);
color: #fff;
font-size: 11px;
font-weight: 700;
+12 -4
View File
@@ -22,6 +22,7 @@ import {
dislikeFillIcon,
dislikeIcon,
settingsIcon,
androidIcon,
storageFillIcon,
storageIcon, inboxFillIcon, inboxIcon, myUploadsFillIcon, myUploadsIcon, uploadFillIcon, uploadIcon, tuneFillIcon, tuneIcon} from '../shared/ui/icons';
import { Avatar, cx, Icon, Logo, Menu } from '../shared/ui/ui';
@@ -92,6 +93,8 @@ function Shell() {
const name = user.display_name ?? user.username;
const staff = user.role !== 'user';
const island = user.ui_prefs.web_player === 'island';
const searchAt = user.ui_prefs.search_position ?? 'center';
const search = <SearchBox align={searchAt} />;
const counts = useCollectionCounts();
const pending = usePendingUploads(user.role === 'admin');
@@ -99,11 +102,15 @@ function Shell() {
<div className={cx(s.shell, island && s.island)}>
{user.ui_prefs.ambient !== false && <ShellAmbient />}
<header className={s.top}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
<SearchBox />
<div className={s.topLeft}>
<Link to="/" className={s.logoLink}>
<Logo />
</Link>
{searchAt === 'left' && search}
</div>
<div className={s.topCenter}>{searchAt === 'center' && search}</div>
<div className={s.topRight}>
{searchAt === 'right' && search}
<Menu
label={t('profile.menu')}
triggerClassName={s.profileButton}
@@ -122,6 +129,7 @@ function Shell() {
}
items={[
{ label: t('profile.settings'), icon: settingsIcon, onSelect: () => void navigate({ to: '/settings' }) },
{ label: t('profile.android_app'), icon: androidIcon, onSelect: () => void navigate({ to: '/android' }) },
{ label: t('profile.logout'), icon: logoutIcon, danger: true, onSelect: () => void session.logout() },
]}
/>
+9 -1
View File
@@ -246,7 +246,15 @@ a.source:hover {
background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow);
animation: pipuIn 0.2s ease-out;
animation: pipuSlideIn 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawerNote {
margin: 0;
padding: 12px 18px 16px;
border-top: 1px solid var(--line);
color: var(--text3);
font-size: 13px;
}
.drawerIsland {
+25 -5
View File
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from 'react';
import { useCallback, useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
@@ -35,6 +35,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const player = usePlayer();
const st = usePlayerState();
const [queueOpen, setQueueOpen] = useState(false);
const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId();
const closeQueue = useCallback(() => {
setQueueOpen(false);
queueButton.current?.focus(); // фокус — туда, откуда открыли
}, []);
const track = st.track;
if (!track) {
@@ -167,10 +173,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button>
<button
ref={queueButton}
type="button"
className={cx(s.icon, queueOpen && s.accent)}
aria-label={t('player.queue')}
aria-expanded={queueOpen}
aria-controls={queueOpen ? queueId : undefined}
onClick={() => setQueueOpen((v) => !v)}
>
<Icon src={queueIcon} size={20} />
@@ -195,23 +203,33 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/>
</div>
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />}
{queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
</footer>
);
}
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose: () => void }) {
function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation();
const player = usePlayer();
const st = usePlayerState();
const upcoming = st.queue.slice(st.index, st.index + 40);
const close = useRef<HTMLButtonElement>(null);
// Клавиатура: при открытии фокус в панель, Escape закрывает.
useEffect(() => {
close.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal(
<aside className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}>
<span>{t('player.queue')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} />
</button>
</div>
@@ -221,6 +239,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
key={`${tr.id}-${k}`}
type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)}
aria-current={k === 0 || undefined}
onClick={() => player.jump(st.index + k)}
>
<Cover album={tr.album} size={40} />
@@ -232,6 +251,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
</button>
))}
</div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>,
document.body,
);
+47 -6
View File
@@ -1,12 +1,17 @@
.box {
position: relative;
flex: 1;
max-width: 520px;
flex: none;
width: 40px;
transition: width 0.32s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.expanded {
width: min(520px, 46vw);
}
.icon {
position: absolute;
left: 14px;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: var(--text3);
@@ -16,13 +21,36 @@
.input {
width: 100%;
height: 40px;
padding: 0 40px 0 42px;
padding: 0 40px 0 40px;
border-radius: var(--radius-pill);
border: 1px solid var(--line);
background: var(--surface2);
color: var(--text);
font: inherit;
outline: none;
transition:
border-color 0.2s,
box-shadow 0.2s,
background 0.2s;
}
/* Свёрнутое поле — круглая кнопка: без подсказки и курсора ввода. */
.box:not(.expanded) .input {
padding: 0;
cursor: pointer;
color: transparent;
}
.box:not(.expanded) .input::placeholder {
color: transparent;
}
.box:not(.expanded) .input:hover {
background: var(--surface3);
}
.box:not(.expanded) .kbd {
display: none;
}
.input:focus {
@@ -56,8 +84,7 @@
.popover {
position: absolute;
left: 0;
right: 0;
width: min(520px, calc(100vw - 32px));
top: calc(100% + 8px);
z-index: 45;
max-height: min(70vh, 560px);
@@ -70,6 +97,20 @@
animation: pipuIn 0.15s ease-out;
}
/* Подсказки раскрываются в сторону свободного места: слева — вправо, справа — влево, по центру — от середины. */
.left .popover {
left: 0;
}
.right .popover {
right: 0;
}
.center .popover {
left: 50%;
transform: translateX(-50%);
}
.corrected {
margin: 4px 10px 8px;
font-size: 13px;
+20 -5
View File
@@ -23,8 +23,11 @@ type Item = {
go: () => void;
};
/** Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент. */
export function SearchBox() {
/**
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
*/
export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'right' }) {
const { t } = useTranslation();
const session = useSession();
const navigate = useNavigate();
@@ -34,6 +37,10 @@ export function SearchBox() {
const [debounced, setDebounced] = useState('');
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const [focused, setFocused] = useState(false);
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
const expanded = focused || q !== '';
useEffect(() => {
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
@@ -141,7 +148,7 @@ export function SearchBox() {
const optionId = (i: number) => `${listId}-${i}`;
return (
<div className={s.box}>
<div className={cx(s.box, expanded && s.expanded, s[align])}>
<Icon src={searchIcon} size={18} className={s.icon} />
<input
ref={inputRef}
@@ -160,8 +167,16 @@ export function SearchBox() {
setOpen(true);
setActive(-1);
}}
onFocus={() => setOpen(true)}
onBlur={() => setTimeout(close, 120)}
onFocus={() => {
// Вернулись в поле раньше, чем сработало закрытие по blur, — подсказки не прячем.
clearTimeout(blurTimer.current);
setFocused(true);
setOpen(true);
}}
onBlur={() => {
setFocused(false);
blurTimer.current = setTimeout(close, 120);
}}
onKeyDown={onKeyDown}
/>
<kbd className={s.kbd} aria-hidden>
+4 -2
View File
@@ -45,6 +45,8 @@ SUPPORTED_LOCALES=ru,en
MIN_ANDROID_VERSION_CODE=0
# --- Самообновление Android (M5) ---
GITEA_URL=https://git.example.lan
GITEA_REPO=owner/pipu-music
# Пусто — install.sh и update.sh возьмут из origin клона (например, https://gitea.example.com и owner/pipu-music).
# Если сервер не видит Gitea по внешнему адресу, укажите внутренний: GITEA_URL=http://10.0.0.5:3000
GITEA_URL=
GITEA_REPO=
GITEA_TOKEN=
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Резервные копии PostgreSQL (docs/deployment.md#бэкапы). Работает в контейнере backup (compose.yaml) и в CI;
# подключение — стандартные PGHOST, PGUSER, PGPASSWORD, PGDATABASE.
# backup.sh loop — каждый день в BACKUP_HOUR (UTC): копия и ротация; раз в неделю — проверка восстановления
# backup.sh once — копия сейчас; печатает путь к файлу
# backup.sh verify FILE — восстановить FILE во временную базу и сверить таблицы и число строк
# backup.sh restore FILE — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены);
# прежняя база остаётся рядом как <db>_before_restore_<время>
set -euo pipefail
DIR="${BACKUP_DIR:-/backups}"
DB="${PGDATABASE:-pipu}"
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
log() { echo "$(date -u +%FT%TZ) backup: $*" >&2; }
# Число строк в каждой таблице базы $1: «таблица<TAB>строк», по алфавиту.
counts() {
psql -X -d "$1" -Atq -F $'\t' -c "
SELECT table_name,
(xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name),
false, true, '')))[1]::text
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY 1"
}
rotate() {
# shellcheck disable=SC2012 # имена файлов наши, без пробелов
ls -1t "$DIR"/daily/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_DAILY + 1)) | xargs -r rm -f --
# shellcheck disable=SC2012
ls -1t "$DIR"/weekly/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_WEEKLY + 1)) | xargs -r rm -f --
find "$DIR/daily" "$DIR/weekly" -name '*.counts' | while read -r c; do
[ -f "${c%.counts}.dump" ] || rm -f -- "$c"
done
}
once() {
mkdir -p "$DIR/daily" "$DIR/weekly"
local name file
name="pipu-$(date -u +%Y%m%d-%H%M%S)"
file="$DIR/daily/$name.dump"
pg_dump -Fc -d "$DB" -f "$file.part"
pg_restore -l "$file.part" >/dev/null # оглавление читается — архив не обрезан
counts "$DB" >"$file.counts" # для verify: что было в базе в момент копии
mv "$file.part" "$file"
# Еженедельная копия — первая за 7 дней (не зависит от того, в какой день сервер был выключен).
if ! find "$DIR/weekly" -name 'pipu-*.dump' -mtime -7 | grep -q .; then
cp "$file" "$DIR/weekly/$name.dump"
cp "$file.counts" "$DIR/weekly/$name.dump.counts"
fi
rotate
log "$file ($(du -h "$file" | cut -f1))"
echo "$file"
}
verify() {
local file="$1" tmpdb="${DB}_verify" got want
[ -f "$file" ] || { log "нет файла $file"; return 1; }
dropdb --if-exists "$tmpdb"
createdb "$tmpdb"
if ! pg_restore --no-owner --exit-on-error -d "$tmpdb" "$file"; then
dropdb --if-exists "$tmpdb"
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file не восстанавливается"
return 1
fi
got="$(counts "$tmpdb")"
dropdb "$tmpdb"
want="$(cat "$file.counts" 2>/dev/null || counts "$DB")"
# Таблица пропала или опустела — копия негодна. Расхождение в числе строк — рабочая база менялась во время
# копии (предупреждение); VERIFY_EXACT=1 (CI, база в покое) — тоже ошибка.
local bad=0 table n m
while IFS=$'\t' read -r table n; do
m="$(printf '%s\n' "$got" | awk -F'\t' -v t="$table" '$1 == t { print $2 }')"
if [ -z "$m" ]; then
log "в копии нет таблицы $table"
bad=1
elif [ "$m" = 0 ] && [ "$n" != 0 ]; then
log "в копии пуста таблица $table (было $n)"
bad=1
elif [ "$m" != "$n" ]; then
log "$table: $m строк в копии, $n при копировании"
[ "${VERIFY_EXACT:-}" = 1 ] && bad=1
fi
done <<<"$want"
if [ "$bad" = 1 ]; then
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file"
return 1
fi
log "проверено: $file восстанавливается ($(printf '%s\n' "$got" | wc -l) таблиц)"
date -u +%FT%TZ >"$DIR/.last-verify"
}
restore() {
local file="$1" old
verify "$file"
old="${DB}_before_restore_$(date -u +%Y%m%d%H%M%S)"
psql -X -d postgres -Atqc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '$DB' AND pid <> pg_backend_pid()" >/dev/null
psql -X -d postgres -qc "ALTER DATABASE \"$DB\" RENAME TO \"$old\""
createdb "$DB"
if ! pg_restore --no-owner --exit-on-error -d "$DB" "$file"; then
dropdb "$DB"
psql -X -d postgres -qc "ALTER DATABASE \"$old\" RENAME TO \"$DB\""
log "восстановление не удалось — рабочая база не тронута"
return 1
fi
log "база $DB восстановлена из $file; прежняя сохранена как $old (удалить: dropdb $old)"
}
loop() {
local hour="${BACKUP_HOUR:-3}" now next file
log "ежедневная копия в $hour:30 UTC, хранится $KEEP_DAILY ежедневных и $KEEP_WEEKLY еженедельных"
while true; do
now="$(date -u +%s)"
next="$(date -u -d "today $hour:30" +%s)"
[ "$next" -gt "$now" ] || next="$(date -u -d "tomorrow $hour:30" +%s)"
sleep $((next - now))
if ! file="$(once)"; then
log "КОПИЯ НЕ СДЕЛАНА"
continue
fi
if [ -z "$(find "$DIR" -maxdepth 1 -name .last-verify -mtime -7)" ]; then
verify "$file" || true
fi
done
}
case "${1:-}" in
loop) loop ;;
once) once ;;
verify) verify "${2:?укажите файл копии}" ;;
restore) restore "${2:?укажите файл копии}" ;;
*)
sed -n '2,9p' "$0" >&2
exit 2
;;
esac
+23
View File
@@ -20,11 +20,13 @@ x-library-ro: &library-ro
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
# (docs/library-and-storage.md#запись-в-хранилище).
# rslave: диск, смонтированный на хосте уже после старта контейнеров, становится виден внутри без перезапуска.
x-library-host-ro: &library-host-ro
type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host
read_only: true
bind: { propagation: rslave }
x-restart: &restart
restart: unless-stopped
@@ -96,6 +98,7 @@ services:
- type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host-rw
bind: { propagation: rslave }
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender:
@@ -142,6 +145,26 @@ services:
interval: 5s
retries: 20
# Ежедневная копия базы и еженедельная проверка восстановления (deploy/backup.sh, docs/deployment.md#бэкапы).
backup:
<<: *restart
image: pgvector/pgvector:0.8.7-pg17
entrypoint: ["bash", "/backup.sh"]
command: ["loop"]
environment:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: pipu
BACKUP_HOUR: ${BACKUP_HOUR:-3}
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
volumes:
- ./backup.sh:/backup.sh:ro
- ${PIPU_DATA:-/srv/pipu}/backups:/backups
depends_on:
postgres: { condition: service_healthy }
redis:
<<: *restart
image: redis:7.4-alpine
+33
View File
@@ -76,6 +76,39 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi
done
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
fill_gitea() {
local url repo origin host path
url="$(sed -n 's/^GITEA_URL=//p' .env)"
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
case "$url" in "" | *example.*) url="" ;; esac
case "$repo" in "" | owner/*) repo="" ;; esac
[ -n "$url" ] && [ -n "$repo" ] && return 0
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || return 0
case "$origin" in
http://* | https://*)
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
ssh://*)
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
*:*)
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
path="${origin#*:}" ;;
*) return 0 ;;
esac
path="${path%.git}"
[ -n "$url" ] || url="$host"
[ -n "$repo" ] || repo="$path"
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
done
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
}
fill_gitea
# LIBRARY_HOST, переданный при повторном запуске, попадает и в уже существующий .env
if [ -n "$LIBRARY_HOST" ]; then
if grep -q '^LIBRARY_HOST=' .env; then
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Восстановление базы из резервной копии (docs/deployment.md#бэкапы).
# ./restore.sh — список копий
# ./restore.sh daily/pipu-20261020-033000.dump
# Сервисы, пишущие в базу, останавливаются на время восстановления; прежняя база сохраняется рядом.
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
DATA="$(sed -n 's/^PIPU_DATA=//p' .env)"
DATA="${DATA:-/srv/pipu}"
if [ -z "${1:-}" ]; then
echo "Копии в $DATA/backups (новые сверху):"
(cd "$DATA/backups" && ls -1t daily/*.dump weekly/*.dump 2>/dev/null) || echo " копий нет"
exit 0
fi
rel="${1#"$DATA/backups/"}"
[ -f "$DATA/backups/$rel" ] || { echo "нет файла $DATA/backups/$rel" >&2; exit 1; }
echo "→ восстановление из $rel: api, рекомендации и анализ будут остановлены на время восстановления"
read -r -p "Продолжить? [y/N] " yes
[ "$yes" = y ] || [ "$yes" = Y ] || exit 1
docker compose stop api recommender analyzer storage-agent
if docker compose run --rm backup restore "/backups/$rel"; then
status=0
else
status=1
fi
docker compose up -d
[ "$status" = 0 ] || { echo "восстановление не удалось — база не изменена" >&2; exit 1; }
echo "готово. Поиск переиндексируется при старте api (или вручную: «Администрирование → Библиотека»)."
+48
View File
@@ -26,6 +26,48 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi
done
# Самообновление Android: адрес Gitea и репозиторий — из origin этого клона, если в .env пусто
# или осталась заглушка из .env.example (https://host/owner/repo, git@host:owner/repo, ssh://git@host:port/owner/repo).
fill_gitea() {
local url repo origin host path
url="$(sed -n 's/^GITEA_URL=//p' .env)"
repo="$(sed -n 's/^GITEA_REPO=//p' .env)"
case "$url" in "" | *example.*) url="" ;; esac
case "$repo" in "" | owner/*) repo="" ;; esac
[ -n "$url" ] && [ -n "$repo" ] && return 0
origin="$(git -c safe.directory='*' remote get-url origin 2>/dev/null || true)"
[ -n "$origin" ] || return 0
case "$origin" in
http://* | https://*)
host="$(printf '%s' "$origin" | sed -E 's#^(https?://)([^@/]*@)?([^/]+)/.*#\1\3#')"
path="$(printf '%s' "$origin" | sed -E 's#^https?://[^/]+/##')" ;;
ssh://*)
host="https://$(printf '%s' "$origin" | sed -E 's#^ssh://([^@/]*@)?([^:/]+).*#\2#')"
path="$(printf '%s' "$origin" | sed -E 's#^ssh://[^/]+/##')" ;;
*:*)
host="https://$(printf '%s' "$origin" | sed -E 's#^([^@]*@)?([^:]+):.*#\2#')"
path="${origin#*:}" ;;
*) return 0 ;;
esac
path="${path%.git}"
[ -n "$url" ] || url="$host"
[ -n "$repo" ] || repo="$path"
for kv in "GITEA_URL=$url" "GITEA_REPO=$repo"; do
if grep -q "^${kv%%=*}=" .env; then sed -i "s|^${kv%%=*}=.*|$kv|" .env; else echo "$kv" >> .env; fi
done
echo "→ самообновление Android: $url, $repo (из git remote; поменять — в .env)"
}
fill_gitea
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
echo "LIBRARY_HOST=$LIBRARY_HOST пуст или не существует — диск с музыкой не смонтирован?" >&2
echo " проверьте: findmnt $LIBRARY_HOST; смонтируйте (sudo mount -a) и повторите." >&2
echo " Если папка и должна быть пустой: PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh ${1:-}" >&2
exit 1
fi
REF="${1:-}"
git fetch --tags --prune --quiet origin
@@ -53,6 +95,12 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
fi
fi
# Копия базы перед обновлением: миграции назад не откатываются (deploy/restore.sh — вернуть).
if [ -n "$(docker compose ps -q --status running postgres 2>/dev/null)" ]; then
echo "→ резервная копия базы"
docker compose run --rm --no-deps backup once >/dev/null
fi
echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF"
+1
View File
@@ -23,6 +23,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [testing.md](testing.md) | Стратегия тестирования |
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, бэкапы, диагностика |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы |
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
+139
View File
@@ -0,0 +1,139 @@
# Руководство администратора
Повседневная работа с сервером после установки ([install-guide.md](install-guide.md)): пользователи, музыка,
очередь на добавление, «Моя волна», обновления, бэкапы и диагностика. Все команды — из каталога
`/opt/pipu-music/deploy`. Справочник по сервисам и переменным — [deployment.md](deployment.md).
## 1. Первый вход
1. Логин `admin`. Пароль — `BOOTSTRAP_ADMIN_PASSWORD` из `.env`, а если он не задан — сгенерированный, его
печатает лог один раз: `docker compose logs api | grep -i bootstrap`.
2. Сервер сразу попросит сменить логин и пароль администратора (пароль ≥ 12 символов). До этого остальные
разделы недоступны.
3. Подключите хранилище с музыкой (раздел 3) — после первого скана появятся библиотека, поиск и «Моя волна».
## 2. Пользователи и роли
«Администрирование → Пользователи».
| Роль | Что может |
|---|---|
| Пользователь | Слушать, искать, собирать коллекцию, «Моя волна» |
| Модератор | Плюс: заводить пользователей и сбрасывать им пароли, смотреть статус библиотеки, предлагать треки |
| Администратор | Всё: хранилища, сканы, очередь на добавление, настройки сервера, роли, аудит |
Полная матрица прав — [auth-and-roles.md](auth-and-roles.md#матрица-прав).
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
раз, передайте его человеку. При первом входе пароль придётся сменить.
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
- Последнего администратора нельзя понизить, заблокировать или удалить.
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
паролей, изменения хранилищ и настроек, решения по очереди.
## 3. Хранилища и библиотека
«Администрирование → Хранилища».
- **Папка на сервере** — каталог хоста из `LIBRARY_HOST` в `.env` (например, смонтированный диск `/mnt/music`);
в форме указывается путь внутри него. **NFS** — сервер и экспорт; монтирует `storage-agent`.
- «Проверить» — доступ к хранилищу, число найденных аудиофайлов.
- **Шаблоны путей** («Шаблоны»): как из пути файла получить исполнителя, альбом, год и номер трека, если в тегах
пусто. «Предпросмотр» показывает разбор реальных путей до сохранения. Что важнее — теги или путь, задаётся
в карточке хранилища (по умолчанию теги).
- **Скан:** обычный — только новые и изменённые файлы (по расписанию — интервал в карточке хранилища — и после
изменения настроек), «Полный» — перечитать всё. Удалённые с диска файлы помечаются отсутствующими, а не стираются из истории.
- «Администрирование → Библиотека» — счётчики, прогресс скана и аудиоанализа, файлы с ошибками чтения
(битые файлы, неизвестные форматы) и «Переиндексировать поиск» — если поиск выдаёт устаревшее (обычно не нужно:
индекс обновляется сам и пересобирается при старте api).
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
Не удалось проанализировать часть треков (например, пока диск не был смонтирован) — «Повторить» под счётчиком
ошибок возвращает их в очередь; рядом показана самая частая ошибка. Пропавшие файлы анализатор не считает
попыткой и ждёт, пока диск вернётся.
## 4. Очередь на добавление
Модераторы и администраторы предлагают треки («Предложить треки»), администратор решает, что попадёт
в библиотеку. Подробно — [uploads.md](uploads.md).
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
получит те же исполнителя, альбом и номер трека. Хранилище по умолчанию — в «Настройках сервера».
- **Рассмотрение** («Очередь на добавление»): прослушать, поправить метаданные (в том числе сразу для всего
альбома), одобрить или отклонить с причиной — автор её увидит. Итоговый путь пересчитывается при правке.
Отметка «возможный дубль» — со ссылкой на похожий трек библиотеки; точные копии отсекаются ещё до загрузки.
- **Импорт:** одобренный файл записывается в хранилище только под окончательным именем, с проверкой контрольной
суммы; кроме самих аудиофайлов (и папок для них) на диск ничего не пишется.
- **Квота** («Настройки сервера»): общий лимит очереди (10 ГБ по умолчанию), лимит на одного модератора
(выключен) и максимальный размер файла (2 ГБ). Отклонённые файлы и дубли место не занимают; снижение лимита ниже
занятого не удаляет загрузки, просто новые не принимаются.
## 5. «Моя волна»
- «Администрирование → Библиотека → Моя волна за 7 дней»: сколько треков выдано, доля дослушанных до конца,
ранних скипов и лайков на 100 треков. Ориентир — ранних скипов меньше 30%.
- Таблица по **версиям модели**: `heuristic-v1` — эвристика, `heuristic-v1+cf` — она же с коллаборативной
фильтрацией (ALS), `lgbm-…` — обученный ранжировщик. По ней видно, стало ли лучше после смены модели.
- Обучение — каждую ночь в 3:00 UTC: ALS по прослушиваниям, бандит по источникам
кандидатов, ранжировщик по показам волны. Новая модель включается, только если по проверке на последней неделе
не хуже текущей. Ранжировщику нужно ~20 тыс. показов с известным исходом — до этого работает эвристика.
- Новые пользователи с почти пустой историей проходят **онбординг вкуса**: выбирают исполнителей и жанры
библиотеки. Пропуск тоже запоминается.
## 6. Обновление
```bash
./update.sh
```
Скрипт делает копию базы, переключается на последний релиз, собирает образы, перезапускает сервисы и ждёт
готовности; миграции применяются при старте api. Конкретная версия — `./update.sh v1.0.0`; откат на старую версию
требует `PIPU_ALLOW_DOWNGRADE=1` (миграции не откатываются — сначала проверьте совместимость). Перед обновлением
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
Раздать приложение — ссылка `https://<ваш-домен>/android` (без входа): кнопка «Скачать APK» последнего релиза,
адрес сервера для копирования и короткая инструкция; та же страница — внизу экрана входа и в меню профиля.
## 7. Бэкапы и восстановление
- Каждую ночь (`BACKUP_HOUR`, по умолчанию 3:30 UTC) контейнер `backup` делает копию базы в
`/srv/pipu/backups/daily` (14 последних) и раз в неделю — в `weekly` (8 последних).
- Раз в неделю копия **проверяется восстановлением** во временную базу. Результат:
`docker compose logs backup | tail`; при ошибке в логе — «ПРОВЕРКА НЕ ПРОЙДЕНА».
- Копия сейчас: `docker compose run --rm backup once`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/<файл>` — восстановить. Прежняя база
остаётся рядом как `pipu_before_restore_<время>`; убедившись, что всё в порядке, удалите её:
`docker compose exec postgres dropdb -U pipu pipu_before_restore_<время>`.
- В копию входит всё, что нельзя пересоздать: пользователи, история, лайки, состояние плеера, очередь на добавление,
результаты аудиоанализа и модели волны. Поиск, обложки и транскоды восстанавливаются сами.
- **Сама музыка не бэкапится** приложением — это ваш диск или NFS. Каталог бэкапов тоже лежит на сервере:
копируйте `/srv/pipu/backups` на другую машину, например `rsync` по cron.
## 8. Наблюдение
| Что | Как |
|---|---|
| Сервер жив и готов | `curl -s https://music.example.com/readyz` → все зависимости `ok` |
| Состояние сервисов | `docker compose ps` |
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`, `backup`) |
| Место на диске | `df -h /srv/pipu`; больше всего занимают `cache/transcode` (чистится сам) и `backups` |
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
## 9. Частые ситуации
| Ситуация | Что делать |
|---|---|
| Ни один трек не играет («Не удалось воспроизвести трек»), в логе api `no such file or directory` | Диск с музыкой не смонтирован: `findmnt <LIBRARY_HOST>`, `sudo mount -a`. Контейнеры увидят диск сами; скан на пустой папке прерывается с ошибкой «is the disk mounted?» и ничего не помечает пропавшим |
| Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» |
| Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан |
| Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» |
| Волна однообразная | Идёт ли аудиоанализ («Библиотека → Аудиоанализ»); чем больше прослушиваний, тем точнее волна |
| Загрузка застряла в «Обработке» | `docker compose logs api | grep upload`; повторный импорт — «Одобрить» ещё раз |
| Пользователь не может войти | Не заблокирован ли; «Сбросить пароль» |
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
+16 -2
View File
@@ -77,7 +77,7 @@
| `BOOTSTRAP_ADMIN_PASSWORD` | пусто → сгенерировать и вывести в лог | |
| `TRANSCODE_CACHE_LIMIT_GB` | `50` | |
| `TRANSCODE_CONCURRENCY` | `2` | |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | `https://git.home.lan`, `owner/pipu-music`, read-only токен | Для самообновления Android |
| `GITEA_URL`, `GITEA_REPO`, `GITEA_TOKEN` | пусто — из `git remote` (`install.sh`, `update.sh`); токен — для закрытого репозитория | Самообновление Android и страница `/android` |
| `PIPU_FILE_UID`, `PIPU_FILE_GID` | `1000` | Владелец файлов, которые агент записывает в принимающие хранилища |
| `UPLOADS_DIR` | `/uploads` | Staging очереди на добавление (лимит очереди задаётся в админке) |
| `LOG_LEVEL` | `info` | |
@@ -97,7 +97,21 @@
## Бэкапы
- `pg_dump` ежедневно (cron-контейнер `backup`), хранить 14 дней + еженедельные 8 недель → `/srv/pipu/backups`.
- Контейнер `backup` (`deploy/backup.sh loop`): каждый день в `BACKUP_HOUR`:30 UTC (по умолчанию 3:30) —
`pg_dump -Fc` → `/srv/pipu/backups/daily/`, хранится `BACKUP_KEEP_DAILY` = 14 последних; первая копия за неделю
копируется в `weekly/` (`BACKUP_KEEP_WEEKLY` = 8). Рядом с копией — `*.counts`: число строк в каждой таблице.
- Раз в неделю копия **проверяется восстановлением**: разворачивается во временную базу `pipu_verify`, сверяются
таблицы и число строк, база удаляется; время последней успешной проверки — `backups/.last-verify`, ошибки —
в `docker compose logs backup` («ПРОВЕРКА НЕ ПРОЙДЕНА»). Тот же цикл копия → проверка → восстановление
выполняется в CI (`ci / db`) на базе после сквозного сценария.
- `update.sh` перед обновлением делает внеочередную копию.
- Вручную: `docker compose run --rm backup once` — копия сейчас; `docker compose run --rm backup verify /backups/daily/<файл>`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/pipu-….dump` — останавливает api, рекомендации и
анализ, проверяет копию, переименовывает текущую базу в `pipu_before_restore_<время>` (страховка; удалить —
`docker compose exec postgres dropdb -U pipu <имя>`), восстанавливает и запускает сервисы; поиск переиндексируется
при старте api. Redis (сессии волны, очереди) не восстанавливается — пользователи просто начнут новую волну.
- Копии лежат на том же сервере: для защиты от потери диска синхронизируйте `/srv/pipu/backups` на другую машину
(например, `rclone sync` или `rsync` по cron).
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
- Сама музыка — на NFS, вне зоны ответственности приложения.
+2 -1
View File
@@ -48,8 +48,9 @@
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
| `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении |
| `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки |
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#737F9C` | `#0A0F1E` / `#4B5675` / `#677290` | Текст: основной / вторичный / подписи |
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#7F8BA8` | `#0A0F1E` / `#4B5675` / `#5C6785` | Текст: основной / вторичный / подписи |
| `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки |
| `accent-fill` / `accent-fill2` | `#1A66E6` / `#1F6FF2` | `#1A63DB` / `#165ED6` | Заливка под белым текстом (кнопки, выбранные сегменты) и её hover — контраст ≥ 4.5:1 |
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
+17 -4
View File
@@ -250,8 +250,10 @@ curl -s https://music.example.com/readyz
приложение попросит обновиться.
Дальше приложение обновляется само: с версии M5 оно проверяет новые релизы через ваш сервер
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Для этого в `.env` сервера нужно заполнить
`GITEA_URL`, `GITEA_REPO` и `GITEA_TOKEN` (токен только на чтение).
и предлагает обновиться ([mobile.md](mobile.md#самообновление)). Адрес Gitea и репозиторий (`GITEA_URL`, `GITEA_REPO`)
`install.sh` и `update.sh` берут из `git remote` клона; `GITEA_TOKEN` (только на чтение) нужен, если репозиторий
закрытый. Если сервер не видит Gitea по внешнему адресу — впишите внутренний в `GITEA_URL`. Раздать приложение —
ссылка `https://<ваш-домен>/android`.
> Приложению нужен настоящий сертификат. С самоподписанным сертификатом и голым IP-адресом подключиться не получится.
> Поэтому нужен домен с Let's Encrypt, как в этой инструкции.
@@ -314,12 +316,21 @@ cd /opt/pipu-music/deploy && ./update.sh
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
автоматически при старте API.
Бэкап базы (всё остальное восстанавливается само — см. [deployment.md](deployment.md#бэкапы)):
Бэкапы базы делаются сами: каждую ночь в `/srv/pipu/backups`, раз в неделю копия проверяется восстановлением,
перед каждым `update.sh` — внеочередная копия. Всё остальное восстанавливается само — см.
[deployment.md](deployment.md#бэкапы). Копия сейчас и восстановление:
```bash
docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump
cd /opt/pipu-music/deploy && docker compose run --rm backup once
```
```bash
cd /opt/pipu-music/deploy && ./restore.sh
```
Без аргумента `restore.sh` показывает список копий; с именем файла (`./restore.sh daily/pipu-20261020-033000.dump`)
восстанавливает базу, сохранив прежнюю рядом.
Остановить сервис, сохранив данные:
```bash
@@ -339,6 +350,8 @@ cd /opt/pipu-music/deploy && docker compose down
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
| Сервисы не стартуют: `… is not a shared or slave mount` | `sudo mount --make-rshared /` (и добавьте в автозагрузку); на systemd-системах корень и так shared |
| `update.sh`: «LIBRARY_HOST … пуст» | Диск с музыкой не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; если папка и должна быть пустой — `PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh` |
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` |
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) |
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
+4 -2
View File
@@ -161,7 +161,9 @@ Meilisearch, индексы:
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
отменяет предыдущий запрос (`AbortController`).
- Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше
в выдаче `suggest` (re-rank первых 20 на стороне `api`).
- Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
(`internal/search/personal.go`). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10,
реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на
2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка.
- Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача
`reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).
+14
View File
@@ -16,6 +16,18 @@
| i18n | i18next, словари из `packages/i18n` |
| Минимальная версия | Android 8.0 (API 26), target — актуальный API |
## Сборка APK: ABI и 16 КБ страницы
- APK только под `arm64-v8a` (`plugins/withArm64Only.js`): нативные модули собираются под одну ABI, а без фильтра
в APK попадали неполные каталоги `x86`, `x86_64`, `armeabi-v7a` из библиотек React Native — эмулятор x86 и
32-битные устройства выбирали их и падали при старте (`couldn't find DSO to load: libexpo-modules-core.so`).
Эмулятор для проверки — образ arm64 или настоящее устройство.
- Все `.so` выровнены по 16 КБ: устройства с Android 15+ и 16 КБ страницами памяти не загружают библиотеки
с выравниванием 4 КБ, приложение закрывается сразу после запуска. Skia 2.0.0-next.4 собиралась без
`ANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES` — флаг добавлен pnpm-патчем (`patches/`).
- `scripts/check-apk.mjs` в релизе проверяет ABI, наличие ключевых библиотек и выравнивание; без этого релиз
не публикуется.
## Экраны
Макет — `Pipu Android.dc.html`, сводка решений — [design.md](design.md#android).
@@ -82,6 +94,8 @@
## Самообновление
Первая установка — публичная страница `/android` веб-клиента: последний APK, адрес сервера и инструкция.
Приложение распространяется только через Gitea Releases (не Google Play).
**Поток:**
+2
View File
@@ -159,6 +159,8 @@ score = 0.35·cos(e, session_vec) + 0.25·max_c cos(e, centroid_c) + 0.15·artis
и не хуже активной более чем на 2%. Модель хранится строкой в `model_versions.artifact` (kind `ranker`),
сервис загружает активную при старте и после ночного обучения; в выдаче её порядок заменяет оценку v1,
MMR-разнообразие и квоты остаются. Пока модели нет, `model_ver` показа — версия v1.
Онлайн-сравнение — админка «Библиотека → Моя волна»: доля доигрываний, ранних скипов и лайков
за 7 дней отдельно по каждому `model_ver` показа.
### 6.4 Занятия и настроения
+5
View File
@@ -49,10 +49,15 @@
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
- Персонализация suggest.
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
- **Состояние:** всё реализовано (v0.8, v1.0). Ранжировщик включается сам после ~20 тыс. размеченных показов;
критерий проверяется по метрикам версий в админке («Библиотека → Моя волна») и NDCG в `model_versions`.
## M7 — Стабилизация (v1.0)
- Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора,
доступность, полировка анимаций, багфиксы.
- **Состояние:** v1.0 — бэкапы с еженедельной проверкой восстановления (и цикл копия → проверка → восстановление
в CI), [руководство администратора](admin-guide.md), аудит доступности WCAG 2.1 AA, k6-smoke
(`tests/load/smoke.js`, запускается вручную на сервере перед релизом).
## Дальше (идеи)
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
+25 -1
View File
@@ -49,4 +49,28 @@
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
- Тест на каждый исправленный баг.
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
- Нагрузочный smoke (k6, вручную перед релизом): 100 одновременных слушателей, 20 rps на suggest — p95 в рамках целей.
- Нагрузочный smoke (k6, вручную перед релизом) — см. ниже.
## Нагрузочный smoke
`tests/load/smoke.js` (k6 ≥ 0.50), 3 минуты: 100 одновременных слушателей волны (старт волны, начало потока
каждого трека — первые 64 КБ, события прослушивания, следующая пачка с отзывами) и 20 запросов подсказок поиска
в секунду. Пороги — цели из [api.md](api.md) и [recommendations.md](recommendations.md#10-требования-к-производительности):
| Запрос | p95 |
|---|---|
| `GET /search/suggest` | < 50 мс |
| `POST /wave/sessions/{id}/next` | < 150 мс |
| `POST /wave/sessions` | < 300 мс |
| начало потока (`Range: bytes=0-65535`) | < 300 мс |
| `POST /events` | < 100 мс |
Ошибок < 1%. k6 завершается с ненулевым кодом, если порог не выполнен.
```bash
k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
```
Пользователь — отдельный обычный (`role=user`): тест наполняет его историю и лайки. Нагрузку можно менять:
`-e LISTENERS=20 -e SUGGEST_RPS=5 -e DURATION=1m`. Запускать с машины в той же сети, что и сервер, —
иначе в цифрах будет задержка канала.
+12
View File
@@ -101,3 +101,15 @@ apps/web/src/
## Доступность
- Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA.
## Доступность
Цель — WCAG 2.1 AA. Проверено axe-core на всех экранах в тёмной и светлой темах (с играющим треком и открытой
очередью) — нарушений нет. Правила, которые держим:
- текст на заливке акцента — только `--accent-fill` (белый текст ≥ 4.5:1), вторичный текст `--text3` — ≥ 4.5:1
на фоне и поверхностях;
- у каждого экрана есть `h1`; у кнопок-иконок — `aria-label`, у переключателей — `aria-pressed`;
- списки треков — `role="list"` / `listitem` (строка фокусируется, Enter запускает трек, лайк и ссылки — отдельные
элементы); прогресс-бары подписаны через `aria-labelledby`;
- выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку;
- `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена.
+1 -1
View File
@@ -13,7 +13,7 @@
"generate": "turbo run generate",
"i18n:check": "pnpm --filter @pipu/i18n check",
"version:info": "node scripts/version.mjs",
"test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs"
"test:scripts": "node --test scripts/version.test.mjs scripts/release.test.mjs scripts/check-apk.test.mjs"
},
"devDependencies": {
"turbo": "2.5.4",
@@ -880,6 +880,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/v1/admin/library/analysis/retry": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Вернуть в очередь треки, которые не удалось проанализировать (admin) */
post: operations["retryAnalysis"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/admin/search/reindex": {
parameters: {
query?: never;
@@ -1492,6 +1509,11 @@ export interface components {
wave_variant?: "orb" | "liquid";
/** @enum {string} */
web_player?: "bar" | "island";
/**
* @description Где в топбаре веба поиск (по умолчанию center)
* @enum {string}
*/
search_position?: "center" | "left" | "right";
/** @enum {string} */
mobile_player?: "cover" | "wave";
ambient?: boolean;
@@ -2012,6 +2034,8 @@ export interface components {
done: number;
failed: number;
analyzer_ver: string | null;
/** @description Самая частая ошибка среди неудавшихся — подсказка, что чинить */
top_error: string | null;
};
/** @description Метрики волны за 7 дней */
WaveMetrics: {
@@ -2021,6 +2045,15 @@ export interface components {
/** @description Лайков на 100 треков */
like_rate: number | null;
sessions: number;
/** @description Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн */
by_model: components["schemas"]["WaveModelMetrics"][];
};
WaveModelMetrics: {
model_ver: string;
served: number;
listen_through_rate: number | null;
early_skip_rate: number | null;
like_rate: number | null;
};
AndroidRelease: {
/** @example 0.6.1 */
@@ -3346,6 +3379,29 @@ export interface operations {
default: components["responses"]["Problem"];
};
};
retryAnalysis: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Сколько задач снова в очереди */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
requeued: number;
};
};
};
default: components["responses"]["Problem"];
};
};
reindexSearch: {
parameters: {
query?: never;
@@ -22,9 +22,11 @@
--line2: rgba(130,160,255,.18);
--text: #EDF1FF;
--text2: #A0ABC6;
--text3: #737F9C;
--text3: #7F8BA8;
--accent: #2F80FF;
--accent2: #5E9BFF;
--accent-fill: #1A66E6;
--accent-fill2: #1F6FF2;
--accent-soft: rgba(47,128,255,.15);
--violet: #9B7CFF;
--violet-soft: rgba(124,92,255,.16);
@@ -56,9 +58,11 @@
--line2: rgba(20,35,80,.17);
--text: #0A0F1E;
--text2: #4B5675;
--text3: #677290;
--text3: #5C6785;
--accent: #1F6FF2;
--accent2: #1F6FF2;
--accent-fill: #1A63DB;
--accent-fill2: #165ED6;
--accent-soft: rgba(31,111,242,.10);
--violet: #6A47F0;
--violet-soft: rgba(106,71,240,.10);
@@ -90,9 +94,11 @@
--line2: rgba(20,35,80,.17);
--text: #0A0F1E;
--text2: #4B5675;
--text3: #677290;
--text3: #5C6785;
--accent: #1F6FF2;
--accent2: #1F6FF2;
--accent-fill: #1A63DB;
--accent-fill2: #165ED6;
--accent-soft: rgba(31,111,242,.10);
--violet: #6A47F0;
--violet-soft: rgba(106,71,240,.10);
@@ -123,9 +129,11 @@
--line2: rgba(130,160,255,.18);
--text: #EDF1FF;
--text2: #A0ABC6;
--text3: #737F9C;
--text3: #7F8BA8;
--accent: #2F80FF;
--accent2: #5E9BFF;
--accent-fill: #1A66E6;
--accent-fill2: #1F6FF2;
--accent-soft: rgba(47,128,255,.15);
--violet: #9B7CFF;
--violet-soft: rgba(124,92,255,.16);
@@ -13,9 +13,11 @@ export const tokens = {
"line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF",
"text2": "#A0ABC6",
"text3": "#737F9C",
"text3": "#7F8BA8",
"accent": "#2F80FF",
"accent2": "#5E9BFF",
"accent-fill": "#1A66E6",
"accent-fill2": "#1F6FF2",
"accent-soft": "rgba(47,128,255,.15)",
"violet": "#9B7CFF",
"violet-soft": "rgba(124,92,255,.16)",
@@ -42,9 +44,11 @@ export const tokens = {
"line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E",
"text2": "#4B5675",
"text3": "#677290",
"text3": "#5C6785",
"accent": "#1F6FF2",
"accent2": "#1F6FF2",
"accent-fill": "#1A63DB",
"accent-fill2": "#165ED6",
"accent-soft": "rgba(31,111,242,.10)",
"violet": "#6A47F0",
"violet-soft": "rgba(106,71,240,.10)",
+5 -5
View File
@@ -1,11 +1,11 @@
{
"$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate",
"$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate. accent-fill — заливка под белым текстом (контраст ≥ 4.5:1, WCAG AA)",
"color": {
"dark": {
"bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236",
"elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#737F9C",
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-soft": "rgba(47,128,255,.15)",
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#7F8BA8",
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-fill": "#1A66E6", "accent-fill2": "#1F6FF2", "accent-soft": "rgba(47,128,255,.15)",
"violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)",
"ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)",
"danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)",
@@ -14,8 +14,8 @@
"light": {
"bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3",
"elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#677290",
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-soft": "rgba(31,111,242,.10)",
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#5C6785",
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-fill": "#1A63DB", "accent-fill2": "#165ED6", "accent-soft": "rgba(31,111,242,.10)",
"violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)",
"ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)",
"danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)",
+4 -1
View File
@@ -158,7 +158,10 @@
"wave_sessions": "Sessions",
"wave_listen": "Played ≥ 50%",
"wave_early_skip": "Early skips",
"wave_likes": "Likes per 100"
"wave_likes": "Likes per 100",
"wave_model": "Model version",
"wave_by_model": "Wave model versions compared",
"analysis_retry": "Retry"
},
"write_template": {
"title": "Uploads and path template",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "At least {{min}} characters",
"submit": "Save and continue",
"logout": "Sign out"
},
"app": {
"link": "Android app",
"title": "pipu for Android",
"subtitle": "Your music from this server on your phone: My Wave, your collection and offline listening.",
"download": "Download APK",
"version": "version {{version}}",
"step_install": "Open the downloaded file. If Android asks, allow installs from this source.",
"step_server": "Launch pipu and enter the server address:",
"copy_server": "Copy the server address",
"step_updates": "Sign in with your account. The app offers new versions by itself.",
"checksum": "SHA-256 checksum",
"unavailable": "The app is not available yet: release downloads are not configured on this server (docs/deployment.md).",
"to_login": "Sign in to the web app"
}
}
+9 -3
View File
@@ -39,7 +39,8 @@
"profile": {
"menu": "Profile menu",
"settings": "Settings",
"logout": "Sign out"
"logout": "Sign out",
"android_app": "Android app"
},
"roles": {
"admin": "Administrator",
@@ -81,7 +82,8 @@
"source_wave": "My Wave",
"source_queue": "Queue",
"queue": "Queue",
"source_wave_seed": "Artist wave · {{label}}"
"source_wave_seed": "Artist wave · {{label}}",
"queue_wave": "The wave never ends: next tracks are picked as you listen"
},
"settings": {
"title": "Settings",
@@ -113,7 +115,11 @@
"player_island": "Island",
"ambient": "Ambient background",
"on": "On",
"off": "Off"
"off": "Off",
"search_position": "Search position",
"search_left": "Left",
"search_center": "Center",
"search_right": "Right"
},
"form": {
"show_password": "Show password",
+4 -1
View File
@@ -158,7 +158,10 @@
"wave_sessions": "Сессий",
"wave_listen": "Дослушано ≥ 50%",
"wave_early_skip": "Ранние скипы",
"wave_likes": "Лайков на 100"
"wave_likes": "Лайков на 100",
"wave_model": "Версия модели",
"wave_by_model": "Сравнение версий модели волны",
"analysis_retry": "Повторить"
},
"write_template": {
"title": "Приём загрузок и шаблон пути",
+14
View File
@@ -20,5 +20,19 @@
"min_length": "Не короче {{min}} символов",
"submit": "Сохранить и продолжить",
"logout": "Выйти"
},
"app": {
"link": "Приложение для Android",
"title": "pipu для Android",
"subtitle": "Ваша музыка с этого сервера на телефоне: «Моя волна», коллекция и офлайн-прослушивание.",
"download": "Скачать APK",
"version": "версия {{version}}",
"step_install": "Откройте скачанный файл. Если Android спросит — разрешите установку из этого источника.",
"step_server": "Запустите pipu и введите адрес сервера:",
"copy_server": "Скопировать адрес сервера",
"step_updates": "Войдите своим логином. Новые версии приложение предложит установить само.",
"checksum": "Контрольная сумма SHA-256",
"unavailable": "Приложение пока недоступно: на сервере не настроена загрузка релизов (docs/deployment.md).",
"to_login": "Войти в веб-версию"
}
}
+9 -3
View File
@@ -39,7 +39,8 @@
"profile": {
"menu": "Меню профиля",
"settings": "Настройки",
"logout": "Выйти"
"logout": "Выйти",
"android_app": "Приложение для Android"
},
"roles": {
"admin": "Администратор",
@@ -81,7 +82,8 @@
"source_wave": "Моя волна",
"source_queue": "Очередь",
"queue": "Очередь",
"source_wave_seed": "Волна по исполнителю · {{label}}"
"source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
},
"settings": {
"title": "Настройки",
@@ -113,7 +115,11 @@
"player_island": "Остров",
"ambient": "Живой фон",
"on": "Вкл.",
"off": "Выкл."
"off": "Выкл.",
"search_position": "Расположение поиска",
"search_left": "Слева",
"search_center": "По центру",
"search_right": "Справа"
},
"form": {
"show_password": "Показать пароль",
@@ -0,0 +1,15 @@
diff --git a/android/build.gradle b/android/build.gradle
index c5431ee7db4fccb4a67fc790ef95db00b5b545dc..bc5feca88bf56c0bf5ac5c4b03f8ab57b693eef5 100644
--- a/android/build.gradle
+++ b/android/build.gradle
@@ -156,7 +156,9 @@ android {
arguments '-DANDROID_STL=c++_shared',
"-DREACT_NATIVE_VERSION=${REACT_NATIVE_VERSION}",
"-DNODE_MODULES_DIR=${nodeModules}",
- "-DPREBUILT_DIR=${prebuiltDir}"
+ "-DPREBUILT_DIR=${prebuiltDir}",
+ // 16 КБ страницы памяти (Android 15+): иначе librnskia.so не загружается на таких устройствах
+ "-DANDROID_SUPPORT_FLEXIBLE_PAGE_SIZES=ON"
}
}
+5 -2
View File
@@ -4,6 +4,9 @@ settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
patchedDependencies:
'@shopify/react-native-skia@2.0.0-next.4': ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7
importers:
.:
@@ -37,7 +40,7 @@ importers:
version: 11.4.1(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))
'@shopify/react-native-skia':
specifier: 2.0.0-next.4
version: 2.0.0-next.4(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
version: 2.0.0-next.4(patch_hash=ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7)(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)
'@tanstack/react-query':
specifier: ^5.104.4
version: 5.104.4(@types/react@19.0.14)(react@19.0.0)
@@ -5396,7 +5399,7 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.64.10':
optional: true
'@shopify/react-native-skia@2.0.0-next.4(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
'@shopify/react-native-skia@2.0.0-next.4(patch_hash=ae341532dba11d4a93999214787e561932c027da2a86640d9f1bcad8ba5910b7)(react-native-reanimated@3.17.5(@babel/core@7.29.7)(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0))(react-native@0.79.6(@babel/core@7.29.7)(@types/react@19.0.14)(react@19.0.0)(supports-color@10.2.2))(react@19.0.0)':
dependencies:
canvaskit-wasm: 0.40.0
react: 19.0.0
+3
View File
@@ -6,6 +6,9 @@ packages:
allowBuilds:
esbuild: true
patchedDependencies:
'@shopify/react-native-skia@2.0.0-next.4': patches/@shopify__react-native-skia@2.0.0-next.4.patch
# Изолированная раскладка node_modules (pnpm 11). Expo вычисляет react-native.config.js пакета expo
# по пути через симлинк apps/mobile/node_modules/expo, и оттуда require('expo-modules-autolinking')
# находится только в корневом node_modules. Без этого autolinking молча генерирует неверный импорт
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env node
// Проверка собранного APK перед публикацией (release.yml):
// - нативные библиотеки только под arm64-v8a: неполный каталог другой ABI роняет приложение при старте
// («couldn't find DSO to load: libexpo-modules-core.so» на x86-эмуляторе);
// - есть библиотеки, без которых приложение не запустится;
// - PT_LOAD-сегменты каждой .so выровнены по 16 КБ: иначе на устройствах с 16 КБ страницами памяти
// (Android 15+) библиотека не загружается и приложение закрывается сразу после запуска.
// Запуск: node scripts/check-apk.mjs app-release.apk
import { readFileSync } from 'node:fs';
import { inflateRawSync } from 'node:zlib';
export const ABI = 'arm64-v8a';
export const REQUIRED = ['libreactnative.so', 'libhermes.so', 'libexpo-modules-core.so', 'librnskia.so', 'libreanimated.so'];
const PAGE = 16384;
/** Файлы ZIP по центральному каталогу: имя → содержимое. */
export function zipEntries(buf) {
let eocd = buf.length - 22;
while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd--;
if (eocd < 0) throw new Error('not a zip file');
const count = buf.readUInt16LE(eocd + 10);
let p = buf.readUInt32LE(eocd + 16);
const out = new Map();
for (let i = 0; i < count; i++) {
const method = buf.readUInt16LE(p + 10);
const size = buf.readUInt32LE(p + 20);
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const local = buf.readUInt32LE(p + 42);
const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
out.set(name, () => {
const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
const raw = buf.subarray(start, start + size);
return method === 0 ? raw : inflateRawSync(raw);
});
p += 46 + nameLen + extraLen + commentLen;
}
return out;
}
/** Наименьшее выравнивание PT_LOAD-сегментов 64-битной ELF-библиотеки. */
export function loadAlignment(elf) {
if (elf.readUInt32BE(0) !== 0x7f454c46 || elf[4] !== 2) throw new Error('not a 64-bit ELF');
const phoff = Number(elf.readBigUInt64LE(0x20));
const phentsize = elf.readUInt16LE(0x36);
const phnum = elf.readUInt16LE(0x38);
let min = Infinity;
for (let i = 0; i < phnum; i++) {
const off = phoff + i * phentsize;
if (elf.readUInt32LE(off) === 1) min = Math.min(min, Number(elf.readBigUInt64LE(off + 0x30)));
}
return min;
}
export function checkApk(buf) {
const problems = [];
const entries = zipEntries(buf);
const libs = [...entries.keys()].filter((n) => n.startsWith('lib/') && n.endsWith('.so'));
const abis = new Set(libs.map((n) => n.split('/')[1]));
for (const abi of abis) if (abi !== ABI) problems.push(`лишняя ABI в APK: lib/${abi}/`);
const mine = new Set(libs.filter((n) => n.startsWith(`lib/${ABI}/`)).map((n) => n.split('/')[2]));
for (const lib of REQUIRED) if (!mine.has(lib)) problems.push(`нет lib/${ABI}/${lib}`);
for (const name of libs.filter((n) => n.startsWith(`lib/${ABI}/`))) {
const align = loadAlignment(entries.get(name)());
if (align < PAGE) problems.push(`${name}: сегменты выровнены по ${align} байт, нужно ${PAGE} (16 КБ страницы)`);
}
return { libs: mine.size, problems };
}
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('check-apk.mjs')) {
const file = process.argv[2];
if (!file) {
console.error('usage: node scripts/check-apk.mjs <apk>');
process.exit(2);
}
const { libs, problems } = checkApk(readFileSync(file));
if (problems.length) {
for (const p of problems) console.error(`✗ ${p}`);
process.exit(1);
}
console.log(`APK: ${libs} библиотек ${ABI}, все выровнены по 16 КБ`);
}
+71
View File
@@ -0,0 +1,71 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { checkApk, loadAlignment, REQUIRED } from './check-apk.mjs';
/** ELF64 с одним PT_LOAD и заданным выравниванием. */
function elf(align) {
const b = Buffer.alloc(64 + 56);
b.writeUInt32BE(0x7f454c46, 0);
b[4] = 2;
b.writeBigUInt64LE(64n, 0x20);
b.writeUInt16LE(56, 0x36);
b.writeUInt16LE(1, 0x38);
b.writeUInt32LE(1, 64);
b.writeBigUInt64LE(BigInt(align), 64 + 0x30);
return b;
}
/** ZIP без сжатия из пар [имя, содержимое]. */
function zip(files) {
const locals = [];
const central = [];
let offset = 0;
for (const [name, data] of files) {
const n = Buffer.from(name);
const lh = Buffer.alloc(30);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt32LE(data.length, 18);
lh.writeUInt32LE(data.length, 22);
lh.writeUInt16LE(n.length, 26);
locals.push(lh, n, data);
const ch = Buffer.alloc(46);
ch.writeUInt32LE(0x02014b50, 0);
ch.writeUInt32LE(data.length, 20);
ch.writeUInt32LE(data.length, 24);
ch.writeUInt16LE(n.length, 28);
ch.writeUInt32LE(offset, 42);
central.push(ch, n);
offset += 30 + n.length + data.length;
}
const cd = Buffer.concat(central);
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50, 0);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(cd.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...locals, cd, end]);
}
test('reads the smallest PT_LOAD alignment', () => {
assert.equal(loadAlignment(elf(16384)), 16384);
assert.equal(loadAlignment(elf(4096)), 4096);
});
test('accepts an arm64-only APK aligned to 16 KB', () => {
const apk = zip(REQUIRED.map((l) => [`lib/arm64-v8a/${l}`, elf(16384)]));
assert.deepEqual(checkApk(apk).problems, []);
});
test('rejects other ABIs, missing libraries and 4 KB alignment', () => {
const libs = REQUIRED.filter((l) => l !== 'libexpo-modules-core.so').map((l) => [
`lib/arm64-v8a/${l}`,
elf(l === 'librnskia.so' ? 4096 : 16384),
]);
const { problems } = checkApk(zip([...libs, ['lib/x86_64/libjsi.so', elf(16384)]]));
assert.equal(problems.length, 3);
assert.match(problems.join('\n'), /lib\/x86_64/);
assert.match(problems.join('\n'), /libexpo-modules-core\.so/);
assert.match(problems.join('\n'), /librnskia\.so.*4096/);
});
+8
View File
@@ -271,6 +271,14 @@ func TestUserJourney(t *testing.T) {
c.ok("GET", "/api/v1/me/likes", nil, &likes)
require.Equal(t, 1, likes.Total)
// Персональные подсказки: лайкнутый трек — первым среди треков альбома.
var sug struct {
Tracks []track `json:"tracks"`
}
c.ok("GET", "/api/v1/search/suggest?q="+url.QueryEscape("кино"), nil, &sug)
require.NotEmpty(t, sug.Tracks)
require.Equal(t, tr.ID, sug.Tracks[0].ID)
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
var onb struct {
Required bool `json:"required"`
+1 -1
View File
@@ -170,7 +170,7 @@ func newApp(ctx context.Context, cfg config.API, pool *pgxpool.Pool, rdb *redis.
Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc,
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
Search: search.NewHandler(meili, libStore, indexer, dto, log),
Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
Playback: pb,
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
+365 -256
View File
@@ -137,6 +137,13 @@ const (
UiPrefsMobilePlayerWave UiPrefsMobilePlayer = "wave"
)
// Defines values for UiPrefsSearchPosition.
const (
Center UiPrefsSearchPosition = "center"
Left UiPrefsSearchPosition = "left"
Right UiPrefsSearchPosition = "right"
)
// Defines values for UiPrefsTheme.
const (
Dark UiPrefsTheme = "dark"
@@ -288,6 +295,9 @@ type AnalysisStatus struct {
Failed int `json:"failed"`
Queued int `json:"queued"`
Running int `json:"running"`
// TopError Самая частая ошибка среди неудавшихся — подсказка, что чинить
TopError *string `json:"top_error"`
}
// AndroidRelease defines model for AndroidRelease.
@@ -907,14 +917,20 @@ type UiPrefs struct {
CrossfadeS *int `json:"crossfade_s,omitempty"`
MobilePlayer *UiPrefsMobilePlayer `json:"mobile_player,omitempty"`
Normalize *bool `json:"normalize,omitempty"`
Theme *UiPrefsTheme `json:"theme,omitempty"`
WaveVariant *UiPrefsWaveVariant `json:"wave_variant,omitempty"`
WebPlayer *UiPrefsWebPlayer `json:"web_player,omitempty"`
// SearchPosition Где в топбаре веба поиск (по умолчанию center)
SearchPosition *UiPrefsSearchPosition `json:"search_position,omitempty"`
Theme *UiPrefsTheme `json:"theme,omitempty"`
WaveVariant *UiPrefsWaveVariant `json:"wave_variant,omitempty"`
WebPlayer *UiPrefsWebPlayer `json:"web_player,omitempty"`
}
// UiPrefsMobilePlayer defines model for UiPrefs.MobilePlayer.
type UiPrefsMobilePlayer string
// UiPrefsSearchPosition Где в топбаре веба поиск (по умолчанию center)
type UiPrefsSearchPosition string
// UiPrefsTheme defines model for UiPrefs.Theme.
type UiPrefsTheme string
@@ -1152,7 +1168,9 @@ type WaveFeedbackOutcome string
// WaveMetrics Метрики волны за 7 дней
type WaveMetrics struct {
EarlySkipRate *float32 `json:"early_skip_rate"`
// ByModel Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
ByModel []WaveModelMetrics `json:"by_model"`
EarlySkipRate *float32 `json:"early_skip_rate"`
// LikeRate Лайков на 100 треков
LikeRate *float32 `json:"like_rate"`
@@ -1161,6 +1179,15 @@ type WaveMetrics struct {
Sessions int `json:"sessions"`
}
// WaveModelMetrics defines model for WaveModelMetrics.
type WaveModelMetrics struct {
EarlySkipRate *float32 `json:"early_skip_rate"`
LikeRate *float32 `json:"like_rate"`
ListenThroughRate *float32 `json:"listen_through_rate"`
ModelVer string `json:"model_ver"`
Served int `json:"served"`
}
// WaveNext defines model for WaveNext.
type WaveNext struct {
Feedback *[]WaveFeedback `json:"feedback,omitempty"`
@@ -1453,6 +1480,9 @@ type ServerInterface interface {
// Журнал действий (admin), новые сверху
// (GET /api/v1/admin/audit)
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)
// (GET /api/v1/admin/library/errors)
ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams)
@@ -1684,6 +1714,12 @@ func (_ Unimplemented) ListAudit(w http.ResponseWriter, r *http.Request, params
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)
// (GET /api/v1/admin/library/errors)
func (_ Unimplemented) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
@@ -2172,6 +2208,26 @@ func (siw *ServerInterfaceWrapper) ListAudit(w http.ResponseWriter, r *http.Requ
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
func (siw *ServerInterfaceWrapper) ListLibraryErrors(w http.ResponseWriter, r *http.Request) {
@@ -3046,12 +3102,6 @@ func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *
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) {
siw.Handler.DownloadAndroidApk(w, r, version)
}))
@@ -3066,12 +3116,6 @@ func (siw *ServerInterfaceWrapper) DownloadAndroidApk(w http.ResponseWriter, r *
// GetAndroidLatest operation middleware
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) {
siw.Handler.GetAndroidLatest(w, r)
}))
@@ -4444,6 +4488,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl
r.Group(func(r chi.Router) {
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.Get(options.BaseURL+"/api/v1/admin/library/errors", wrapper.ListLibraryErrors)
})
@@ -4698,6 +4745,36 @@ func (response ListAuditdefaultApplicationProblemPlusJSONResponse) VisitListAudi
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 {
Params ListLibraryErrorsParams
}
@@ -6821,6 +6898,9 @@ type StrictServerInterface interface {
// Журнал действий (admin), новые сверху
// (GET /api/v1/admin/audit)
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)
// (GET /api/v1/admin/library/errors)
ListLibraryErrors(ctx context.Context, request ListLibraryErrorsRequestObject) (ListLibraryErrorsResponseObject, error)
@@ -7097,6 +7177,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
func (sh *strictHandler) ListLibraryErrors(w http.ResponseWriter, r *http.Request, params ListLibraryErrorsParams) {
var request ListLibraryErrorsRequestObject
@@ -9119,246 +9223,251 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{
"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",
}
// GetSwagger returns the content of the embedded swagger specification file
+3 -1
View File
@@ -100,8 +100,10 @@ func NewHandler(cfg Config, log *slog.Logger) *Handler {
return &Handler{cfg: cfg, client: &http.Client{Timeout: 10 * time.Minute}, log: log}
}
// configured — адрес и репозиторий заданы, и это не заглушка из .env.example.
func (h *Handler) configured() bool {
return h.cfg.GiteaURL != "" && h.cfg.Repo != ""
return h.cfg.GiteaURL != "" && h.cfg.Repo != "" &&
!strings.Contains(h.cfg.GiteaURL, "example.") && !strings.HasPrefix(h.cfg.Repo, "owner/")
}
func (h *Handler) get(ctx context.Context, url string) (*http.Response, error) {
+1
View File
@@ -565,5 +565,6 @@ func TestPublicRoutes(t *testing.T) {
e := newEnv(t)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/api/v1/server-info", nil).Code)
assert.Equal(t, http.StatusOK, e.do(http.MethodGet, "/healthz", nil).Code)
assert.NotEqual(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/app/android/latest", nil).Code, "APK page is public")
assert.Equal(t, http.StatusUnauthorized, e.do(http.MethodGet, "/api/v1/unknown", nil).Code)
}
+9 -7
View File
@@ -241,13 +241,14 @@ func MeDTO(u User) gen.Me {
func uiPrefsDTO(p UIPrefs) gen.UiPrefs {
return gen.UiPrefs{
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
Theme: (*gen.UiPrefsTheme)(p.Theme),
WaveVariant: (*gen.UiPrefsWaveVariant)(p.WaveVariant),
WebPlayer: (*gen.UiPrefsWebPlayer)(p.WebPlayer),
SearchPosition: (*gen.UiPrefsSearchPosition)(p.SearchPos),
MobilePlayer: (*gen.UiPrefsMobilePlayer)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
Normalize: p.Normalize,
}
}
@@ -256,6 +257,7 @@ func uiPrefsFromDTO(p gen.UiPrefs) UIPrefs {
Theme: (*string)(p.Theme),
WaveVariant: (*string)(p.WaveVariant),
WebPlayer: (*string)(p.WebPlayer),
SearchPos: (*string)(p.SearchPosition),
MobilePlayer: (*string)(p.MobilePlayer),
Ambient: p.Ambient,
CrossfadeS: p.CrossfadeS,
+4 -1
View File
@@ -55,6 +55,8 @@ var publicRoutes = map[string]bool{
"GET /api/v1/server-info": true,
"POST /api/v1/auth/login": true,
"POST /api/v1/auth/refresh": true,
// Android-приложение раздаётся по ссылке /android без входа: в APK нет ничего, кроме клиента.
"GET /api/v1/app/android/latest": true,
}
// Что разрешено, пока пользователь обязан сменить учётные данные / пароль.
@@ -90,7 +92,8 @@ func Middleware(svc *Service, log *slog.Logger) func(http.Handler) http.Handler
// Обложки открывает <img> без заголовка Authorization — ссылку защищает подпись.
// Аудио открывает <audio> — тоже без заголовка; ссылка подписана для пользователя.
cover := (r.Method == http.MethodGet || r.Method == http.MethodHead) &&
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/"))
(strings.HasPrefix(r.URL.Path, "/api/v1/covers/") || strings.HasPrefix(r.URL.Path, "/api/v1/stream/") ||
strings.HasPrefix(r.URL.Path, "/api/v1/app/android/download/"))
if !strings.HasPrefix(r.URL.Path, "/api/v1/") || publicRoutes[route] || cover {
next.ServeHTTP(w, r.WithContext(ctx))
return
+1
View File
@@ -37,6 +37,7 @@ type UIPrefs struct {
Theme *string `json:"theme,omitempty"`
WaveVariant *string `json:"wave_variant,omitempty"`
WebPlayer *string `json:"web_player,omitempty"`
SearchPos *string `json:"search_position,omitempty"`
MobilePlayer *string `json:"mobile_player,omitempty"`
Ambient *bool `json:"ambient,omitempty"`
CrossfadeS *int `json:"crossfade_s,omitempty"`
+9 -4
View File
@@ -86,10 +86,11 @@ var commonPasswords = func() map[string]struct{} {
}()
var uiPrefEnums = map[string][]string{
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"mobile_player": {"cover", "wave"},
"theme": {"dark", "light", "system"},
"wave_variant": {"orb", "liquid"},
"web_player": {"bar", "island"},
"search_position": {"center", "left", "right"},
"mobile_player": {"cover", "wave"},
}
// ValidateUIPrefs проверяет значения перечислений и диапазон кроссфейда.
@@ -107,6 +108,7 @@ func ValidateUIPrefs(p UIPrefs) error {
}
for name, v := range map[string]*string{
"theme": p.Theme, "wave_variant": p.WaveVariant, "web_player": p.WebPlayer, "mobile_player": p.MobilePlayer,
"search_position": p.SearchPos,
} {
if err := check(name, v); err != nil {
return err
@@ -130,6 +132,9 @@ func MergeUIPrefs(base, patch UIPrefs) UIPrefs {
if patch.WebPlayer != nil {
base.WebPlayer = patch.WebPlayer
}
if patch.SearchPos != nil {
base.SearchPos = patch.SearchPos
}
if patch.MobilePlayer != nil {
base.MobilePlayer = patch.MobilePlayer
}
@@ -103,7 +103,8 @@ func TestLibraryAndUploads(t *testing.T) {
assert.True(t, src.AcceptsUploads)
require.NotNil(t, src.WriteTemplate)
scanner := library.NewScanner(lib, fakeProber{}, library.NewProgressBoard(), log)
board := library.NewProgressBoard()
scanner := library.NewScanner(lib, fakeProber{}, board, log)
var fileIDs []uuid.UUID
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
id, err := scanner.ScanFile(ctx, src, root, rel)
@@ -114,6 +115,15 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, fileIDs[0], again, "rescan keeps the file row")
// Пустой каталог при известных файлах (диск не смонтирован) — скан прерывается и ничего не помечает пропавшим.
require.NoError(t, scanner.Start(src, t.TempDir(), false))
require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 50*time.Millisecond)
p, _ := board.Get(src.ID)
assert.Contains(t, p.Error, "is the disk mounted")
var missing int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM media_files WHERE source_id = $1 AND missing_since IS NOT NULL`, src.ID).Scan(&missing))
assert.Zero(t, missing)
// --- Отпечатки ---
todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10)
require.NoError(t, err)
@@ -198,6 +198,7 @@ func (fp *Fingerprinter) chromaprint() bool {
func (fp *Fingerprinter) Run(ctx context.Context) {
withFP := fp.chromaprint()
roots := map[uuid.UUID]string{}
unavailable := map[uuid.UUID]bool{} // каталог источника пуст или не открывается — диск не смонтирован
var after uuid.UUID
for ctx.Err() == nil {
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
@@ -213,6 +214,9 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
return
}
after = f.ID
if unavailable[f.SourceID] {
continue
}
root, ok := roots[f.SourceID]
if !ok {
if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
@@ -220,12 +224,31 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
continue
}
roots[f.SourceID] = root
if empty, err := dirEmpty(root); empty || err != nil {
fp.log.Warn("fingerprints: source folder is empty or unreadable, skipped — is the disk mounted?",
"source_id", f.SourceID, "root", root, "err", err)
unavailable[f.SourceID] = true
continue
}
}
fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP)
}
}
}
func dirEmpty(dir string) (bool, error) {
d, err := os.Open(dir)
if err != nil {
return false, err
}
defer func() { _ = d.Close() }()
names, err := d.Readdirnames(1)
if errors.Is(err, io.EOF) {
return true, nil
}
return len(names) == 0, err
}
func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) {
sum := f.SHA256
if sum == nil {
+17 -2
View File
@@ -292,6 +292,15 @@ func (h *Handler) ScanSource(ctx context.Context, req gen.ScanSourceRequestObjec
return gen.ScanSource202JSONResponse(progressDTO(p)), nil
}
// RetryAnalysis реализует gen.StrictServerInterface.
func (h *Handler) RetryAnalysis(ctx context.Context, _ gen.RetryAnalysisRequestObject) (gen.RetryAnalysisResponseObject, error) {
n, err := h.svc.RetryAnalysis(ctx)
if err != nil {
return nil, err
}
return gen.RetryAnalysis200JSONResponse{Requeued: n}, nil
}
// GetLibraryStatus реализует gen.StrictServerInterface.
func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRequestObject) (gen.GetLibraryStatusResponseObject, error) {
st, err := h.svc.Status(ctx)
@@ -304,13 +313,19 @@ func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRe
MissingFiles: int(c.Missing), FileErrors: int(c.Errors), Sources: []gen.SourceStatusItem{},
Analysis: gen.AnalysisStatus{
Queued: st.Analysis.Queued, Running: st.Analysis.Running, Done: st.Analysis.Done,
Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer,
Failed: st.Analysis.Failed, AnalyzerVer: st.Analysis.AnalyzerVer, TopError: st.Analysis.TopError,
},
Wave: gen.WaveMetrics{
Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough,
EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate,
EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate, ByModel: []gen.WaveModelMetrics{},
},
}
for _, m := range st.Wave.ByModel {
out.Wave.ByModel = append(out.Wave.ByModel, gen.WaveModelMetrics{
ModelVer: m.ModelVer, Served: m.Served, ListenThroughRate: m.ListenThrough,
EarlySkipRate: m.EarlySkip, LikeRate: m.LikeRate,
})
}
for _, s := range st.Sources {
dto := h.sourceDTO(s)
out.Sources = append(out.Sources, gen.SourceStatusItem{
+17
View File
@@ -119,6 +119,15 @@ type walked struct {
hash []byte
}
func hasPresent(files map[string]FileRow) bool {
for _, f := range files {
if !f.Missing {
return true
}
}
return false
}
// job — файл, который нужно прочитать.
type job struct {
walked
@@ -135,6 +144,11 @@ type probed struct {
// ErrScanRunning — скан источника уже идёт.
var ErrScanRunning = errors.New("library: scan already running")
// ErrSourceEmpty — в каталоге источника нет ни одного файла, а в библиотеке они есть: скорее всего, диск
// не смонтирован. Скан прерывается, чтобы не пометить всю библиотеку отсутствующей (и через MissingRetention —
// не удалить треки вместе с историей).
var ErrSourceEmpty = errors.New("library: source folder is empty but the library has its files — is the disk mounted?")
// Start запускает скан в фоне. root — каталог источника в файловой системе api.
func (sc *Scanner) Start(src Source, root string, full bool) error {
if !sc.board.start(src.ID, full, sc.now()) {
@@ -192,6 +206,9 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
if err != nil {
return err
}
if len(found) == 0 && hasPresent(existing) {
return ErrSourceEmpty
}
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
var (
+9
View File
@@ -587,6 +587,15 @@ func (s *Service) previewOne(ctx context.Context, root, rel string, patterns []m
}
// StartScan запускает скан источника в фоне.
// RetryAnalysis возвращает в очередь треки, которые анализатор не смог обработать (например, пока диск
// не был смонтирован).
func (s *Service) RetryAnalysis(ctx context.Context) (int, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return 0, err
}
return s.store.RetryFailedAnalysis(ctx)
}
func (s *Service) StartScan(ctx context.Context, id uuid.UUID, full bool) (Progress, error) {
if err := requirePerm(ctx, auth.PermManageLibrary); err != nil {
return Progress{}, err
+63 -12
View File
@@ -605,7 +605,7 @@ func (st *Store) AlbumCoverPath(ctx context.Context, albumID uuid.UUID) (*string
// AnalysisCounts — состояние очереди аудиоанализа.
type AnalysisCounts struct {
Queued, Running, Done, Failed int
AnalyzerVer *string
AnalyzerVer, TopError *string
}
// AnalysisCounts считает задачи анализа по статусам и текущую версию анализатора.
@@ -614,20 +614,59 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
err := st.pool.QueryRow(ctx, `
SELECT count(*) FILTER (WHERE status = 'queued'), count(*) FILTER (WHERE status = 'running'),
count(*) FILTER (WHERE status = 'done'), count(*) FILTER (WHERE status = 'failed'),
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer)
(SELECT analyzer_ver FROM track_features ORDER BY analyzed_at DESC LIMIT 1),
(SELECT error FROM analysis_jobs WHERE status = 'failed' AND error IS NOT NULL
GROUP BY error ORDER BY count(*) DESC LIMIT 1)
FROM analysis_jobs`).Scan(&c.Queued, &c.Running, &c.Done, &c.Failed, &c.AnalyzerVer, &c.TopError)
if err != nil {
return AnalysisCounts{}, fmt.Errorf("library: analysis counts: %w", err)
}
return c, nil
}
// RetryFailedAnalysis возвращает неудавшиеся задачи анализа в очередь с нуля попыток.
func (st *Store) RetryFailedAnalysis(ctx context.Context) (int, error) {
tag, err := st.pool.Exec(ctx, `
UPDATE analysis_jobs SET status = 'queued', attempts = 0, error = NULL, updated_at = now()
WHERE status = 'failed'`)
if err != nil {
return 0, fmt.Errorf("library: retry analysis: %w", err)
}
return int(tag.RowsAffected()), nil
}
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
type WaveMetrics struct {
Served, Sessions int
Served, Sessions int
WaveRates
ByModel []WaveModelMetrics
}
// WaveRates — доли по показам с известным исходом (nil — исходов ещё нет).
type WaveRates struct {
ListenThrough, EarlySkip, LikeRate *float32
}
// WaveModelMetrics — метрики показов одной версии модели.
type WaveModelMetrics struct {
ModelVer string
Served int
WaveRates
}
func rates(judged, through, early, liked int) WaveRates {
if judged == 0 {
return WaveRates{}
}
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
return WaveRates{ListenThrough: &lt, EarlySkip: &es, LikeRate: &lr}
}
const waveOutcomes = `count(outcome),
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
count(*) FILTER (WHERE outcome = 'like')`
// WaveMetrics считает метрики по показам с известным исходом.
func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
var (
@@ -635,20 +674,32 @@ func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
judged, through, early, liked int
)
err := st.pool.QueryRow(ctx, `
SELECT count(*), count(DISTINCT wave_session_id), count(outcome),
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
count(*) FILTER (WHERE outcome = 'like')
SELECT count(*), count(DISTINCT wave_session_id), `+waveOutcomes+`
FROM wave_impressions WHERE served_at > now() - interval '7 days'`).
Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked)
if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err)
}
if judged > 0 {
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
m.ListenThrough, m.EarlySkip, m.LikeRate = &lt, &es, &lr
m.WaveRates = rates(judged, through, early, liked)
rows, err := st.pool.Query(ctx, `
SELECT coalesce(model_ver, ''), count(*), `+waveOutcomes+`
FROM wave_impressions WHERE served_at > now() - interval '7 days'
GROUP BY 1 ORDER BY 2 DESC`)
if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
}
return m, nil
defer rows.Close()
m.ByModel = []WaveModelMetrics{}
for rows.Next() {
var mm WaveModelMetrics
if err := rows.Scan(&mm.ModelVer, &mm.Served, &judged, &through, &early, &liked); err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
}
mm.WaveRates = rates(judged, through, early, liked)
m.ByModel = append(m.ByModel, mm)
}
return m, rows.Err()
}
// FingerprintTodo — файл, которому нужны отпечатки.
+83
View File
@@ -0,0 +1,83 @@
package search
import (
"context"
"math"
"sort"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок.
const suggestPool = 3
// Affinity — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания,
// лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска.
type Affinity struct{ pool *pgxpool.Pool }
// NewAffinity создаёт источник предпочтений.
func NewAffinity(pool *pgxpool.Pool) *Affinity { return &Affinity{pool: pool} }
// Of возвращает вес для каждого из ids, о котором что-то известно.
func (a *Affinity) Of(ctx context.Context, user uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]float64, error) {
rows, err := a.pool.Query(ctx, `
WITH s AS (
SELECT track_id, sum(w) AS w FROM (
SELECT track_id, (play_count + 2 * complete_count - early_skip_count)::float8 AS w
FROM user_track_stats WHERE user_id = $1
UNION ALL
SELECT track_id, CASE reaction WHEN 'like' THEN 10 ELSE -10 END FROM track_reactions WHERE user_id = $1
) x GROUP BY track_id
)
SELECT track_id, w FROM s WHERE track_id = ANY($2)
UNION ALL
SELECT t.album_id, sum(s.w) FROM s JOIN tracks t ON t.id = s.track_id WHERE t.album_id = ANY($2) GROUP BY t.album_id
UNION ALL
SELECT ta.artist_id, sum(s.w) FROM s JOIN track_artists ta ON ta.track_id = s.track_id
WHERE ta.artist_id = ANY($2) GROUP BY ta.artist_id
UNION ALL
SELECT artist_id, CASE reaction WHEN 'like' THEN 20 ELSE -20 END FROM artist_reactions
WHERE user_id = $1 AND artist_id = ANY($2)`, user, ids)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[uuid.UUID]float64{}
for rows.Next() {
var id uuid.UUID
var w float64
if err := rows.Scan(&id, &w); err != nil {
return nil, err
}
out[id] += w
}
return out, rows.Err()
}
// personalize переставляет найденное: знакомое и любимое поднимается на несколько позиций, нелюбимое
// опускается; релевантность Meilisearch остаётся основой порядка. Возвращает не больше n.
func personalize(found []string, aff map[uuid.UUID]float64, n int) []string {
type item struct {
id string
key float64
}
items := make([]item, len(found))
for i, s := range found {
key := float64(i)
if id, err := uuid.Parse(s); err == nil {
if w := aff[id]; w > 0 {
key -= 2 * math.Log1p(w)
} else if w < 0 {
key += 3
}
}
items[i] = item{s, key}
}
sort.SliceStable(items, func(i, j int) bool { return items[i].key < items[j].key })
out := make([]string, 0, min(n, len(items)))
for _, it := range items[:min(n, len(items))] {
out = append(out, it.id)
}
return out
}
+29 -3
View File
@@ -28,12 +28,15 @@ type Handler struct {
meili *Meili
catalog *library.Store
indexer *Indexer
aff *Affinity
log *slog.Logger
}
// NewHandler создаёт обработчик поиска.
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, dto library.DTO, log *slog.Logger) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, dto: dto, log: log}
func NewHandler(
meili *Meili, catalog *library.Store, indexer *Indexer, aff *Affinity, dto library.DTO, log *slog.Logger,
) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, aff: aff, dto: dto, log: log}
}
type limits struct{ artists, albums, tracks, offset int }
@@ -150,10 +153,16 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
// SearchSuggest реализует gen.StrictServerInterface.
func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) {
f, corrected, err := h.run(ctx, req.Params.Q, limits{artists: 3, albums: 3, tracks: 5})
want := limits{artists: 3, albums: 3, tracks: 5}
l := want
if h.aff != nil {
l = limits{artists: want.artists * suggestPool, albums: want.albums * suggestPool, tracks: want.tracks * suggestPool}
}
f, corrected, err := h.run(ctx, req.Params.Q, l)
if err != nil {
return nil, err
}
f = h.personal(ctx, f, want)
out, err := h.hydrate(ctx, f, corrected)
if err != nil {
return nil, err
@@ -161,6 +170,23 @@ func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestReques
return gen.SearchSuggest200JSONResponse(out), nil
}
// personal оставляет в подсказках по want.* лучших с учётом вкуса пользователя.
// Без пользователя или при сбое — просто первые по релевантности.
func (h *Handler) personal(ctx context.Context, f found, want limits) found {
var aff map[uuid.UUID]float64
if p, ok := auth.PrincipalFrom(ctx); ok && h.aff != nil {
all := ids(append(append(append([]string{}, f.artists...), f.albums...), f.tracks...))
var err error
if aff, err = h.aff.Of(ctx, p.UserID, all); err != nil {
h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
}
}
f.artists = personalize(f.artists, aff, want.artists)
f.albums = personalize(f.albums, aff, want.albums)
f.tracks = personalize(f.tracks, aff, want.tracks)
return f
}
// Search реализует gen.StrictServerInterface.
func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) {
typ := gen.All
@@ -3,6 +3,8 @@ package search
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
)
@@ -20,3 +22,14 @@ func TestSwitchLayout(t *testing.T) {
assert.Equal(t, "кино", SwitchLayout("rbyj"))
assert.Equal(t, "", SwitchLayout("123"))
}
func TestPersonalizeLiftsFamiliarAndSinksDisliked(t *testing.T) {
liked, disliked := uuid.New(), uuid.New()
other := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
found := []string{other[0], disliked.String(), other[1], other[2], liked.String()}
aff := map[uuid.UUID]float64{liked: 30, disliked: -10}
got := personalize(found, aff, 3)
assert.Equal(t, []string{liked.String(), other[0], other[1]}, got)
assert.Equal(t, found[:2], personalize(found, nil, 2), "без вкуса — порядок релевантности")
}
@@ -20,6 +20,7 @@ log = logging.getLogger(__name__)
MAX_ATTEMPTS = 3
MAX_DURATION_S = 15 * 60
IDLE_SLEEP_S = 10.0
MISSING_SLEEP_S = 60.0 # файлы пропали целиком — ждём, пока смонтируют диск
@dataclass(frozen=True)
@@ -168,6 +169,18 @@ class Worker:
)
conn.commit()
def postpone(self, conn: psycopg.Connection[Any], track_ids: list[str]) -> None:
"""Файла нет на диске (не смонтирован?) — обратно в очередь, попытка не засчитывается."""
conn.execute(
"""
UPDATE analysis_jobs SET status = 'queued', attempts = greatest(attempts - 1, 0),
error = 'file not found — is the disk mounted?', updated_at = now()
WHERE track_id = ANY(%s::uuid[])
""",
(track_ids,),
)
conn.commit()
def requeue_stale(self, conn: psycopg.Connection[Any]) -> None:
"""Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь."""
conn.execute(
@@ -189,6 +202,16 @@ class Worker:
self.requeue_stale(conn)
while True:
jobs = self.claim(conn, self.workers * 2)
missing = [j for j in jobs if not Path(j.path).exists()]
if missing:
self.postpone(conn, [j.track_id for j in missing])
jobs = [j for j in jobs if j not in missing]
if not jobs:
log.warning(
"analysis: %d files not found, waiting — is the disk mounted?", len(missing)
)
time.sleep(MISSING_SLEEP_S)
continue
if not jobs:
time.sleep(IDLE_SLEEP_S)
continue
+115
View File
@@ -0,0 +1,115 @@
// Нагрузочный smoke перед релизом (docs/testing.md#нагрузочный-smoke): 100 одновременных слушателей волны
// и 20 запросов подсказок поиска в секунду в течение 3 минут; пороги — цели из docs/api.md и
// docs/recommendations.md. Запуск (k6 ≥ 0.50):
// k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
// Пользователь — обычный (role=user), заведённый для теста: его история и лайки засоряются.
import { check, sleep } from 'k6';
import http from 'k6/http';
const BASE = (__ENV.BASE_URL || 'http://localhost:8080').replace(/\/$/, '');
const DURATION = __ENV.DURATION || '3m';
const LISTENERS = Number(__ENV.LISTENERS || 100);
const SUGGEST_RPS = Number(__ENV.SUGGEST_RPS || 20);
export const options = {
scenarios: {
listeners: { executor: 'constant-vus', vus: LISTENERS, duration: DURATION, exec: 'listen' },
suggest: {
executor: 'constant-arrival-rate',
rate: SUGGEST_RPS,
timeUnit: '1s',
duration: DURATION,
preAllocatedVUs: 10,
maxVUs: 40,
exec: 'suggest',
},
},
thresholds: {
'http_req_duration{name:suggest}': ['p(95)<50'],
'http_req_duration{name:wave_next}': ['p(95)<150'],
'http_req_duration{name:wave_start}': ['p(95)<300'],
'http_req_duration{name:stream}': ['p(95)<300'], // первые 64 КБ — время до начала звука
'http_req_duration{name:events}': ['p(95)<100'],
http_req_failed: ['rate<0.01'],
checks: ['rate>0.99'],
},
};
const json = (token) => ({
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
});
export function setup() {
const res = http.post(
`${BASE}/api/v1/auth/login`,
JSON.stringify({
username: __ENV.PIPU_USER,
password: __ENV.PIPU_PASSWORD,
client_type: 'android',
device_name: 'k6 load smoke',
}),
{ headers: { 'Content-Type': 'application/json' } },
);
if (res.status !== 200) throw new Error(`login: ${res.status} ${res.body}`);
return { token: res.json('access_token') };
}
function uuid() {
// v4 — для id событий (дубли отбрасываются сервером, поэтому уникальность важна)
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
});
}
// Слушатель: волна → по каждому треку начало потока и события → следующая пачка с отзывами.
export function listen(data) {
const p = json(data.token);
const start = http.post(`${BASE}/api/v1/wave/sessions`, JSON.stringify({}), { ...p, tags: { name: 'wave_start' } });
if (!check(start, { 'wave started': (r) => r.status === 200 || r.status === 201 })) {
sleep(5);
return;
}
const session = start.json('session_id');
let tracks = start.json('tracks') || [];
for (let batch = 0; batch < 3 && tracks.length > 0; batch++) {
const feedback = [];
for (const t of tracks) {
const s = http.get(`${BASE}${t.stream_url}`, { headers: { Range: 'bytes=0-65535' }, tags: { name: 'stream' } });
check(s, { 'stream starts': (r) => r.status === 206 || r.status === 200 });
const now = new Date();
const outcome = Math.random() < 0.3 ? 'skip_early' : 'complete';
const played = outcome === 'complete' ? 180000 : 8000;
const ev = http.post(
`${BASE}/api/v1/events`,
JSON.stringify({
events: [
{ id: uuid(), type: 'play_start', track_id: t.id, occurred_at: new Date(now - played).toISOString(), position_ms: 0, context_type: 'wave', context_id: session },
{ id: uuid(), type: 'play_end', track_id: t.id, occurred_at: now.toISOString(), position_ms: played, played_ms: played, context_type: 'wave', context_id: session },
],
}),
{ ...p, tags: { name: 'events' } },
);
check(ev, { 'events accepted': (r) => r.status === 200 });
feedback.push({ track_id: t.id, outcome });
sleep(1 + Math.random() * 2); // «слушает» — запросы реального клиента редкие
}
const next = http.post(`${BASE}/api/v1/wave/sessions/${session}/next`, JSON.stringify({ feedback }), {
...p,
tags: { name: 'wave_next' },
});
if (!check(next, { 'wave next': (r) => r.status === 200 })) break;
tracks = next.json('tracks') || [];
}
}
const QUERIES = ['ки', 'кино', 'mo', 'mozart', 'the', 'love', 'ghbdtn', 'a', 'нау', 'rock', 'би', 'queen', 'ле', 'jazz'];
export function suggest(data) {
const q = QUERIES[Math.floor(Math.random() * QUERIES.length)];
const r = http.get(`${BASE}/api/v1/search/suggest?q=${encodeURIComponent(q)}`, {
headers: { Authorization: `Bearer ${data.token}` },
tags: { name: 'suggest' },
});
check(r, { 'suggest ok': (x) => x.status === 200 });
}