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