Compare commits

...
11 Commits
Author SHA1 Message Date
mrleo1nidandClaude Opus 5.5 2d49cb3f19 fix(web): header dropdowns sit above the page again
ci / ts / ts (packages + web) (push) Successful in 50s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 3m45s
Every shell child got the same z-index, so the page painted over the search
suggestions and the profile menu and swallowed their clicks: suggestions could
not be picked, and the language switch and settings item did nothing.

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

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

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

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

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

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

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

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

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

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

No files matched your search

+26
View File
@@ -4,16 +4,19 @@
name: ci / db name: ci / db
on: on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push: push:
branches: [main] branches: [main]
paths: paths:
- 'services/api/**' - 'services/api/**'
- 'services/recommender/**' - 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml' - '.gitea/workflows/ci-db.yml'
pull_request: pull_request:
paths: paths:
- 'services/api/**' - 'services/api/**'
- 'services/recommender/**' - 'services/recommender/**'
- 'deploy/backup.sh'
- '.gitea/workflows/ci-db.yml' - '.gitea/workflows/ci-db.yml'
concurrency: concurrency:
@@ -64,3 +67,26 @@ jobs:
run: | run: |
uv sync --frozen uv sync --frozen
uv run pytest -q tests/test_db_integration.py uv run pytest -q tests/test_db_integration.py
# База уже наполнена сценариями выше — проверяем настоящий цикл копия → проверка → восстановление.
- name: Бэкап, проверка и восстановление (deploy/backup.sh)
env:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: pipu
PGDATABASE: pipu
BACKUP_DIR: /tmp/backups
VERIFY_EXACT: "1"
run: |
SUDO=$(command -v sudo || true)
$SUDO install -d /usr/share/postgresql-common/pgdg
$SUDO curl -fsSLo /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc https://www.postgresql.org/media/keys/ACCC4CF8.asc
echo "deb [signed-by=/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc] https://apt.postgresql.org/pub/repos/apt $(. /etc/os-release && echo "$VERSION_CODENAME")-pgdg main" | $SUDO tee /etc/apt/sources.list.d/pgdg.list >/dev/null
$SUDO apt-get update -qq && $SUDO apt-get install -y -qq postgresql-client-17 >/dev/null
export PATH=/usr/lib/postgresql/17/bin:$PATH
before=$(psql -Atc "SELECT count(*) FROM tracks")
file=$(bash deploy/backup.sh once)
bash deploy/backup.sh verify "$file"
bash deploy/backup.sh restore "$file"
after=$(psql -Atc "SELECT count(*) FROM tracks")
echo "tracks: $before → $after"
test "$before" = "$after"
+1
View File
@@ -2,6 +2,7 @@
name: ci / mobile name: ci / mobile
on: on:
workflow_dispatch: # перезапуск вручную (например, после чистки раннера)
push: push:
branches: [main] branches: [main]
paths: paths:
+23 -1
View File
@@ -3096,7 +3096,7 @@ components:
nullable: true nullable: true
WaveMetrics: WaveMetrics:
type: object type: object
required: [served, listen_through_rate, early_skip_rate, like_rate, sessions] required: [served, listen_through_rate, early_skip_rate, like_rate, sessions, by_model]
description: Метрики волны за 7 дней description: Метрики волны за 7 дней
properties: properties:
served: served:
@@ -3113,6 +3113,28 @@ components:
description: Лайков на 100 треков description: Лайков на 100 треков
sessions: sessions:
type: integer type: integer
by_model:
type: array
description: Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
items:
$ref: '#/components/schemas/WaveModelMetrics'
WaveModelMetrics:
type: object
required: [model_ver, served, listen_through_rate, early_skip_rate, like_rate]
properties:
model_ver:
type: string
served:
type: integer
listen_through_rate:
type: number
nullable: true
early_skip_rate:
type: number
nullable: true
like_rate:
type: number
nullable: true
AndroidRelease: AndroidRelease:
type: object type: object
required: [version, version_code, notes_md, size, sha256, download_url, min_supported] required: [version, version_code, notes_md, size, sha256, download_url, min_supported]
+1 -1
View File
@@ -117,7 +117,7 @@ function Chips({
height: 38, height: 38,
paddingHorizontal: 14, paddingHorizontal: 14,
borderRadius: 999, borderRadius: 999,
backgroundColor: on ? colors.accent : colors.surface2, backgroundColor: on ? colors['accent-fill'] : colors.surface2,
borderWidth: 1, borderWidth: 1,
borderColor: on ? colors.accent : colors.line, borderColor: on ? colors.accent : colors.line,
}} }}
+2 -2
View File
@@ -107,7 +107,7 @@ export function Button({
}) { }) {
const s = useStyles(); const s = useStyles();
const colors = useColors(); const colors = useColors();
const bg = { primary: colors.accent, secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant]; const bg = { primary: colors['accent-fill'], secondary: colors.surface3, ghost: 'transparent', danger: colors['danger-soft'] }[variant];
const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant]; const fg = { primary: '#fff', secondary: colors.text, ghost: colors.text2, danger: colors.danger }[variant];
return ( return (
<Pressable <Pressable
@@ -282,7 +282,7 @@ export function Segmented<T extends string>({
onPress={() => onChange(o.value)} onPress={() => onChange(o.value)}
accessibilityRole="tab" accessibilityRole="tab"
accessibilityState={{ selected: value === o.value }} accessibilityState={{ selected: value === o.value }}
style={[s.segment, value === o.value && { backgroundColor: colors.accent }]} style={[s.segment, value === o.value && { backgroundColor: colors['accent-fill'] }]}
> >
<Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text> <Text style={{ color: value === o.value ? '#fff' : colors.text2, fontWeight: '600', fontSize: 13 }}>{o.label}</Text>
</Pressable> </Pressable>
+1 -1
View File
@@ -250,7 +250,7 @@ describe('player', () => {
const alice = makeUser('alice', 'user'); const alice = makeUser('alice', 'user');
const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice }); const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice });
const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!; const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="listitem"]')!;
await user.click(row); await user.click(row);
const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); const bar = screen.getByRole('contentinfo', { name: 'Плеер' });
await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument()); await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument());
+2 -2
View File
@@ -132,7 +132,7 @@ export function TrackList({
const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1)); const discs = new Set(tracks.map((tr) => tr.disc_no ?? 1));
let lastDisc: number | null = null; let lastDisc: number | null = null;
return ( return (
<div className={s.tracks} role="table" aria-label={t('search.tracks')}> <div className={s.tracks} role="list" aria-label={t('search.tracks')}>
{tracks.map((tr, i) => { {tracks.map((tr, i) => {
const disc = tr.disc_no ?? 1; const disc = tr.disc_no ?? 1;
const header = numbered && discs.size > 1 && disc !== lastDisc; const header = numbered && discs.size > 1 && disc !== lastDisc;
@@ -145,7 +145,7 @@ export function TrackList({
<Fragment key={`${tr.id}-${i}`}> <Fragment key={`${tr.id}-${i}`}>
{header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>} {header && <div className={s.disc}>{t('catalog.disc', { n: disc })}</div>}
<div <div
role="row" role="listitem"
tabIndex={tr.available ? 0 : -1} tabIndex={tr.available ? 0 : -1}
aria-current={isCurrent || undefined} aria-current={isCurrent || undefined}
className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)} className={cx(s.track, !tr.available && s.unavailable, isCurrent && s.current)}
+33 -2
View File
@@ -1,5 +1,6 @@
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession, useUser } from '../../app/session'; import { useSession, useUser } from '../../app/session';
@@ -99,6 +100,33 @@ export function LibraryPage() {
</div> </div>
))} ))}
</div> </div>
{d.wave.by_model.length > 1 && (
<div className={s.tableWrap}>
<table className={s.table}>
<caption className={s.muted}>{t('library.wave_by_model')}</caption>
<thead>
<tr>
<th scope="col">{t('library.wave_model')}</th>
<th scope="col">{t('library.wave_served')}</th>
<th scope="col">{t('library.wave_listen')}</th>
<th scope="col">{t('library.wave_early_skip')}</th>
<th scope="col">{t('library.wave_likes')}</th>
</tr>
</thead>
<tbody>
{d.wave.by_model.map((m) => (
<tr key={m.model_ver}>
<td>{m.model_ver || '—'}</td>
<td>{m.served.toLocaleString()}</td>
<td>{pct(m.listen_through_rate)}</td>
<td>{pct(m.early_skip_rate)}</td>
<td>{m.like_rate === null ? '—' : m.like_rate.toFixed(1)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section> </section>
)} )}
{d && d.sources.length > 0 && ( {d && d.sources.length > 0 && (
@@ -133,10 +161,13 @@ const pct = (v: number | null) => (v === null ? '—' : `${Math.round(v * 100)}%
function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) { function ScanLineBar({ done, total, label }: { done: number; total: number; label: string }) {
const value = total > 0 ? Math.round((done / total) * 100) : 0; const value = total > 0 ? Math.round((done / total) * 100) : 0;
const id = useId();
return ( return (
<div className={l.scan}> <div className={l.scan}>
<div className={l.scanText}>{label}</div> <div className={l.scanText} id={id}>
<div className={l.bar} role="progressbar" aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}> {label}
</div>
<div className={l.bar} role="progressbar" aria-labelledby={id} aria-valuenow={value} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${value}%` }} /> <span style={{ width: `${value}%` }} />
</div> </div>
</div> </div>
+4 -2
View File
@@ -1,4 +1,5 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { formatDateTime } from '../../shared/lib/format'; import { formatDateTime } from '../../shared/lib/format';
@@ -16,14 +17,15 @@ export function StatusBadge({ status }: { status: SourceStatus }) {
/** Прогресс идущего скана или итог последнего. */ /** Прогресс идущего скана или итог последнего. */
export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) { export function ScanLine({ scan, lastScanAt }: { scan: ScanProgress | null; lastScanAt: string | null }) {
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
const id = useId();
if (scan?.running) { if (scan?.running) {
const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0; const pct = scan.to_process > 0 ? Math.round((scan.processed / scan.to_process) * 100) : 0;
return ( return (
<div className={s.scan}> <div className={s.scan}>
<div className={s.scanText}> <div className={s.scanText} id={id}>
{scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })} {scan.found === 0 ? t('sources.scan_walking') : t('sources.scanning', { processed: scan.processed, total: scan.to_process })}
</div> </div>
<div className={s.bar} role="progressbar" aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}> <div className={s.bar} role="progressbar" aria-labelledby={id} aria-valuenow={pct} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${pct}%` }} /> <span style={{ width: `${pct}%` }} />
</div> </div>
</div> </div>
+1 -1
View File
@@ -86,7 +86,7 @@
.chipOn, .chipOn,
.chipOn:hover { .chipOn:hover {
background: var(--accent); background: var(--accent-fill);
border-color: var(--accent); border-color: var(--accent);
color: #fff; color: #fff;
box-shadow: var(--glow); box-shadow: var(--glow);
+1 -1
View File
@@ -104,7 +104,7 @@
} }
.genre[aria-pressed='true'] { .genre[aria-pressed='true'] {
background: var(--accent); background: var(--accent-fill);
border-color: var(--accent); border-color: var(--accent);
color: #fff; color: #fff;
box-shadow: var(--glow); box-shadow: var(--glow);
+4 -2
View File
@@ -1,6 +1,7 @@
import type { components } from '@pipu/api-client'; import type { components } from '@pipu/api-client';
import { unwrap } from '@pipu/core'; import { unwrap } from '@pipu/core';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useId } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useSession } from '../../app/session'; import { useSession } from '../../app/session';
@@ -47,18 +48,19 @@ export function useUploadQuota() {
export function QuotaMeter() { export function QuotaMeter() {
const { t, i18n } = useTranslation('admin'); const { t, i18n } = useTranslation('admin');
const quota = useUploadQuota(); const quota = useUploadQuota();
const id = useId();
if (!quota.data) return null; if (!quota.data) return null;
const q = quota.data; const q = quota.data;
const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1; const ratio = q.limit_bytes > 0 ? Math.min(q.used_bytes / q.limit_bytes, 1) : 1;
const fmt = (n: number) => formatBytes(n, i18n.language); const fmt = (n: number) => formatBytes(n, i18n.language);
return ( return (
<div className={u.quota}> <div className={u.quota}>
<span className={u.quotaText}> <span className={u.quotaText} id={id}>
{t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })} {t('uploads.quota', { used: fmt(q.used_bytes), limit: fmt(q.limit_bytes) })}
{q.per_user_limit_bytes !== null && {q.per_user_limit_bytes !== null &&
` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`} ` · ${t('uploads.quota_mine', { used: fmt(q.user_used_bytes), limit: fmt(q.per_user_limit_bytes) })}`}
</span> </span>
<div className={u.progress} role="progressbar" aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}> <div className={u.progress} role="progressbar" aria-labelledby={id} aria-valuenow={Math.round(ratio * 100)} aria-valuemin={0} aria-valuemax={100}>
<span style={{ width: `${ratio * 100}%` }} /> <span style={{ width: `${ratio * 100}%` }} />
</div> </div>
</div> </div>
+3 -3
View File
@@ -53,13 +53,13 @@
} }
.primary { .primary {
background: var(--accent); background: var(--accent-fill);
color: #fff; color: #fff;
box-shadow: var(--glow); box-shadow: var(--glow);
} }
.primary:hover:not(:disabled) { .primary:hover:not(:disabled) {
background: var(--accent2); background: var(--accent-fill2);
} }
.secondary { .secondary {
@@ -186,7 +186,7 @@
} }
.segmented button[aria-pressed='true'] { .segmented button[aria-pressed='true'] {
background: var(--accent); background: var(--accent-fill);
color: #fff; color: #fff;
} }
+8
View File
@@ -53,6 +53,14 @@ button {
} }
} }
/* Выезжающие справа панели (очередь, docs/design.md#4). */
@keyframes pipuSlideIn {
from {
opacity: 0;
transform: translateX(24px);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+3 -1
View File
@@ -27,8 +27,10 @@
padding-bottom: 132px; padding-bottom: 132px;
} }
/* Над контентом: выпадающие подсказки поиска и меню профиля перекрывают страницу, а не уходят под неё. */
.top { .top {
grid-area: top; grid-area: top;
z-index: 30;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 24px; gap: 24px;
@@ -155,7 +157,7 @@
height: 20px; height: 20px;
padding: 0 6px; padding: 0 6px;
border-radius: var(--radius-pill); border-radius: var(--radius-pill);
background: var(--accent); background: var(--accent-fill);
color: #fff; color: #fff;
font-size: 11px; font-size: 11px;
font-weight: 700; font-weight: 700;
+9 -1
View File
@@ -246,7 +246,15 @@ a.source:hover {
background: var(--glass); background: var(--glass);
backdrop-filter: blur(28px) saturate(1.3); backdrop-filter: blur(28px) saturate(1.3);
box-shadow: var(--shadow); box-shadow: var(--shadow);
animation: pipuIn 0.2s ease-out; animation: pipuSlideIn 0.22s cubic-bezier(0.2, 0.8, 0.2, 1);
}
.drawerNote {
margin: 0;
padding: 12px 18px 16px;
border-top: 1px solid var(--line);
color: var(--text3);
font-size: 13px;
} }
.drawerIsland { .drawerIsland {
+25 -5
View File
@@ -1,4 +1,4 @@
import { useState, type CSSProperties } from 'react'; import { useCallback, useEffect, useId, useRef, useState, type CSSProperties } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -35,6 +35,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
const player = usePlayer(); const player = usePlayer();
const st = usePlayerState(); const st = usePlayerState();
const [queueOpen, setQueueOpen] = useState(false); const [queueOpen, setQueueOpen] = useState(false);
const queueButton = useRef<HTMLButtonElement>(null);
const queueId = useId();
const closeQueue = useCallback(() => {
setQueueOpen(false);
queueButton.current?.focus(); // фокус — туда, откуда открыли
}, []);
const track = st.track; const track = st.track;
if (!track) { if (!track) {
@@ -167,10 +173,12 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
<Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} /> <Icon src={st.repeat === 'one' ? repeatOneIcon : repeatIcon} size={20} />
</button> </button>
<button <button
ref={queueButton}
type="button" type="button"
className={cx(s.icon, queueOpen && s.accent)} className={cx(s.icon, queueOpen && s.accent)}
aria-label={t('player.queue')} aria-label={t('player.queue')}
aria-expanded={queueOpen} aria-expanded={queueOpen}
aria-controls={queueOpen ? queueId : undefined}
onClick={() => setQueueOpen((v) => !v)} onClick={() => setQueueOpen((v) => !v)}
> >
<Icon src={queueIcon} size={20} /> <Icon src={queueIcon} size={20} />
@@ -195,23 +203,33 @@ export function PlayerBar({ variant = 'bar' }: { variant?: 'bar' | 'island' }) {
style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties} style={{ '--pct': `${(st.muted ? 0 : st.volume) * 100}%` } as CSSProperties}
/> />
</div> </div>
{queueOpen && <QueueDrawer variant={variant} onClose={() => setQueueOpen(false)} />} {queueOpen && <QueueDrawer id={queueId} variant={variant} onClose={closeQueue} />}
</footer> </footer>
); );
} }
/** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */ /** Очередь воспроизведения (выдвижная панель справа): что играет и что дальше, клик — перейти к треку. */
function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose: () => void }) { function QueueDrawer({ id, variant, onClose }: { id: string; variant: 'bar' | 'island'; onClose: () => void }) {
const { t } = useTranslation(); const { t } = useTranslation();
const player = usePlayer(); const player = usePlayer();
const st = usePlayerState(); const st = usePlayerState();
const upcoming = st.queue.slice(st.index, st.index + 40); const upcoming = st.queue.slice(st.index, st.index + 40);
const close = useRef<HTMLButtonElement>(null);
// Клавиатура: при открытии фокус в панель, Escape закрывает.
useEffect(() => {
close.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [onClose]);
// Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе. // Портал: у плеера backdrop-filter, а он делает fixed-потомков относительными к себе.
return createPortal( return createPortal(
<aside className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}> <aside id={id} className={cx(s.drawer, variant === 'island' && s.drawerIsland)} aria-label={t('player.queue')}>
<div className={s.drawerHead}> <div className={s.drawerHead}>
<span>{t('player.queue')}</span> <span>{t('player.queue')}</span>
<button type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}> <button ref={close} type="button" className={s.icon} aria-label={t('form.close')} onClick={onClose}>
<Icon src={closeIcon} size={20} /> <Icon src={closeIcon} size={20} />
</button> </button>
</div> </div>
@@ -221,6 +239,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
key={`${tr.id}-${k}`} key={`${tr.id}-${k}`}
type="button" type="button"
className={cx(s.queueRow, k === 0 && s.queueCurrent)} className={cx(s.queueRow, k === 0 && s.queueCurrent)}
aria-current={k === 0 || undefined}
onClick={() => player.jump(st.index + k)} onClick={() => player.jump(st.index + k)}
> >
<Cover album={tr.album} size={40} /> <Cover album={tr.album} size={40} />
@@ -232,6 +251,7 @@ function QueueDrawer({ variant, onClose }: { variant: 'bar' | 'island'; onClose:
</button> </button>
))} ))}
</div> </div>
{st.wave && <p className={s.drawerNote}>{t('player.queue_wave')}</p>}
</aside>, </aside>,
document.body, document.body,
); );
+139
View File
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Резервные копии PostgreSQL (docs/deployment.md#бэкапы). Работает в контейнере backup (compose.yaml) и в CI;
# подключение — стандартные PGHOST, PGUSER, PGPASSWORD, PGDATABASE.
# backup.sh loop — каждый день в BACKUP_HOUR (UTC): копия и ротация; раз в неделю — проверка восстановления
# backup.sh once — копия сейчас; печатает путь к файлу
# backup.sh verify FILE — восстановить FILE во временную базу и сверить таблицы и число строк
# backup.sh restore FILE — заменить рабочую базу копией (сервисы, пишущие в базу, должны быть остановлены);
# прежняя база остаётся рядом как <db>_before_restore_<время>
set -euo pipefail
DIR="${BACKUP_DIR:-/backups}"
DB="${PGDATABASE:-pipu}"
KEEP_DAILY="${BACKUP_KEEP_DAILY:-14}"
KEEP_WEEKLY="${BACKUP_KEEP_WEEKLY:-8}"
log() { echo "$(date -u +%FT%TZ) backup: $*" >&2; }
# Число строк в каждой таблице базы $1: «таблица<TAB>строк», по алфавиту.
counts() {
psql -X -d "$1" -Atq -F $'\t' -c "
SELECT table_name,
(xpath('/row/c/text()', query_to_xml(format('SELECT count(*) AS c FROM %I.%I', table_schema, table_name),
false, true, '')))[1]::text
FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY 1"
}
rotate() {
# shellcheck disable=SC2012 # имена файлов наши, без пробелов
ls -1t "$DIR"/daily/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_DAILY + 1)) | xargs -r rm -f --
# shellcheck disable=SC2012
ls -1t "$DIR"/weekly/pipu-*.dump 2>/dev/null | tail -n +$((KEEP_WEEKLY + 1)) | xargs -r rm -f --
find "$DIR/daily" "$DIR/weekly" -name '*.counts' | while read -r c; do
[ -f "${c%.counts}.dump" ] || rm -f -- "$c"
done
}
once() {
mkdir -p "$DIR/daily" "$DIR/weekly"
local name file
name="pipu-$(date -u +%Y%m%d-%H%M%S)"
file="$DIR/daily/$name.dump"
pg_dump -Fc -d "$DB" -f "$file.part"
pg_restore -l "$file.part" >/dev/null # оглавление читается — архив не обрезан
counts "$DB" >"$file.counts" # для verify: что было в базе в момент копии
mv "$file.part" "$file"
# Еженедельная копия — первая за 7 дней (не зависит от того, в какой день сервер был выключен).
if ! find "$DIR/weekly" -name 'pipu-*.dump' -mtime -7 | grep -q .; then
cp "$file" "$DIR/weekly/$name.dump"
cp "$file.counts" "$DIR/weekly/$name.dump.counts"
fi
rotate
log "$file ($(du -h "$file" | cut -f1))"
echo "$file"
}
verify() {
local file="$1" tmpdb="${DB}_verify" got want
[ -f "$file" ] || { log "нет файла $file"; return 1; }
dropdb --if-exists "$tmpdb"
createdb "$tmpdb"
if ! pg_restore --no-owner --exit-on-error -d "$tmpdb" "$file"; then
dropdb --if-exists "$tmpdb"
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file не восстанавливается"
return 1
fi
got="$(counts "$tmpdb")"
dropdb "$tmpdb"
want="$(cat "$file.counts" 2>/dev/null || counts "$DB")"
# Таблица пропала или опустела — копия негодна. Расхождение в числе строк — рабочая база менялась во время
# копии (предупреждение); VERIFY_EXACT=1 (CI, база в покое) — тоже ошибка.
local bad=0 table n m
while IFS=$'\t' read -r table n; do
m="$(printf '%s\n' "$got" | awk -F'\t' -v t="$table" '$1 == t { print $2 }')"
if [ -z "$m" ]; then
log "в копии нет таблицы $table"
bad=1
elif [ "$m" = 0 ] && [ "$n" != 0 ]; then
log "в копии пуста таблица $table (было $n)"
bad=1
elif [ "$m" != "$n" ]; then
log "$table: $m строк в копии, $n при копировании"
[ "${VERIFY_EXACT:-}" = 1 ] && bad=1
fi
done <<<"$want"
if [ "$bad" = 1 ]; then
log "ПРОВЕРКА НЕ ПРОЙДЕНА: $file"
return 1
fi
log "проверено: $file восстанавливается ($(printf '%s\n' "$got" | wc -l) таблиц)"
date -u +%FT%TZ >"$DIR/.last-verify"
}
restore() {
local file="$1" old
verify "$file"
old="${DB}_before_restore_$(date -u +%Y%m%d%H%M%S)"
psql -X -d postgres -Atqc "SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE datname = '$DB' AND pid <> pg_backend_pid()" >/dev/null
psql -X -d postgres -qc "ALTER DATABASE \"$DB\" RENAME TO \"$old\""
createdb "$DB"
if ! pg_restore --no-owner --exit-on-error -d "$DB" "$file"; then
dropdb "$DB"
psql -X -d postgres -qc "ALTER DATABASE \"$old\" RENAME TO \"$DB\""
log "восстановление не удалось — рабочая база не тронута"
return 1
fi
log "база $DB восстановлена из $file; прежняя сохранена как $old (удалить: dropdb $old)"
}
loop() {
local hour="${BACKUP_HOUR:-3}" now next file
log "ежедневная копия в $hour:30 UTC, хранится $KEEP_DAILY ежедневных и $KEEP_WEEKLY еженедельных"
while true; do
now="$(date -u +%s)"
next="$(date -u -d "today $hour:30" +%s)"
[ "$next" -gt "$now" ] || next="$(date -u -d "tomorrow $hour:30" +%s)"
sleep $((next - now))
if ! file="$(once)"; then
log "КОПИЯ НЕ СДЕЛАНА"
continue
fi
if [ -z "$(find "$DIR" -maxdepth 1 -name .last-verify -mtime -7)" ]; then
verify "$file" || true
fi
done
}
case "${1:-}" in
loop) loop ;;
once) once ;;
verify) verify "${2:?укажите файл копии}" ;;
restore) restore "${2:?укажите файл копии}" ;;
*)
sed -n '2,9p' "$0" >&2
exit 2
;;
esac
+23
View File
@@ -20,11 +20,13 @@ x-library-ro: &library-ro
# Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music. # Локальная музыка (источники type=local): каталог хоста, например смонтированный диск /mnt/music.
# Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw # Всем сервисам — только чтение; на запись (импорт загрузок) его видит только storage-agent — /library-host-rw
# (docs/library-and-storage.md#запись-в-хранилище). # (docs/library-and-storage.md#запись-в-хранилище).
# rslave: диск, смонтированный на хосте уже после старта контейнеров, становится виден внутри без перезапуска.
x-library-host-ro: &library-host-ro x-library-host-ro: &library-host-ro
type: bind type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host} source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host target: /library-host
read_only: true read_only: true
bind: { propagation: rslave }
x-restart: &restart x-restart: &restart
restart: unless-stopped restart: unless-stopped
@@ -96,6 +98,7 @@ services:
- type: bind - type: bind
source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host} source: ${LIBRARY_HOST:-${PIPU_DATA:-/srv/pipu}/library-host}
target: /library-host-rw target: /library-host-rw
bind: { propagation: rslave }
- ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro - ${PIPU_DATA:-/srv/pipu}/uploads:/uploads:ro
recommender: recommender:
@@ -142,6 +145,26 @@ services:
interval: 5s interval: 5s
retries: 20 retries: 20
# Ежедневная копия базы и еженедельная проверка восстановления (deploy/backup.sh, docs/deployment.md#бэкапы).
backup:
<<: *restart
image: pgvector/pgvector:0.8.7-pg17
entrypoint: ["bash", "/backup.sh"]
command: ["loop"]
environment:
PGHOST: postgres
PGUSER: pipu
PGPASSWORD: ${POSTGRES_PASSWORD}
PGDATABASE: pipu
BACKUP_HOUR: ${BACKUP_HOUR:-3}
BACKUP_KEEP_DAILY: ${BACKUP_KEEP_DAILY:-14}
BACKUP_KEEP_WEEKLY: ${BACKUP_KEEP_WEEKLY:-8}
volumes:
- ./backup.sh:/backup.sh:ro
- ${PIPU_DATA:-/srv/pipu}/backups:/backups
depends_on:
postgres: { condition: service_healthy }
redis: redis:
<<: *restart <<: *restart
image: redis:7.4-alpine image: redis:7.4-alpine
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Восстановление базы из резервной копии (docs/deployment.md#бэкапы).
# ./restore.sh — список копий
# ./restore.sh daily/pipu-20261020-033000.dump
# Сервисы, пишущие в базу, останавливаются на время восстановления; прежняя база сохраняется рядом.
set -euo pipefail
cd "$(dirname "$0")"
[ -f .env ] || { echo "нет .env — сначала sudo ./install.sh" >&2; exit 1; }
DATA="$(sed -n 's/^PIPU_DATA=//p' .env)"
DATA="${DATA:-/srv/pipu}"
if [ -z "${1:-}" ]; then
echo "Копии в $DATA/backups (новые сверху):"
(cd "$DATA/backups" && ls -1t daily/*.dump weekly/*.dump 2>/dev/null) || echo " копий нет"
exit 0
fi
rel="${1#"$DATA/backups/"}"
[ -f "$DATA/backups/$rel" ] || { echo "нет файла $DATA/backups/$rel" >&2; exit 1; }
echo "→ восстановление из $rel: api, рекомендации и анализ будут остановлены на время восстановления"
read -r -p "Продолжить? [y/N] " yes
[ "$yes" = y ] || [ "$yes" = Y ] || exit 1
docker compose stop api recommender analyzer storage-agent
if docker compose run --rm backup restore "/backups/$rel"; then
status=0
else
status=1
fi
docker compose up -d
[ "$status" = 0 ] || { echo "восстановление не удалось — база не изменена" >&2; exit 1; }
echo "готово. Поиск переиндексируется при старте api (или вручную: «Администрирование → Библиотека»)."
+15
View File
@@ -26,6 +26,15 @@ for key in JWT_PRIVATE_KEY STREAM_SIGNING_KEY; do
fi fi
done done
# Папка с музыкой пуста — почти всегда это несмонтированный диск: api отдавал бы ошибки воспроизведения.
LIBRARY_HOST="$(sed -n 's/^LIBRARY_HOST=//p' .env)"
if [ -n "$LIBRARY_HOST" ] && [ -z "$(ls -A "$LIBRARY_HOST" 2>/dev/null)" ] && [ "${PIPU_ALLOW_EMPTY_LIBRARY:-}" != 1 ]; then
echo "LIBRARY_HOST=$LIBRARY_HOST пуст или не существует — диск с музыкой не смонтирован?" >&2
echo " проверьте: findmnt $LIBRARY_HOST; смонтируйте (sudo mount -a) и повторите." >&2
echo " Если папка и должна быть пустой: PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh ${1:-}" >&2
exit 1
fi
REF="${1:-}" REF="${1:-}"
git fetch --tags --prune --quiet origin git fetch --tags --prune --quiet origin
@@ -53,6 +62,12 @@ if [ -n "$cur_core" ] && [ -n "$tgt_core" ] && [ "$cur_core" != "$tgt_core" ] &&
fi fi
fi fi
# Копия базы перед обновлением: миграции назад не откатываются (deploy/restore.sh — вернуть).
if [ -n "$(docker compose ps -q --status running postgres 2>/dev/null)" ]; then
echo "→ резервная копия базы"
docker compose run --rm --no-deps backup once >/dev/null
fi
echo "→ переключаемся на $REF" echo "→ переключаемся на $REF"
if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then if git show-ref --verify --quiet "refs/remotes/origin/$REF"; then
git checkout --quiet "$REF" git checkout --quiet "$REF"
+1
View File
@@ -23,6 +23,7 @@ Pipu Music — self-hosted музыкальный сервис (веб + Android
| [testing.md](testing.md) | Стратегия тестирования | | [testing.md](testing.md) | Стратегия тестирования |
| [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу | | [ci-cd.md](ci-cd.md) | Gitea Actions: проверки и релиз по тегу |
| [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android | | [install-guide.md](install-guide.md) | **Пошагово:** установка сервера на домен с HTTPS, подключение веба и Android |
| [admin-guide.md](admin-guide.md) | **Администратору:** пользователи, хранилища, очередь, волна, обновления, бэкапы, диагностика |
| [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы | | [deployment.md](deployment.md) | Справочник по развёртыванию: сервисы, каталоги, переменные, бэкапы |
| [conventions.md](conventions.md) | Соглашения по коду, git, версиям | | [conventions.md](conventions.md) | Соглашения по коду, git, версиям |
| [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности | | [roadmap.md](roadmap.md) | Этапы разработки и критерии готовности |
+134
View File
@@ -0,0 +1,134 @@
# Руководство администратора
Повседневная работа с сервером после установки ([install-guide.md](install-guide.md)): пользователи, музыка,
очередь на добавление, «Моя волна», обновления, бэкапы и диагностика. Все команды — из каталога
`/opt/pipu-music/deploy`. Справочник по сервисам и переменным — [deployment.md](deployment.md).
## 1. Первый вход
1. Логин `admin`. Пароль — `BOOTSTRAP_ADMIN_PASSWORD` из `.env`, а если он не задан — сгенерированный, его
печатает лог один раз: `docker compose logs api | grep -i bootstrap`.
2. Сервер сразу попросит сменить логин и пароль администратора (пароль ≥ 12 символов). До этого остальные
разделы недоступны.
3. Подключите хранилище с музыкой (раздел 3) — после первого скана появятся библиотека, поиск и «Моя волна».
## 2. Пользователи и роли
«Администрирование → Пользователи».
| Роль | Что может |
|---|---|
| Пользователь | Слушать, искать, собирать коллекцию, «Моя волна» |
| Модератор | Плюс: заводить пользователей и сбрасывать им пароли, смотреть статус библиотеки, предлагать треки |
| Администратор | Всё: хранилища, сканы, очередь на добавление, настройки сервера, роли, аудит |
Полная матрица прав — [auth-and-roles.md](auth-and-roles.md#матрица-прав).
- **Новый пользователь:** «Создать» → логин, имя, роль. Сервер выдаёт **временный пароль** — он показывается один
раз, передайте его человеку. При первом входе пароль придётся сменить.
- **Забыл пароль:** «Сбросить пароль» — новый временный пароль, все сессии пользователя завершаются.
- **Заблокировать** — вход запрещён, история и лайки сохраняются; **удалить** — безвозвратно вместе с историей.
- Последнего администратора нельзя понизить, заблокировать или удалить.
- **Аудит** («Администрирование → Аудит»): кто, когда и что сделал — создание пользователей, смена ролей, сбросы
паролей, изменения хранилищ и настроек, решения по очереди.
## 3. Хранилища и библиотека
«Администрирование → Хранилища».
- **Папка на сервере** — каталог хоста из `LIBRARY_HOST` в `.env` (например, смонтированный диск `/mnt/music`);
в форме указывается путь внутри него. **NFS** — сервер и экспорт; монтирует `storage-agent`.
- «Проверить» — доступ к хранилищу, число найденных аудиофайлов.
- **Шаблоны путей** («Шаблоны»): как из пути файла получить исполнителя, альбом, год и номер трека, если в тегах
пусто. «Предпросмотр» показывает разбор реальных путей до сохранения. Что важнее — теги или путь, задаётся
в карточке хранилища (по умолчанию теги).
- **Скан:** обычный — только новые и изменённые файлы (по расписанию — интервал в карточке хранилища — и после
изменения настроек), «Полный» — перечитать всё. Удалённые с диска файлы помечаются отсутствующими, а не стираются из истории.
- «Администрирование → Библиотека» — счётчики, прогресс скана и аудиоанализа, файлы с ошибками чтения
(битые файлы, неизвестные форматы) и «Переиндексировать поиск» — если поиск выдаёт устаревшее (обычно не нужно:
индекс обновляется сам и пересобирается при старте api).
- **Аудиоанализ** (темп, энергия, настроение — для «Моей волны») идёт в фоне: на 10 тыс. треков — несколько часов
при 4 ядрах (`analyzer` в `compose.yaml`, `--workers`). Волна работает и до его окончания, но точнее — после.
## 4. Очередь на добавление
Модераторы и администраторы предлагают треки («Предложить треки»), администратор решает, что попадёт
в библиотеку. Подробно — [uploads.md](uploads.md).
- **Куда пишутся файлы:** у хранилища включите «Принимать загрузки» и задайте **шаблон раскладки** — «Подобрать»
предложит шаблон по уже лежащим файлам, «Проверить» убедится, что из пути, построенного шаблоном, сканер
получит те же исполнителя, альбом и номер трека. Хранилище по умолчанию — в «Настройках сервера».
- **Рассмотрение** («Очередь на добавление»): прослушать, поправить метаданные (в том числе сразу для всего
альбома), одобрить или отклонить с причиной — автор её увидит. Итоговый путь пересчитывается при правке.
Отметка «возможный дубль» — со ссылкой на похожий трек библиотеки; точные копии отсекаются ещё до загрузки.
- **Импорт:** одобренный файл записывается в хранилище только под окончательным именем, с проверкой контрольной
суммы; кроме самих аудиофайлов (и папок для них) на диск ничего не пишется.
- **Квота** («Настройки сервера»): общий лимит очереди (10 ГБ по умолчанию), лимит на одного модератора
(выключен) и максимальный размер файла (2 ГБ). Отклонённые файлы и дубли место не занимают; снижение лимита ниже
занятого не удаляет загрузки, просто новые не принимаются.
## 5. «Моя волна»
- «Администрирование → Библиотека → Моя волна за 7 дней»: сколько треков выдано, доля дослушанных до конца,
ранних скипов и лайков на 100 треков. Ориентир — ранних скипов меньше 30%.
- Таблица по **версиям модели**: `heuristic-v1` — эвристика, `heuristic-v1+cf` — она же с коллаборативной
фильтрацией (ALS), `lgbm-…` — обученный ранжировщик. По ней видно, стало ли лучше после смены модели.
- Обучение — каждую ночь в 3:00 UTC: ALS по прослушиваниям, бандит по источникам
кандидатов, ранжировщик по показам волны. Новая модель включается, только если по проверке на последней неделе
не хуже текущей. Ранжировщику нужно ~20 тыс. показов с известным исходом — до этого работает эвристика.
- Новые пользователи с почти пустой историей проходят **онбординг вкуса**: выбирают исполнителей и жанры
библиотеки. Пропуск тоже запоминается.
## 6. Обновление
```bash
./update.sh
```
Скрипт делает копию базы, переключается на последний релиз, собирает образы, перезапускает сервисы и ждёт
готовности; миграции применяются при старте api. Конкретная версия — `./update.sh v1.0.0`; откат на старую версию
требует `PIPU_ALLOW_DOWNGRADE=1` (миграции не откатываются — сначала проверьте совместимость). Перед обновлением
прочитайте заметки к релизу на странице релизов Gitea.
Android-приложение обновляется само: при запуске оно сверяется с сервером и предлагает новый APK.
## 7. Бэкапы и восстановление
- Каждую ночь (`BACKUP_HOUR`, по умолчанию 3:30 UTC) контейнер `backup` делает копию базы в
`/srv/pipu/backups/daily` (14 последних) и раз в неделю — в `weekly` (8 последних).
- Раз в неделю копия **проверяется восстановлением** во временную базу. Результат:
`docker compose logs backup | tail`; при ошибке в логе — «ПРОВЕРКА НЕ ПРОЙДЕНА».
- Копия сейчас: `docker compose run --rm backup once`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/<файл>` — восстановить. Прежняя база
остаётся рядом как `pipu_before_restore_<время>`; убедившись, что всё в порядке, удалите её:
`docker compose exec postgres dropdb -U pipu pipu_before_restore_<время>`.
- В копию входит всё, что нельзя пересоздать: пользователи, история, лайки, состояние плеера, очередь на добавление,
результаты аудиоанализа и модели волны. Поиск, обложки и транскоды восстанавливаются сами.
- **Сама музыка не бэкапится** приложением — это ваш диск или NFS. Каталог бэкапов тоже лежит на сервере:
копируйте `/srv/pipu/backups` на другую машину, например `rsync` по cron.
## 8. Наблюдение
| Что | Как |
|---|---|
| Сервер жив и готов | `curl -s https://music.example.com/readyz` → все зависимости `ok` |
| Состояние сервисов | `docker compose ps` |
| Логи | `docker compose logs -f --tail=100 api` (или `recommender`, `analyzer`, `storage-agent`, `backup`) |
| Место на диске | `df -h /srv/pipu`; больше всего занимают `cache/transcode` (чистится сам) и `backups` |
| Версия | `grep PIPU_VERSION .env`, `GET /api/v1/server-info`; Android — внизу вкладки «Профиль» |
| Нагрузка | `tests/load/smoke.js` — см. [testing.md](testing.md#нагрузочный-smoke) |
## 9. Частые ситуации
| Ситуация | Что делать |
|---|---|
| Ни один трек не играет («Не удалось воспроизвести трек»), в логе api `no such file or directory` | Диск с музыкой не смонтирован: `findmnt <LIBRARY_HOST>`, `sudo mount -a`. Контейнеры увидят диск сами; скан на пустой папке прерывается с ошибкой «is the disk mounted?» и ничего не помечает пропавшим |
| Трек не находится поиском | Есть ли он в «Библиотеке» (скан завершён, файл не в «ошибках»); затем «Переиндексировать поиск» |
| Неверный исполнитель или альбом | Поправьте теги файла или шаблон путей и запустите скан |
| Хранилище в статусе «Ошибка» | Текст ошибки на карточке; диск смонтирован (`findmnt`), NFS доступен с сервера; «Проверить» |
| Волна однообразная | Идёт ли аудиоанализ («Библиотека → Аудиоанализ»); чем больше прослушиваний, тем точнее волна |
| Загрузка застряла в «Обработке» | `docker compose logs api | grep upload`; повторный импорт — «Одобрить» ещё раз |
| Пользователь не может войти | Не заблокирован ли; «Сбросить пароль» |
| Сервис перезапускается по кругу | `docker compose logs <сервис>`; часто — закончилось место на диске |
Остальное — [install-guide.md](install-guide.md#12-если-что-то-не-работает).
+15 -1
View File
@@ -97,7 +97,21 @@
## Бэкапы ## Бэкапы
- `pg_dump` ежедневно (cron-контейнер `backup`), хранить 14 дней + еженедельные 8 недель → `/srv/pipu/backups`. - Контейнер `backup` (`deploy/backup.sh loop`): каждый день в `BACKUP_HOUR`:30 UTC (по умолчанию 3:30) —
`pg_dump -Fc` → `/srv/pipu/backups/daily/`, хранится `BACKUP_KEEP_DAILY` = 14 последних; первая копия за неделю
копируется в `weekly/` (`BACKUP_KEEP_WEEKLY` = 8). Рядом с копией — `*.counts`: число строк в каждой таблице.
- Раз в неделю копия **проверяется восстановлением**: разворачивается во временную базу `pipu_verify`, сверяются
таблицы и число строк, база удаляется; время последней успешной проверки — `backups/.last-verify`, ошибки —
в `docker compose logs backup` («ПРОВЕРКА НЕ ПРОЙДЕНА»). Тот же цикл копия → проверка → восстановление
выполняется в CI (`ci / db`) на базе после сквозного сценария.
- `update.sh` перед обновлением делает внеочередную копию.
- Вручную: `docker compose run --rm backup once` — копия сейчас; `docker compose run --rm backup verify /backups/daily/<файл>`.
- Восстановление: `./restore.sh` — список копий, `./restore.sh daily/pipu-….dump` — останавливает api, рекомендации и
анализ, проверяет копию, переименовывает текущую базу в `pipu_before_restore_<время>` (страховка; удалить —
`docker compose exec postgres dropdb -U pipu <имя>`), восстанавливает и запускает сервисы; поиск переиндексируется
при старте api. Redis (сессии волны, очереди) не восстанавливается — пользователи просто начнут новую волну.
- Копии лежат на том же сервере: для защиты от потери диска синхронизируйте `/srv/pipu/backups` на другую машину
(например, `rclone sync` или `rsync` по cron).
- Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ). - Meilisearch, кэши и модели **не бэкапятся** — восстанавливаются (`reindex`, повторный транскод / анализ).
- Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен. - Признаки анализа хранятся в Postgres — после восстановления из бэкапа повторный многочасовой анализ не нужен.
- Сама музыка — на NFS, вне зоны ответственности приложения. - Сама музыка — на NFS, вне зоны ответственности приложения.
+2 -1
View File
@@ -48,8 +48,9 @@
| `elev` | `#111726` | `#FFFFFF` | Поповеры, меню | | `elev` | `#111726` | `#FFFFFF` | Поповеры, меню |
| `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении | | `hover` | `rgba(120,150,255,.07)` | `rgba(30,60,140,.06)` | Подсветка при наведении |
| `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки | | `line` / `line2` | `rgba(130,160,255,.10)` / `.18` | `rgba(20,35,80,.10)` / `.17` | Разделители / рамки |
| `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#737F9C` | `#0A0F1E` / `#4B5675` / `#677290` | Текст: основной / вторичный / подписи | | `text` / `text2` / `text3` | `#EDF1FF` / `#A0ABC6` / `#7F8BA8` | `#0A0F1E` / `#4B5675` / `#5C6785` | Текст: основной / вторичный / подписи |
| `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки | | `accent` / `accent2` | `#2F80FF` / `#5E9BFF` | `#1F6FF2` / `#1F6FF2` | Основной акцент, ссылки |
| `accent-fill` / `accent-fill2` | `#1A66E6` / `#1F6FF2` | `#1A63DB` / `#165ED6` | Заливка под белым текстом (кнопки, выбранные сегменты) и её hover — контраст ≥ 4.5:1 |
| `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус | | `accent-soft` | `rgba(47,128,255,.15)` | `rgba(31,111,242,.10)` | Активный пункт меню, фокус |
| `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) | | `violet` / `violet-soft` | `#9B7CFF` / `rgba(124,92,255,.16)` | `#6A47F0` / `.10` | Второй акцент (волна, бейджи) |
| `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех | | `ok` / `ok-soft` | `#2FD39A` | `#0E9465` | Успех |
+13 -2
View File
@@ -314,12 +314,21 @@ cd /opt/pipu-music/deploy && ./update.sh
пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются пересобирает образы и перезапускает сервисы; старые образы удаляются. Миграции БД применяются
автоматически при старте API. автоматически при старте API.
Бэкап базы (всё остальное восстанавливается само — см. [deployment.md](deployment.md#бэкапы)): Бэкапы базы делаются сами: каждую ночь в `/srv/pipu/backups`, раз в неделю копия проверяется восстановлением,
перед каждым `update.sh` — внеочередная копия. Всё остальное восстанавливается само — см.
[deployment.md](deployment.md#бэкапы). Копия сейчас и восстановление:
```bash ```bash
docker compose exec -T postgres pg_dump -U pipu -Fc pipu > /srv/pipu/backups/pipu-$(date +%F).dump cd /opt/pipu-music/deploy && docker compose run --rm backup once
``` ```
```bash
cd /opt/pipu-music/deploy && ./restore.sh
```
Без аргумента `restore.sh` показывает список копий; с именем файла (`./restore.sh daily/pipu-20261020-033000.dump`)
восстанавливает базу, сохранив прежнюю рядом.
Остановить сервис, сохранив данные: Остановить сервис, сохранив данные:
```bash ```bash
@@ -339,6 +348,8 @@ cd /opt/pipu-music/deploy && docker compose down
| Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша | | Сборка падает на скачивании зависимостей | Нет интернета или DNS на сервере; повторите `./update.sh` — уже собранные слои берутся из кэша |
| Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` | | Сборка падает: «no space left on device» | Очистите кэш сборки: `docker builder prune` |
| `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились | | `permission denied … docker.sock` | Пользователь не в группе `docker` (шаг 3) или вы не перелогинились |
| Сервисы не стартуют: `… is not a shared or slave mount` | `sudo mount --make-rshared /` (и добавьте в автозагрузку); на systemd-системах корень и так shared |
| `update.sh`: «LIBRARY_HOST … пуст» | Диск с музыкой не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; если папка и должна быть пустой — `PIPU_ALLOW_EMPTY_LIBRARY=1 ./update.sh` |
| Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` | | Сервисы не стартуют: `bind source path does not exist: /mnt/music` | Диск не смонтирован: `findmnt /mnt/music`, `sudo mount -a`; путь в `LIBRARY_HOST` в `.env` |
| Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) | | Хранилище «Локальная папка» пустое или ошибка доступа | Диск смонтирован (`ls /mnt/music`); права на чтение для всех — `sudo chmod -R a+rX /mnt/music` (шаг 4) |
| `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` | | `storage-agent` не стартует, ошибка про propagation / shared mount | Ещё раз `sudo ./install.sh`; на хосте `findmnt -o TARGET,PROPAGATION /srv/pipu/library` должно показать `shared` |
+4 -2
View File
@@ -161,7 +161,9 @@ Meilisearch, индексы:
- Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней). - Ранжирование: стандартные правила Meilisearch + `popularity:desc` (глобальные засчитанные прослушивания за 90 дней).
- `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс, - `/search/suggest` использует `multi-search` (один запрос — три индекса), отдаёт ≤ 3/3/5. Клиент делает debounce 150 мс,
отменяет предыдущий запрос (`AbortController`). отменяет предыдущий запрос (`AbortController`).
- Персонализация (опционально, этап 2): треки, которые пользователь лайкал / часто слушал, поднимаются выше - Персонализация: `suggest` берёт из Meilisearch втрое больше кандидатов и переранжирует их в `api`
в выдаче `suggest` (re-rank первых 20 на стороне `api`). (`internal/search/personal.go`). Вес объекта — прослушивания + 2 × доигрывания − ранние скипы, лайк трека ±10,
реакция на исполнителя ±20; альбом и исполнитель суммируют вес своих треков. Знакомое поднимается на
2·ln(1+вес) позиций, нелюбимое опускается на 3 — релевантность остаётся основой порядка.
- Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача - Синхронизация: `api` после изменения каталога ставит документы в батч-очередь; полная переиндексация — задача
`reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма). `reindex_search` (индекс собирается во временном индексе и подменяется через `swap-indexes`, без даунтайма).
+2
View File
@@ -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`), и не хуже активной более чем на 2%. Модель хранится строкой в `model_versions.artifact` (kind `ranker`),
сервис загружает активную при старте и после ночного обучения; в выдаче её порядок заменяет оценку v1, сервис загружает активную при старте и после ночного обучения; в выдаче её порядок заменяет оценку v1,
MMR-разнообразие и квоты остаются. Пока модели нет, `model_ver` показа — версия v1. MMR-разнообразие и квоты остаются. Пока модели нет, `model_ver` показа — версия v1.
Онлайн-сравнение — админка «Библиотека → Моя волна»: доля доигрываний, ранних скипов и лайков
за 7 дней отдельно по каждому `model_ver` показа.
### 6.4 Занятия и настроения ### 6.4 Занятия и настроения
+5
View File
@@ -49,10 +49,15 @@
- ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик. - ALS, центроиды вкусов, LightGBM-ранжировщик, бандит, онбординг, ночное обучение с проверкой метрик.
- Персонализация suggest. - Персонализация suggest.
- **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate. - **Готово, когда:** на накопленных данных v2 лучше v1 по офлайн-NDCG и онлайн early-skip rate.
- **Состояние:** всё реализовано (v0.8, v1.0). Ранжировщик включается сам после ~20 тыс. размеченных показов;
критерий проверяется по метрикам версий в админке («Библиотека → Моя волна») и NDCG в `model_versions`.
## M7 — Стабилизация (v1.0) ## M7 — Стабилизация (v1.0)
- Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора, - Нагрузочный smoke, бэкапы и восстановление (проверено), документация администратора,
доступность, полировка анимаций, багфиксы. доступность, полировка анимаций, багфиксы.
- **Состояние:** v1.0 — бэкапы с еженедельной проверкой восстановления (и цикл копия → проверка → восстановление
в CI), [руководство администратора](admin-guide.md), аудит доступности WCAG 2.1 AA, k6-smoke
(`tests/load/smoke.js`, запускается вручную на сервере перед релизом).
## Дальше (идеи) ## Дальше (идеи)
- Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA, - Плейлисты, волна по исполнителю / треку, ежедневные подборки, тексты песен, Chromecast / DLNA,
+25 -1
View File
@@ -49,4 +49,28 @@
- Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток. - Покрытие: Go и Python ≥ 70% по строкам для `internal/*/service` и `pipu_rec/*`; для `auth/policy` — 100% веток.
- Тест на каждый исправленный баг. - Тест на каждый исправленный баг.
- Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно. - Flaky-тесты помечаются и чинятся в течение недели, а не перезапускаются бесконечно.
- Нагрузочный smoke (k6, вручную перед релизом): 100 одновременных слушателей, 20 rps на suggest — p95 в рамках целей. - Нагрузочный smoke (k6, вручную перед релизом) — см. ниже.
## Нагрузочный smoke
`tests/load/smoke.js` (k6 ≥ 0.50), 3 минуты: 100 одновременных слушателей волны (старт волны, начало потока
каждого трека — первые 64 КБ, события прослушивания, следующая пачка с отзывами) и 20 запросов подсказок поиска
в секунду. Пороги — цели из [api.md](api.md) и [recommendations.md](recommendations.md#10-требования-к-производительности):
| Запрос | p95 |
|---|---|
| `GET /search/suggest` | < 50 мс |
| `POST /wave/sessions/{id}/next` | < 150 мс |
| `POST /wave/sessions` | < 300 мс |
| начало потока (`Range: bytes=0-65535`) | < 300 мс |
| `POST /events` | < 100 мс |
Ошибок < 1%. k6 завершается с ненулевым кодом, если порог не выполнен.
```bash
k6 run -e BASE_URL=https://music.example.com -e PIPU_USER=loadtest -e PIPU_PASSWORD=… tests/load/smoke.js
```
Пользователь — отдельный обычный (`role=user`): тест наполняет его историю и лайки. Нагрузку можно менять:
`-e LISTENERS=20 -e SUGGEST_RPS=5 -e DURATION=1m`. Запускать с машины в той же сети, что и сервер, —
иначе в цифрах будет задержка канала.
+12
View File
@@ -101,3 +101,15 @@ apps/web/src/
## Доступность ## Доступность
- Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA. - Вся навигация с клавиатуры, видимый фокус, ARIA-роли у плеера и поиска, контраст AA.
## Доступность
Цель — WCAG 2.1 AA. Проверено axe-core на всех экранах в тёмной и светлой темах (с играющим треком и открытой
очередью) — нарушений нет. Правила, которые держим:
- текст на заливке акцента — только `--accent-fill` (белый текст ≥ 4.5:1), вторичный текст `--text3` — ≥ 4.5:1
на фоне и поверхностях;
- у каждого экрана есть `h1`; у кнопок-иконок — `aria-label`, у переключателей — `aria-pressed`;
- списки треков — `role="list"` / `listitem` (строка фокусируется, Enter запускает трек, лайк и ссылки — отдельные
элементы); прогресс-бары подписаны через `aria-labelledby`;
- выезжающие панели (очередь) забирают фокус при открытии, закрываются по Escape и возвращают фокус на кнопку;
- `:focus-visible` — обводка акцентом; `prefers-reduced-motion` — анимации и Ambient выключены, волна замедлена.
@@ -2021,6 +2021,15 @@ export interface components {
/** @description Лайков на 100 треков */ /** @description Лайков на 100 треков */
like_rate: number | null; like_rate: number | null;
sessions: number; sessions: number;
/** @description Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн */
by_model: components["schemas"]["WaveModelMetrics"][];
};
WaveModelMetrics: {
model_ver: string;
served: number;
listen_through_rate: number | null;
early_skip_rate: number | null;
like_rate: number | null;
}; };
AndroidRelease: { AndroidRelease: {
/** @example 0.6.1 */ /** @example 0.6.1 */
@@ -22,9 +22,11 @@
--line2: rgba(130,160,255,.18); --line2: rgba(130,160,255,.18);
--text: #EDF1FF; --text: #EDF1FF;
--text2: #A0ABC6; --text2: #A0ABC6;
--text3: #737F9C; --text3: #7F8BA8;
--accent: #2F80FF; --accent: #2F80FF;
--accent2: #5E9BFF; --accent2: #5E9BFF;
--accent-fill: #1A66E6;
--accent-fill2: #1F6FF2;
--accent-soft: rgba(47,128,255,.15); --accent-soft: rgba(47,128,255,.15);
--violet: #9B7CFF; --violet: #9B7CFF;
--violet-soft: rgba(124,92,255,.16); --violet-soft: rgba(124,92,255,.16);
@@ -56,9 +58,11 @@
--line2: rgba(20,35,80,.17); --line2: rgba(20,35,80,.17);
--text: #0A0F1E; --text: #0A0F1E;
--text2: #4B5675; --text2: #4B5675;
--text3: #677290; --text3: #5C6785;
--accent: #1F6FF2; --accent: #1F6FF2;
--accent2: #1F6FF2; --accent2: #1F6FF2;
--accent-fill: #1A63DB;
--accent-fill2: #165ED6;
--accent-soft: rgba(31,111,242,.10); --accent-soft: rgba(31,111,242,.10);
--violet: #6A47F0; --violet: #6A47F0;
--violet-soft: rgba(106,71,240,.10); --violet-soft: rgba(106,71,240,.10);
@@ -90,9 +94,11 @@
--line2: rgba(20,35,80,.17); --line2: rgba(20,35,80,.17);
--text: #0A0F1E; --text: #0A0F1E;
--text2: #4B5675; --text2: #4B5675;
--text3: #677290; --text3: #5C6785;
--accent: #1F6FF2; --accent: #1F6FF2;
--accent2: #1F6FF2; --accent2: #1F6FF2;
--accent-fill: #1A63DB;
--accent-fill2: #165ED6;
--accent-soft: rgba(31,111,242,.10); --accent-soft: rgba(31,111,242,.10);
--violet: #6A47F0; --violet: #6A47F0;
--violet-soft: rgba(106,71,240,.10); --violet-soft: rgba(106,71,240,.10);
@@ -123,9 +129,11 @@
--line2: rgba(130,160,255,.18); --line2: rgba(130,160,255,.18);
--text: #EDF1FF; --text: #EDF1FF;
--text2: #A0ABC6; --text2: #A0ABC6;
--text3: #737F9C; --text3: #7F8BA8;
--accent: #2F80FF; --accent: #2F80FF;
--accent2: #5E9BFF; --accent2: #5E9BFF;
--accent-fill: #1A66E6;
--accent-fill2: #1F6FF2;
--accent-soft: rgba(47,128,255,.15); --accent-soft: rgba(47,128,255,.15);
--violet: #9B7CFF; --violet: #9B7CFF;
--violet-soft: rgba(124,92,255,.16); --violet-soft: rgba(124,92,255,.16);
@@ -13,9 +13,11 @@ export const tokens = {
"line2": "rgba(130,160,255,.18)", "line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF", "text": "#EDF1FF",
"text2": "#A0ABC6", "text2": "#A0ABC6",
"text3": "#737F9C", "text3": "#7F8BA8",
"accent": "#2F80FF", "accent": "#2F80FF",
"accent2": "#5E9BFF", "accent2": "#5E9BFF",
"accent-fill": "#1A66E6",
"accent-fill2": "#1F6FF2",
"accent-soft": "rgba(47,128,255,.15)", "accent-soft": "rgba(47,128,255,.15)",
"violet": "#9B7CFF", "violet": "#9B7CFF",
"violet-soft": "rgba(124,92,255,.16)", "violet-soft": "rgba(124,92,255,.16)",
@@ -42,9 +44,11 @@ export const tokens = {
"line2": "rgba(20,35,80,.17)", "line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E", "text": "#0A0F1E",
"text2": "#4B5675", "text2": "#4B5675",
"text3": "#677290", "text3": "#5C6785",
"accent": "#1F6FF2", "accent": "#1F6FF2",
"accent2": "#1F6FF2", "accent2": "#1F6FF2",
"accent-fill": "#1A63DB",
"accent-fill2": "#165ED6",
"accent-soft": "rgba(31,111,242,.10)", "accent-soft": "rgba(31,111,242,.10)",
"violet": "#6A47F0", "violet": "#6A47F0",
"violet-soft": "rgba(106,71,240,.10)", "violet-soft": "rgba(106,71,240,.10)",
+5 -5
View File
@@ -1,11 +1,11 @@
{ {
"$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate", "$comment": "Источник истины дизайн-токенов (docs/design.md). После правки: pnpm --filter @pipu/design-tokens generate. accent-fill — заливка под белым текстом (контраст ≥ 4.5:1, WCAG AA)",
"color": { "color": {
"dark": { "dark": {
"bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236", "bg": "#05070D", "bg2": "#080B14", "surface": "#0B0F1A", "surface2": "#121827", "surface3": "#1A2236",
"elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)", "elev": "#111726", "hover": "rgba(120,150,255,.07)", "line": "rgba(130,160,255,.10)", "line2": "rgba(130,160,255,.18)",
"text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#737F9C", "text": "#EDF1FF", "text2": "#A0ABC6", "text3": "#7F8BA8",
"accent": "#2F80FF", "accent2": "#5E9BFF", "accent-soft": "rgba(47,128,255,.15)", "accent": "#2F80FF", "accent2": "#5E9BFF", "accent-fill": "#1A66E6", "accent-fill2": "#1F6FF2", "accent-soft": "rgba(47,128,255,.15)",
"violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)", "violet": "#9B7CFF", "violet-soft": "rgba(124,92,255,.16)",
"ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)", "ok": "#2FD39A", "ok-soft": "rgba(47,211,154,.14)", "warn": "#FFB547", "warn-soft": "rgba(255,181,71,.14)",
"danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)", "danger": "#FF5C70", "danger-soft": "rgba(255,92,112,.14)",
@@ -14,8 +14,8 @@
"light": { "light": {
"bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3", "bg": "#F3F5FA", "bg2": "#EBEEF5", "surface": "#FFFFFF", "surface2": "#F0F3F9", "surface3": "#E4E9F3",
"elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)", "elev": "#FFFFFF", "hover": "rgba(30,60,140,.06)", "line": "rgba(20,35,80,.10)", "line2": "rgba(20,35,80,.17)",
"text": "#0A0F1E", "text2": "#4B5675", "text3": "#677290", "text": "#0A0F1E", "text2": "#4B5675", "text3": "#5C6785",
"accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-soft": "rgba(31,111,242,.10)", "accent": "#1F6FF2", "accent2": "#1F6FF2", "accent-fill": "#1A63DB", "accent-fill2": "#165ED6", "accent-soft": "rgba(31,111,242,.10)",
"violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)", "violet": "#6A47F0", "violet-soft": "rgba(106,71,240,.10)",
"ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)", "ok": "#0E9465", "ok-soft": "rgba(14,148,101,.10)", "warn": "#A86200", "warn-soft": "rgba(168,98,0,.10)",
"danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)", "danger": "#D32F47", "danger-soft": "rgba(211,47,71,.09)",
+3 -1
View File
@@ -158,7 +158,9 @@
"wave_sessions": "Sessions", "wave_sessions": "Sessions",
"wave_listen": "Played ≥ 50%", "wave_listen": "Played ≥ 50%",
"wave_early_skip": "Early skips", "wave_early_skip": "Early skips",
"wave_likes": "Likes per 100" "wave_likes": "Likes per 100",
"wave_model": "Model version",
"wave_by_model": "Wave model versions compared"
}, },
"write_template": { "write_template": {
"title": "Uploads and path template", "title": "Uploads and path template",
+2 -1
View File
@@ -81,7 +81,8 @@
"source_wave": "My Wave", "source_wave": "My Wave",
"source_queue": "Queue", "source_queue": "Queue",
"queue": "Queue", "queue": "Queue",
"source_wave_seed": "Artist wave · {{label}}" "source_wave_seed": "Artist wave · {{label}}",
"queue_wave": "The wave never ends: next tracks are picked as you listen"
}, },
"settings": { "settings": {
"title": "Settings", "title": "Settings",
+3 -1
View File
@@ -158,7 +158,9 @@
"wave_sessions": "Сессий", "wave_sessions": "Сессий",
"wave_listen": "Дослушано ≥ 50%", "wave_listen": "Дослушано ≥ 50%",
"wave_early_skip": "Ранние скипы", "wave_early_skip": "Ранние скипы",
"wave_likes": "Лайков на 100" "wave_likes": "Лайков на 100",
"wave_model": "Версия модели",
"wave_by_model": "Сравнение версий модели волны"
}, },
"write_template": { "write_template": {
"title": "Приём загрузок и шаблон пути", "title": "Приём загрузок и шаблон пути",
+2 -1
View File
@@ -81,7 +81,8 @@
"source_wave": "Моя волна", "source_wave": "Моя волна",
"source_queue": "Очередь", "source_queue": "Очередь",
"queue": "Очередь", "queue": "Очередь",
"source_wave_seed": "Волна по исполнителю · {{label}}" "source_wave_seed": "Волна по исполнителю · {{label}}",
"queue_wave": "Волна бесконечна: следующие треки подбираются по ходу прослушивания"
}, },
"settings": { "settings": {
"title": "Настройки", "title": "Настройки",
+8
View File
@@ -271,6 +271,14 @@ func TestUserJourney(t *testing.T) {
c.ok("GET", "/api/v1/me/likes", nil, &likes) c.ok("GET", "/api/v1/me/likes", nil, &likes)
require.Equal(t, 1, likes.Total) require.Equal(t, 1, likes.Total)
// Персональные подсказки: лайкнутый трек — первым среди треков альбома.
var sug struct {
Tracks []track `json:"tracks"`
}
c.ok("GET", "/api/v1/search/suggest?q="+url.QueryEscape("кино"), nil, &sug)
require.NotEmpty(t, sug.Tracks)
require.Equal(t, tr.ID, sug.Tracks[0].ID)
// 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается. // 7½. Онбординг: варианты из библиотеки, выбор сохраняется, второй раз не предлагается.
var onb struct { var onb struct {
Required bool `json:"required"` 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), Auth: auth.NewHandler(authSvc, cfg.CookieSecure),
AuthService: authSvc, AuthService: authSvc,
Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel), Library: library.NewHandler(libSvc, libStore, dto, covers, cfg.XAccel),
Search: search.NewHandler(meili, libStore, indexer, dto, log), Search: search.NewHandler(meili, libStore, indexer, search.NewAffinity(pool), dto, log),
Playback: pb, Playback: pb,
Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken, Wave: wave.NewHandler(pool, &wave.Recommender{URL: cfg.RecommenderURL, Token: cfg.InternalToken,
Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log), Client: &http.Client{}}, libStore, func() library.DTO { return dto }, log),
+252 -239
View File
@@ -1152,7 +1152,9 @@ type WaveFeedbackOutcome string
// WaveMetrics Метрики волны за 7 дней // WaveMetrics Метрики волны за 7 дней
type WaveMetrics struct { type WaveMetrics struct {
EarlySkipRate *float32 `json:"early_skip_rate"` // ByModel Те же метрики по версии модели показа (v1 / ALS / ранжировщик) — сравнение онлайн
ByModel []WaveModelMetrics `json:"by_model"`
EarlySkipRate *float32 `json:"early_skip_rate"`
// LikeRate Лайков на 100 треков // LikeRate Лайков на 100 треков
LikeRate *float32 `json:"like_rate"` LikeRate *float32 `json:"like_rate"`
@@ -1161,6 +1163,15 @@ type WaveMetrics struct {
Sessions int `json:"sessions"` Sessions int `json:"sessions"`
} }
// WaveModelMetrics defines model for WaveModelMetrics.
type WaveModelMetrics struct {
EarlySkipRate *float32 `json:"early_skip_rate"`
LikeRate *float32 `json:"like_rate"`
ListenThroughRate *float32 `json:"listen_through_rate"`
ModelVer string `json:"model_ver"`
Served int `json:"served"`
}
// WaveNext defines model for WaveNext. // WaveNext defines model for WaveNext.
type WaveNext struct { type WaveNext struct {
Feedback *[]WaveFeedback `json:"feedback,omitempty"` Feedback *[]WaveFeedback `json:"feedback,omitempty"`
@@ -9119,246 +9130,248 @@ func (sh *strictHandler) GetReadyz(w http.ResponseWriter, r *http.Request) {
// Base64 encoded, gzipped, json marshaled Swagger object // Base64 encoded, gzipped, json marshaled Swagger object
var swaggerSpec = []string{ var swaggerSpec = []string{
"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",
} }
// GetSwagger returns the content of the embedded swagger specification file // GetSwagger returns the content of the embedded swagger specification file
@@ -103,7 +103,8 @@ func TestLibraryAndUploads(t *testing.T) {
assert.True(t, src.AcceptsUploads) assert.True(t, src.AcceptsUploads)
require.NotNil(t, src.WriteTemplate) require.NotNil(t, src.WriteTemplate)
scanner := library.NewScanner(lib, fakeProber{}, library.NewProgressBoard(), log) board := library.NewProgressBoard()
scanner := library.NewScanner(lib, fakeProber{}, board, log)
var fileIDs []uuid.UUID var fileIDs []uuid.UUID
for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} { for _, rel := range []string{"Кино/Группа крови/Кино - Группа крови.flac", "Кино/Группа крови/Кино - Закрой за мной дверь.flac"} {
id, err := scanner.ScanFile(ctx, src, root, rel) id, err := scanner.ScanFile(ctx, src, root, rel)
@@ -114,6 +115,15 @@ func TestLibraryAndUploads(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, fileIDs[0], again, "rescan keeps the file row") assert.Equal(t, fileIDs[0], again, "rescan keeps the file row")
// Пустой каталог при известных файлах (диск не смонтирован) — скан прерывается и ничего не помечает пропавшим.
require.NoError(t, scanner.Start(src, t.TempDir(), false))
require.Eventually(t, func() bool { p, ok := board.Get(src.ID); return ok && !p.Running }, 10*time.Second, 50*time.Millisecond)
p, _ := board.Get(src.ID)
assert.Contains(t, p.Error, "is the disk mounted")
var missing int
require.NoError(t, pool.QueryRow(ctx, `SELECT count(*) FROM media_files WHERE source_id = $1 AND missing_since IS NOT NULL`, src.ID).Scan(&missing))
assert.Zero(t, missing)
// --- Отпечатки --- // --- Отпечатки ---
todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10) todo, err := lib.FilesToFingerprint(ctx, true, uuid.Nil, 10)
require.NoError(t, err) require.NoError(t, err)
@@ -198,6 +198,7 @@ func (fp *Fingerprinter) chromaprint() bool {
func (fp *Fingerprinter) Run(ctx context.Context) { func (fp *Fingerprinter) Run(ctx context.Context) {
withFP := fp.chromaprint() withFP := fp.chromaprint()
roots := map[uuid.UUID]string{} roots := map[uuid.UUID]string{}
unavailable := map[uuid.UUID]bool{} // каталог источника пуст или не открывается — диск не смонтирован
var after uuid.UUID var after uuid.UUID
for ctx.Err() == nil { for ctx.Err() == nil {
todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200) todo, err := fp.store.FilesToFingerprint(ctx, withFP, after, 200)
@@ -213,6 +214,9 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
return return
} }
after = f.ID after = f.ID
if unavailable[f.SourceID] {
continue
}
root, ok := roots[f.SourceID] root, ok := roots[f.SourceID]
if !ok { if !ok {
if root, err = fp.rootOf(ctx, f.SourceID); err != nil { if root, err = fp.rootOf(ctx, f.SourceID); err != nil {
@@ -220,12 +224,31 @@ func (fp *Fingerprinter) Run(ctx context.Context) {
continue continue
} }
roots[f.SourceID] = root roots[f.SourceID] = root
if empty, err := dirEmpty(root); empty || err != nil {
fp.log.Warn("fingerprints: source folder is empty or unreadable, skipped — is the disk mounted?",
"source_id", f.SourceID, "root", root, "err", err)
unavailable[f.SourceID] = true
continue
}
} }
fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP) fp.process(ctx, f, filepath.Join(root, filepath.FromSlash(f.RelPath)), withFP)
} }
} }
} }
func dirEmpty(dir string) (bool, error) {
d, err := os.Open(dir)
if err != nil {
return false, err
}
defer func() { _ = d.Close() }()
names, err := d.Readdirnames(1)
if errors.Is(err, io.EOF) {
return true, nil
}
return len(names) == 0, err
}
func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) { func (fp *Fingerprinter) process(ctx context.Context, f FingerprintTodo, abs string, withFP bool) {
sum := f.SHA256 sum := f.SHA256
if sum == nil { if sum == nil {
+7 -1
View File
@@ -308,9 +308,15 @@ func (h *Handler) GetLibraryStatus(ctx context.Context, _ gen.GetLibraryStatusRe
}, },
Wave: gen.WaveMetrics{ Wave: gen.WaveMetrics{
Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough, Served: st.Wave.Served, Sessions: st.Wave.Sessions, ListenThroughRate: st.Wave.ListenThrough,
EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate, EarlySkipRate: st.Wave.EarlySkip, LikeRate: st.Wave.LikeRate, ByModel: []gen.WaveModelMetrics{},
}, },
} }
for _, m := range st.Wave.ByModel {
out.Wave.ByModel = append(out.Wave.ByModel, gen.WaveModelMetrics{
ModelVer: m.ModelVer, Served: m.Served, ListenThroughRate: m.ListenThrough,
EarlySkipRate: m.EarlySkip, LikeRate: m.LikeRate,
})
}
for _, s := range st.Sources { for _, s := range st.Sources {
dto := h.sourceDTO(s) dto := h.sourceDTO(s)
out.Sources = append(out.Sources, gen.SourceStatusItem{ out.Sources = append(out.Sources, gen.SourceStatusItem{
+17
View File
@@ -119,6 +119,15 @@ type walked struct {
hash []byte hash []byte
} }
func hasPresent(files map[string]FileRow) bool {
for _, f := range files {
if !f.Missing {
return true
}
}
return false
}
// job — файл, который нужно прочитать. // job — файл, который нужно прочитать.
type job struct { type job struct {
walked walked
@@ -135,6 +144,11 @@ type probed struct {
// ErrScanRunning — скан источника уже идёт. // ErrScanRunning — скан источника уже идёт.
var ErrScanRunning = errors.New("library: scan already running") var ErrScanRunning = errors.New("library: scan already running")
// ErrSourceEmpty — в каталоге источника нет ни одного файла, а в библиотеке они есть: скорее всего, диск
// не смонтирован. Скан прерывается, чтобы не пометить всю библиотеку отсутствующей (и через MissingRetention —
// не удалить треки вместе с историей).
var ErrSourceEmpty = errors.New("library: source folder is empty but the library has its files — is the disk mounted?")
// Start запускает скан в фоне. root — каталог источника в файловой системе api. // Start запускает скан в фоне. root — каталог источника в файловой системе api.
func (sc *Scanner) Start(src Source, root string, full bool) error { func (sc *Scanner) Start(src Source, root string, full bool) error {
if !sc.board.start(src.ID, full, sc.now()) { if !sc.board.start(src.ID, full, sc.now()) {
@@ -192,6 +206,9 @@ func (sc *Scanner) run(ctx context.Context, src Source, root string, full bool)
if err != nil { if err != nil {
return err return err
} }
if len(found) == 0 && hasPresent(existing) {
return ErrSourceEmpty
}
sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) }) sc.board.update(src.ID, func(p *Progress) { p.Found = len(found) })
var ( var (
+47 -9
View File
@@ -624,10 +624,36 @@ func (st *Store) AnalysisCounts(ctx context.Context) (AnalysisCounts, error) {
// WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех). // WaveMetrics — метрики волны за 7 дней (docs/recommendations.md#1-цели-и-как-измеряем-успех).
type WaveMetrics struct { type WaveMetrics struct {
Served, Sessions int Served, Sessions int
WaveRates
ByModel []WaveModelMetrics
}
// WaveRates — доли по показам с известным исходом (nil — исходов ещё нет).
type WaveRates struct {
ListenThrough, EarlySkip, LikeRate *float32 ListenThrough, EarlySkip, LikeRate *float32
} }
// WaveModelMetrics — метрики показов одной версии модели.
type WaveModelMetrics struct {
ModelVer string
Served int
WaveRates
}
func rates(judged, through, early, liked int) WaveRates {
if judged == 0 {
return WaveRates{}
}
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
return WaveRates{ListenThrough: &lt, EarlySkip: &es, LikeRate: &lr}
}
const waveOutcomes = `count(outcome),
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
count(*) FILTER (WHERE outcome = 'like')`
// WaveMetrics считает метрики по показам с известным исходом. // WaveMetrics считает метрики по показам с известным исходом.
func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) { func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
var ( var (
@@ -635,20 +661,32 @@ func (st *Store) WaveMetrics(ctx context.Context) (WaveMetrics, error) {
judged, through, early, liked int judged, through, early, liked int
) )
err := st.pool.QueryRow(ctx, ` err := st.pool.QueryRow(ctx, `
SELECT count(*), count(DISTINCT wave_session_id), count(outcome), SELECT count(*), count(DISTINCT wave_session_id), `+waveOutcomes+`
count(*) FILTER (WHERE outcome IN ('complete', 'like')),
count(*) FILTER (WHERE outcome IN ('skip_early', 'dislike')),
count(*) FILTER (WHERE outcome = 'like')
FROM wave_impressions WHERE served_at > now() - interval '7 days'`). FROM wave_impressions WHERE served_at > now() - interval '7 days'`).
Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked) Scan(&m.Served, &m.Sessions, &judged, &through, &early, &liked)
if err != nil { if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err) return WaveMetrics{}, fmt.Errorf("library: wave metrics: %w", err)
} }
if judged > 0 { m.WaveRates = rates(judged, through, early, liked)
lt, es, lr := float32(through)/float32(judged), float32(early)/float32(judged), float32(liked)*100/float32(judged)
m.ListenThrough, m.EarlySkip, m.LikeRate = &lt, &es, &lr rows, err := st.pool.Query(ctx, `
SELECT coalesce(model_ver, ''), count(*), `+waveOutcomes+`
FROM wave_impressions WHERE served_at > now() - interval '7 days'
GROUP BY 1 ORDER BY 2 DESC`)
if err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
} }
return m, nil defer rows.Close()
m.ByModel = []WaveModelMetrics{}
for rows.Next() {
var mm WaveModelMetrics
if err := rows.Scan(&mm.ModelVer, &mm.Served, &judged, &through, &early, &liked); err != nil {
return WaveMetrics{}, fmt.Errorf("library: wave metrics by model: %w", err)
}
mm.WaveRates = rates(judged, through, early, liked)
m.ByModel = append(m.ByModel, mm)
}
return m, rows.Err()
} }
// FingerprintTodo — файл, которому нужны отпечатки. // FingerprintTodo — файл, которому нужны отпечатки.
+83
View File
@@ -0,0 +1,83 @@
package search
import (
"context"
"math"
"sort"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
)
// suggestPool — во сколько раз больше кандидатов берётся из Meilisearch для переранжирования подсказок.
const suggestPool = 3
// Affinity — насколько пользователь «знает» исполнителей, альбомы и треки: прослушивания, доигрывания,
// лайки и дизлайки. Отрицательное — не любит. Нужен для персональных подсказок поиска.
type Affinity struct{ pool *pgxpool.Pool }
// NewAffinity создаёт источник предпочтений.
func NewAffinity(pool *pgxpool.Pool) *Affinity { return &Affinity{pool: pool} }
// Of возвращает вес для каждого из ids, о котором что-то известно.
func (a *Affinity) Of(ctx context.Context, user uuid.UUID, ids []uuid.UUID) (map[uuid.UUID]float64, error) {
rows, err := a.pool.Query(ctx, `
WITH s AS (
SELECT track_id, sum(w) AS w FROM (
SELECT track_id, (play_count + 2 * complete_count - early_skip_count)::float8 AS w
FROM user_track_stats WHERE user_id = $1
UNION ALL
SELECT track_id, CASE reaction WHEN 'like' THEN 10 ELSE -10 END FROM track_reactions WHERE user_id = $1
) x GROUP BY track_id
)
SELECT track_id, w FROM s WHERE track_id = ANY($2)
UNION ALL
SELECT t.album_id, sum(s.w) FROM s JOIN tracks t ON t.id = s.track_id WHERE t.album_id = ANY($2) GROUP BY t.album_id
UNION ALL
SELECT ta.artist_id, sum(s.w) FROM s JOIN track_artists ta ON ta.track_id = s.track_id
WHERE ta.artist_id = ANY($2) GROUP BY ta.artist_id
UNION ALL
SELECT artist_id, CASE reaction WHEN 'like' THEN 20 ELSE -20 END FROM artist_reactions
WHERE user_id = $1 AND artist_id = ANY($2)`, user, ids)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[uuid.UUID]float64{}
for rows.Next() {
var id uuid.UUID
var w float64
if err := rows.Scan(&id, &w); err != nil {
return nil, err
}
out[id] += w
}
return out, rows.Err()
}
// personalize переставляет найденное: знакомое и любимое поднимается на несколько позиций, нелюбимое
// опускается; релевантность Meilisearch остаётся основой порядка. Возвращает не больше n.
func personalize(found []string, aff map[uuid.UUID]float64, n int) []string {
type item struct {
id string
key float64
}
items := make([]item, len(found))
for i, s := range found {
key := float64(i)
if id, err := uuid.Parse(s); err == nil {
if w := aff[id]; w > 0 {
key -= 2 * math.Log1p(w)
} else if w < 0 {
key += 3
}
}
items[i] = item{s, key}
}
sort.SliceStable(items, func(i, j int) bool { return items[i].key < items[j].key })
out := make([]string, 0, min(n, len(items)))
for _, it := range items[:min(n, len(items))] {
out = append(out, it.id)
}
return out
}
+29 -3
View File
@@ -28,12 +28,15 @@ type Handler struct {
meili *Meili meili *Meili
catalog *library.Store catalog *library.Store
indexer *Indexer indexer *Indexer
aff *Affinity
log *slog.Logger log *slog.Logger
} }
// NewHandler создаёт обработчик поиска. // NewHandler создаёт обработчик поиска.
func NewHandler(meili *Meili, catalog *library.Store, indexer *Indexer, dto library.DTO, log *slog.Logger) *Handler { func NewHandler(
return &Handler{meili: meili, catalog: catalog, indexer: indexer, dto: dto, log: log} meili *Meili, catalog *library.Store, indexer *Indexer, aff *Affinity, dto library.DTO, log *slog.Logger,
) *Handler {
return &Handler{meili: meili, catalog: catalog, indexer: indexer, aff: aff, dto: dto, log: log}
} }
type limits struct{ artists, albums, tracks, offset int } type limits struct{ artists, albums, tracks, offset int }
@@ -150,10 +153,16 @@ func (h *Handler) hydrate(ctx context.Context, f found, corrected string) (gen.S
// SearchSuggest реализует gen.StrictServerInterface. // SearchSuggest реализует gen.StrictServerInterface.
func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) { func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestRequestObject) (gen.SearchSuggestResponseObject, error) {
f, corrected, err := h.run(ctx, req.Params.Q, limits{artists: 3, albums: 3, tracks: 5}) want := limits{artists: 3, albums: 3, tracks: 5}
l := want
if h.aff != nil {
l = limits{artists: want.artists * suggestPool, albums: want.albums * suggestPool, tracks: want.tracks * suggestPool}
}
f, corrected, err := h.run(ctx, req.Params.Q, l)
if err != nil { if err != nil {
return nil, err return nil, err
} }
f = h.personal(ctx, f, want)
out, err := h.hydrate(ctx, f, corrected) out, err := h.hydrate(ctx, f, corrected)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -161,6 +170,23 @@ func (h *Handler) SearchSuggest(ctx context.Context, req gen.SearchSuggestReques
return gen.SearchSuggest200JSONResponse(out), nil return gen.SearchSuggest200JSONResponse(out), nil
} }
// personal оставляет в подсказках по want.* лучших с учётом вкуса пользователя.
// Без пользователя или при сбое — просто первые по релевантности.
func (h *Handler) personal(ctx context.Context, f found, want limits) found {
var aff map[uuid.UUID]float64
if p, ok := auth.PrincipalFrom(ctx); ok && h.aff != nil {
all := ids(append(append(append([]string{}, f.artists...), f.albums...), f.tracks...))
var err error
if aff, err = h.aff.Of(ctx, p.UserID, all); err != nil {
h.log.WarnContext(ctx, "suggest personalization failed", "err", err)
}
}
f.artists = personalize(f.artists, aff, want.artists)
f.albums = personalize(f.albums, aff, want.albums)
f.tracks = personalize(f.tracks, aff, want.tracks)
return f
}
// Search реализует gen.StrictServerInterface. // Search реализует gen.StrictServerInterface.
func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) { func (h *Handler) Search(ctx context.Context, req gen.SearchRequestObject) (gen.SearchResponseObject, error) {
typ := gen.All typ := gen.All
@@ -3,6 +3,8 @@ package search
import ( import (
"testing" "testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@@ -20,3 +22,14 @@ func TestSwitchLayout(t *testing.T) {
assert.Equal(t, "кино", SwitchLayout("rbyj")) assert.Equal(t, "кино", SwitchLayout("rbyj"))
assert.Equal(t, "", SwitchLayout("123")) assert.Equal(t, "", SwitchLayout("123"))
} }
func TestPersonalizeLiftsFamiliarAndSinksDisliked(t *testing.T) {
liked, disliked := uuid.New(), uuid.New()
other := []string{uuid.NewString(), uuid.NewString(), uuid.NewString()}
found := []string{other[0], disliked.String(), other[1], other[2], liked.String()}
aff := map[uuid.UUID]float64{liked: 30, disliked: -10}
got := personalize(found, aff, 3)
assert.Equal(t, []string{liked.String(), other[0], other[1]}, got)
assert.Equal(t, found[:2], personalize(found, nil, 2), "без вкуса — порядок релевантности")
}
+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 });
}